diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 0d17aca..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(flutter analyze:*)", - "Bash(find:*)", - "Bash(flutter pub:*)", - "Bash(flutter packages pub run build_runner build:*)", - "Bash(flutter run:*)", - "Bash(flutter build:*)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\widgets\\cards/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\controllers/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\bindings/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\android\\app\\src\\main/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\bindings/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\controllers/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\routes/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\middlewares/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\profile/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\controllers\\auth/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\bindings/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\bindings/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\profile/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\profile/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\bindings/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\controllers\\auth/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\controllers\\auth/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\bindings/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\routes/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\middlewares/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\controllers/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\routes/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\routes/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\bindings/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Read(/C:\\Users\\ravi7\\OneDrive\\Documents\\GitHub\\stays-app\\lib\\app\\ui\\views\\home/**)", - "Bash(flutter clean:*)", - "Bash(curl:*)", - "Bash(printf \"R\")", - "Bash(flutter test:*)" - ], - "deny": [], - "ask": [] - }, - "hooks": { - "PreToolUse": [ - { - "matcher": "WebSearch", - "hooks": [ - { - "type": "command", - "command": "python3 -c \"import json, sys, re; from datetime import datetime; input_data = json.load(sys.stdin); tool_name = input_data.get('tool_name', ''); tool_input = input_data.get('tool_input', {}); query = tool_input.get('query', ''); current_year = datetime.now().year; year_pattern = r'\\\\b(20\\\\d{2})\\\\b'; years_found = re.findall(year_pattern, query); outdated_years = [year for year in years_found if int(year) < current_year]; output = {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'additionalContext': f'Current date: {datetime.now().strftime(\\\"%Y-%m-%d\\\")}. The current year is {current_year}. If searching for recent information, consider using {current_year} instead of older years.'}} if outdated_years else None; print(json.dumps(output) if output else ''); sys.exit(0)\"", - "timeout": 5 - } - ] - } - ] - } -} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d10a772..34d8af3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -189,6 +189,7 @@ jobs: - name: Comment on PR with build status if: github.event_name == 'pull_request' uses: actions/github-script@v7 + continue-on-error: true with: script: | const artifactName = '${{ steps.artifact-name.outputs.name }}'; diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f5e2db3..ac55b77 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,17 +1,17 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") - // Apply Google Services plugin here (only once, not at bottom) + // Google Services plugin id("com.google.gms.google-services") // Firebase Crashlytics id("com.google.firebase.crashlytics") } -import java.util.Properties -import java.io.FileInputStream - // Load key.properties for release signing val keystorePropertiesFile = rootProject.file("key.properties") val keystoreProperties = Properties() @@ -22,11 +22,12 @@ if (keystorePropertiesFile.exists()) { android { namespace = "com.a360ghar.stays" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -52,13 +53,13 @@ android { versionName = flutter.versionName } - // 🔹 Flavor setup + // Flavor setup flavorDimensions += listOf("env") productFlavors { create("dev") { dimension = "env" - applicationIdSuffix = ".dev" + applicationId = "com.example.stays_app.dev" resValue("string", "app_name", "360ghar stays (Dev)") } create("staging") { @@ -93,7 +94,7 @@ android { } } - // 🔹 Automatically pick correct google-services.json based on flavor + // Automatically pick correct google-services.json based on flavor sourceSets { getByName("dev") { res.srcDirs("src/dev/res") @@ -113,6 +114,11 @@ android { } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") + implementation("com.google.android.play:core:1.10.3") +} + flutter { source = "../.." } diff --git a/android/app/google-services.json b/android/app/google-services.json index f2319c9..a6bc9bc 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -5,6 +5,25 @@ "storage_bucket": "stays-app-52ca6.firebasestorage.app" }, "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:866676409773:android:e7434edd703cfea8e14d24", + "android_client_info": { + "package_name": "com.a360ghar.stays" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBcOC2oanpFWvLmIqlsKxNm81LSnyKieeQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, { "client_info": { "mobilesdk_app_id": "1:866676409773:android:e7434edd703cfea8e14d24", @@ -23,6 +42,44 @@ "other_platform_oauth_client": [] } } + }, + { + "client_info": { + "mobilesdk_app_id": "1:866676409773:android:e7434edd703cfea8e14d24", + "android_client_info": { + "package_name": "com.example.stays_app.dev" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBcOC2oanpFWvLmIqlsKxNm81LSnyKieeQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:866676409773:android:e7434edd703cfea8e14d24", + "android_client_info": { + "package_name": "com.a360ghar.stays.staging" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBcOC2oanpFWvLmIqlsKxNm81LSnyKieeQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } } ], "configuration_version": "1" diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 494875f..b3bbfed 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -58,3 +58,9 @@ # Keep secure storage -keep class com.it_nomads.fluttersecurestorage.** { *; } + +# Play Core / SplitCompat (not always included but referenced by Flutter embedding) +-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication +-dontwarn com.google.android.play.core.tasks.OnFailureListener +-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener +-dontwarn com.google.android.play.core.listener.StateUpdatedListener diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0d2cfad..96e841d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + (PlacesService()); Get.put( AnalyticsService(enabled: AppConfig.I.enableAnalytics), + permanent: true, ); // Property cache service for offline support diff --git a/lib/app/controllers/favorites_controller.dart b/lib/app/controllers/favorites_controller.dart index 92f747e..6ca6e58 100644 --- a/lib/app/controllers/favorites_controller.dart +++ b/lib/app/controllers/favorites_controller.dart @@ -1,8 +1,6 @@ import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; - -class FavoritesController extends BaseController { +class FavoritesController extends GetxController { FavoritesController(); final RxSet favoriteIds = {}.obs; diff --git a/lib/app/controllers/filter_controller.dart b/lib/app/controllers/filter_controller.dart index 8959238..4af7bd0 100644 --- a/lib/app/controllers/filter_controller.dart +++ b/lib/app/controllers/filter_controller.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import '../data/models/unified_filter_model.dart'; import '../ui/widgets/filters/property_filter_sheet.dart'; enum FilterScope { explore, wishlist, booking, locate } -class FilterController extends BaseController { +class FilterController extends GetxController { FilterController(); final Map> _filters = { diff --git a/lib/app/data/models/api_response_models.dart b/lib/app/data/models/api_response_models.dart index 43f9f03..d1945bd 100644 --- a/lib/app/data/models/api_response_models.dart +++ b/lib/app/data/models/api_response_models.dart @@ -1,25 +1,13 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'api_response_models.g.dart'; - -@JsonSerializable() class NotificationSettings { - @JsonKey(defaultValue: true) final bool pushEnabled; - @JsonKey(defaultValue: true) final bool emailEnabled; - @JsonKey(defaultValue: false) final bool smsEnabled; - @JsonKey(defaultValue: true) final bool bookingUpdates; - @JsonKey(defaultValue: false) final bool promotions; - @JsonKey(defaultValue: true) final bool messages; - @JsonKey(defaultValue: true) final bool reminders; - const NotificationSettings({ + NotificationSettings({ this.pushEnabled = true, this.emailEnabled = true, this.smsEnabled = false, @@ -29,28 +17,38 @@ class NotificationSettings { this.reminders = true, }); - factory NotificationSettings.fromJson(Map json) => - _$NotificationSettingsFromJson(json); + factory NotificationSettings.fromJson(Map json) { + return NotificationSettings( + pushEnabled: json['pushEnabled'] ?? true, + emailEnabled: json['emailEnabled'] ?? true, + smsEnabled: json['smsEnabled'] ?? false, + bookingUpdates: json['bookingUpdates'] ?? true, + promotions: json['promotions'] ?? false, + messages: json['messages'] ?? true, + reminders: json['reminders'] ?? true, + ); + } - Map toJson() => _$NotificationSettingsToJson(this); + Map toJson() => { + 'pushEnabled': pushEnabled, + 'emailEnabled': emailEnabled, + 'smsEnabled': smsEnabled, + 'bookingUpdates': bookingUpdates, + 'promotions': promotions, + 'messages': messages, + 'reminders': reminders, + }; } -@JsonSerializable() class PrivacySettings { - @JsonKey(defaultValue: true) final bool profilePublic; - @JsonKey(defaultValue: false) final bool showEmail; - @JsonKey(defaultValue: false) final bool showPhone; - @JsonKey(defaultValue: true) final bool showListings; - @JsonKey(defaultValue: true) final bool showReviews; - @JsonKey(defaultValue: true) final bool allowMessages; - const PrivacySettings({ + PrivacySettings({ this.profilePublic = true, this.showEmail = false, this.showPhone = false, @@ -59,8 +57,23 @@ class PrivacySettings { this.allowMessages = true, }); - factory PrivacySettings.fromJson(Map json) => - _$PrivacySettingsFromJson(json); + factory PrivacySettings.fromJson(Map json) { + return PrivacySettings( + profilePublic: json['profilePublic'] ?? true, + showEmail: json['showEmail'] ?? false, + showPhone: json['showPhone'] ?? false, + showListings: json['showListings'] ?? true, + showReviews: json['showReviews'] ?? true, + allowMessages: json['allowMessages'] ?? true, + ); + } - Map toJson() => _$PrivacySettingsToJson(this); + Map toJson() => { + 'profilePublic': profilePublic, + 'showEmail': showEmail, + 'showPhone': showPhone, + 'showListings': showListings, + 'showReviews': showReviews, + 'allowMessages': allowMessages, + }; } diff --git a/lib/app/data/models/api_response_models.g.dart b/lib/app/data/models/api_response_models.g.dart deleted file mode 100644 index fffcd8b..0000000 --- a/lib/app/data/models/api_response_models.g.dart +++ /dev/null @@ -1,51 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'api_response_models.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NotificationSettings _$NotificationSettingsFromJson( - Map json, -) => NotificationSettings( - pushEnabled: json['pushEnabled'] as bool? ?? true, - emailEnabled: json['emailEnabled'] as bool? ?? true, - smsEnabled: json['smsEnabled'] as bool? ?? false, - bookingUpdates: json['bookingUpdates'] as bool? ?? true, - promotions: json['promotions'] as bool? ?? false, - messages: json['messages'] as bool? ?? true, - reminders: json['reminders'] as bool? ?? true, -); - -Map _$NotificationSettingsToJson( - NotificationSettings instance, -) => { - 'pushEnabled': instance.pushEnabled, - 'emailEnabled': instance.emailEnabled, - 'smsEnabled': instance.smsEnabled, - 'bookingUpdates': instance.bookingUpdates, - 'promotions': instance.promotions, - 'messages': instance.messages, - 'reminders': instance.reminders, -}; - -PrivacySettings _$PrivacySettingsFromJson(Map json) => - PrivacySettings( - profilePublic: json['profilePublic'] as bool? ?? true, - showEmail: json['showEmail'] as bool? ?? false, - showPhone: json['showPhone'] as bool? ?? false, - showListings: json['showListings'] as bool? ?? true, - showReviews: json['showReviews'] as bool? ?? true, - allowMessages: json['allowMessages'] as bool? ?? true, - ); - -Map _$PrivacySettingsToJson(PrivacySettings instance) => - { - 'profilePublic': instance.profilePublic, - 'showEmail': instance.showEmail, - 'showPhone': instance.showPhone, - 'showListings': instance.showListings, - 'showReviews': instance.showReviews, - 'allowMessages': instance.allowMessages, - }; diff --git a/lib/app/data/models/booking_model.dart b/lib/app/data/models/booking_model.dart index fb28b58..1cc0def 100644 --- a/lib/app/data/models/booking_model.dart +++ b/lib/app/data/models/booking_model.dart @@ -1,43 +1,25 @@ -import 'package:json_annotation/json_annotation.dart'; import 'package:stays_app/app/data/models/property_model.dart'; -part 'booking_model.g.dart'; - -@JsonSerializable(createFactory: false) class Booking { final int id; - @JsonKey(name: 'property_id') final int propertyId; - @JsonKey(name: 'user_id') final int userId; - @JsonKey(name: 'booking_reference') final String bookingReference; - @JsonKey(name: 'check_in_date') final DateTime checkInDate; - @JsonKey(name: 'check_out_date') final DateTime checkOutDate; final int guests; final int nights; - @JsonKey(name: 'total_amount') final double totalAmount; - @JsonKey(name: 'booking_status') final String bookingStatus; - @JsonKey(name: 'payment_status') final String paymentStatus; - @JsonKey(name: 'created_at') final DateTime createdAt; - @JsonKey(includeToJson: false) final Property? property; - @JsonKey(name: 'property_title') final String? propertyTitle; - @JsonKey(name: 'property_city') final String? propertyCity; - @JsonKey(name: 'property_country') final String? propertyCountry; - @JsonKey(name: 'property_image_url') final String? propertyImageUrl; - const Booking({ + Booking({ required this.id, required this.propertyId, required this.userId, @@ -57,7 +39,6 @@ class Booking { this.propertyImageUrl, }); - // Custom fromJson to handle complex API response variations factory Booking.fromJson(Map json) { final checkIn = json['check_in_date'] != null ? DateTime.parse(json['check_in_date'] as String) @@ -140,17 +121,28 @@ class Booking { return (city ?? country) ?? ''; } - Map toJson() { - final result = _$BookingToJson(this); - if (property != null) { - result['property'] = property!.toJson(); - } - return result; + Map toMap() { + return { + 'id': id, + 'property_id': propertyId, + 'user_id': userId, + 'booking_reference': bookingReference, + 'check_in_date': checkInDate.toIso8601String(), + 'check_out_date': checkOutDate.toIso8601String(), + 'guests': guests, + 'nights': nights, + 'total_amount': totalAmount, + 'booking_status': bookingStatus, + 'payment_status': paymentStatus, + 'created_at': createdAt.toIso8601String(), + 'property_title': propertyTitle, + 'property_city': propertyCity, + 'property_country': propertyCountry, + 'property_image_url': propertyImageUrl, + if (property != null) 'property': property!.toJson(), + }; } - // Backwards compatibility - Map toMap() => toJson(); - static Property? _safePropertyFromJson(Map value) { try { final mapped = value.map((key, val) => MapEntry(key.toString(), val)); diff --git a/lib/app/data/models/booking_model.g.dart b/lib/app/data/models/booking_model.g.dart deleted file mode 100644 index a0db074..0000000 --- a/lib/app/data/models/booking_model.g.dart +++ /dev/null @@ -1,29 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'booking_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Map _$BookingToJson(Booking instance) => { - 'id': instance.id, - 'property_id': instance.propertyId, - 'user_id': instance.userId, - 'booking_reference': instance.bookingReference, - 'check_in_date': instance.checkInDate.toIso8601String(), - 'check_out_date': instance.checkOutDate.toIso8601String(), - 'guests': instance.guests, - 'nights': instance.nights, - 'total_amount': instance.totalAmount, - 'booking_status': instance.bookingStatus, - 'payment_status': instance.paymentStatus, - 'created_at': instance.createdAt.toIso8601String(), - 'property_title': instance.propertyTitle, - 'property_city': instance.propertyCity, - 'property_country': instance.propertyCountry, - 'property_image_url': instance.propertyImageUrl, - 'displayTitle': instance.displayTitle, - 'displayImage': instance.displayImage, - 'displayLocation': instance.displayLocation, -}; diff --git a/lib/app/data/models/booking_pricing_model.dart b/lib/app/data/models/booking_pricing_model.dart index 98b87ee..82c5b61 100644 --- a/lib/app/data/models/booking_pricing_model.dart +++ b/lib/app/data/models/booking_pricing_model.dart @@ -1,18 +1,8 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'booking_pricing_model.g.dart'; - -@JsonSerializable() class BookingPricingModel { - @JsonKey(name: 'base_amount', fromJson: _numToDouble) final double baseAmount; - @JsonKey(name: 'taxes_amount', fromJson: _numToDouble) final double taxesAmount; - @JsonKey(name: 'service_charges', fromJson: _numToDouble) final double serviceCharges; - @JsonKey(name: 'discount_amount', fromJson: _optionalDouble) final double? discountAmount; - @JsonKey(name: 'total_amount', fromJson: _numToDouble) final double totalAmount; final int? nights; @@ -25,14 +15,16 @@ class BookingPricingModel { this.nights, }); - factory BookingPricingModel.fromJson(Map json) => - _$BookingPricingModelFromJson(json); - - Map toJson() => _$BookingPricingModelToJson(this); - - // Backwards compatibility - factory BookingPricingModel.fromMap(Map map) => - BookingPricingModel.fromJson(map); + factory BookingPricingModel.fromMap(Map map) { + return BookingPricingModel( + baseAmount: _numToDouble(map['base_amount']), + taxesAmount: _numToDouble(map['taxes_amount']), + serviceCharges: _numToDouble(map['service_charges']), + discountAmount: _optionalDouble(map['discount_amount']), + totalAmount: _numToDouble(map['total_amount']), + nights: _optionalInt(map['nights']), + ); + } static double _numToDouble(dynamic value) { if (value is num) return value.toDouble(); @@ -49,4 +41,12 @@ class BookingPricingModel { if (value is String) return double.tryParse(value); return null; } + + static int? _optionalInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } } diff --git a/lib/app/data/models/booking_pricing_model.g.dart b/lib/app/data/models/booking_pricing_model.g.dart deleted file mode 100644 index 0978143..0000000 --- a/lib/app/data/models/booking_pricing_model.g.dart +++ /dev/null @@ -1,30 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'booking_pricing_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -BookingPricingModel _$BookingPricingModelFromJson(Map json) => - BookingPricingModel( - baseAmount: BookingPricingModel._numToDouble(json['base_amount']), - taxesAmount: BookingPricingModel._numToDouble(json['taxes_amount']), - serviceCharges: BookingPricingModel._numToDouble(json['service_charges']), - totalAmount: BookingPricingModel._numToDouble(json['total_amount']), - discountAmount: BookingPricingModel._optionalDouble( - json['discount_amount'], - ), - nights: (json['nights'] as num?)?.toInt(), - ); - -Map _$BookingPricingModelToJson( - BookingPricingModel instance, -) => { - 'base_amount': instance.baseAmount, - 'taxes_amount': instance.taxesAmount, - 'service_charges': instance.serviceCharges, - 'discount_amount': instance.discountAmount, - 'total_amount': instance.totalAmount, - 'nights': instance.nights, -}; diff --git a/lib/app/data/models/location_model.dart b/lib/app/data/models/location_model.dart index 975bebc..4d1d7f8 100644 --- a/lib/app/data/models/location_model.dart +++ b/lib/app/data/models/location_model.dart @@ -1,14 +1,7 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'location_model.g.dart'; - -@JsonSerializable() class LocationModel { final String city; final String country; - @JsonKey(defaultValue: 0.0) final double lat; - @JsonKey(defaultValue: 0.0) final double lng; const LocationModel({ @@ -18,14 +11,17 @@ class LocationModel { required this.lng, }); - factory LocationModel.fromJson(Map json) => - _$LocationModelFromJson(json); - - Map toJson() => _$LocationModelToJson(this); - - // Backwards compatibility - factory LocationModel.fromMap(Map map) => - LocationModel.fromJson(map); + factory LocationModel.fromMap(Map map) => LocationModel( + city: map['city'] as String? ?? '', + country: map['country'] as String? ?? '', + lat: (map['lat'] as num?)?.toDouble() ?? 0, + lng: (map['lng'] as num?)?.toDouble() ?? 0, + ); - Map toMap() => toJson(); + Map toMap() => { + 'city': city, + 'country': country, + 'lat': lat, + 'lng': lng, + }; } diff --git a/lib/app/data/models/location_model.g.dart b/lib/app/data/models/location_model.g.dart deleted file mode 100644 index 4b98f39..0000000 --- a/lib/app/data/models/location_model.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'location_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LocationModel _$LocationModelFromJson(Map json) => - LocationModel( - city: json['city'] as String, - country: json['country'] as String, - lat: (json['lat'] as num?)?.toDouble() ?? 0.0, - lng: (json['lng'] as num?)?.toDouble() ?? 0.0, - ); - -Map _$LocationModelToJson(LocationModel instance) => - { - 'city': instance.city, - 'country': instance.country, - 'lat': instance.lat, - 'lng': instance.lng, - }; diff --git a/lib/app/data/models/message_model.dart b/lib/app/data/models/message_model.dart index a9e2b48..ea81500 100644 --- a/lib/app/data/models/message_model.dart +++ b/lib/app/data/models/message_model.dart @@ -1,8 +1,5 @@ -import 'package:json_annotation/json_annotation.dart'; +import '../../utils/logger/app_logger.dart'; -part 'message_model.g.dart'; - -@JsonSerializable() class MessageModel { final String id; final String conversationId; @@ -18,14 +15,30 @@ class MessageModel { required this.createdAt, }); - factory MessageModel.fromJson(Map json) => - _$MessageModelFromJson(json); - - Map toJson() => _$MessageModelToJson(this); + factory MessageModel.fromMap(Map map) => MessageModel( + id: map['id']?.toString() ?? '', + conversationId: map['conversationId']?.toString() ?? '', + senderId: map['senderId']?.toString() ?? '', + content: map['content'] as String? ?? '', + createdAt: _parseCreatedAt(map['createdAt'] as String?), + ); - // Backwards compatibility - factory MessageModel.fromMap(Map map) => - MessageModel.fromJson(map); + Map toMap() => { + 'id': id, + 'conversationId': conversationId, + 'senderId': senderId, + 'content': content, + 'createdAt': createdAt.toIso8601String(), + }; - Map toMap() => toJson(); + static DateTime _parseCreatedAt(String? raw) { + if (raw != null && raw.isNotEmpty) { + final parsed = DateTime.tryParse(raw); + if (parsed != null) return parsed; + } + AppLogger.warning( + 'MessageModel: invalid or missing createdAt, falling back to DateTime.now()', + ); + return DateTime.now(); + } } diff --git a/lib/app/data/models/message_model.g.dart b/lib/app/data/models/message_model.g.dart deleted file mode 100644 index 9962be9..0000000 --- a/lib/app/data/models/message_model.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'message_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MessageModel _$MessageModelFromJson(Map json) => MessageModel( - id: json['id'] as String, - conversationId: json['conversationId'] as String, - senderId: json['senderId'] as String, - content: json['content'] as String, - createdAt: DateTime.parse(json['createdAt'] as String), -); - -Map _$MessageModelToJson(MessageModel instance) => - { - 'id': instance.id, - 'conversationId': instance.conversationId, - 'senderId': instance.senderId, - 'content': instance.content, - 'createdAt': instance.createdAt.toIso8601String(), - }; diff --git a/lib/app/data/models/notification_model.dart b/lib/app/data/models/notification_model.dart index d3cabb1..87377d0 100644 --- a/lib/app/data/models/notification_model.dart +++ b/lib/app/data/models/notification_model.dart @@ -1,8 +1,3 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'notification_model.g.dart'; - -@JsonSerializable() class NotificationModel { final String id; final String title; @@ -16,14 +11,20 @@ class NotificationModel { required this.createdAt, }); - factory NotificationModel.fromJson(Map json) => - _$NotificationModelFromJson(json); - - Map toJson() => _$NotificationModelToJson(this); - - // Backwards compatibility factory NotificationModel.fromMap(Map map) => - NotificationModel.fromJson(map); + NotificationModel( + id: map['id']?.toString() ?? '', + title: map['title'] as String? ?? '', + body: map['body'] as String? ?? '', + createdAt: + DateTime.tryParse(map['createdAt'] as String? ?? '') ?? + DateTime.now(), + ); - Map toMap() => toJson(); + Map toMap() => { + 'id': id, + 'title': title, + 'body': body, + 'createdAt': createdAt.toIso8601String(), + }; } diff --git a/lib/app/data/models/notification_model.g.dart b/lib/app/data/models/notification_model.g.dart deleted file mode 100644 index 50be3aa..0000000 --- a/lib/app/data/models/notification_model.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'notification_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NotificationModel _$NotificationModelFromJson(Map json) => - NotificationModel( - id: json['id'] as String, - title: json['title'] as String, - body: json['body'] as String, - createdAt: DateTime.parse(json['createdAt'] as String), - ); - -Map _$NotificationModelToJson(NotificationModel instance) => - { - 'id': instance.id, - 'title': instance.title, - 'body': instance.body, - 'createdAt': instance.createdAt.toIso8601String(), - }; diff --git a/lib/app/data/models/payment_model.dart b/lib/app/data/models/payment_model.dart index 1d8704d..63fe0c5 100644 --- a/lib/app/data/models/payment_model.dart +++ b/lib/app/data/models/payment_model.dart @@ -1,14 +1,7 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'payment_model.g.dart'; - -@JsonSerializable() class PaymentModel { final String id; final num amount; - @JsonKey(defaultValue: 'USD') final String currency; - @JsonKey(defaultValue: 'pending') final String status; const PaymentModel({ @@ -18,14 +11,17 @@ class PaymentModel { this.status = 'pending', }); - factory PaymentModel.fromJson(Map json) => - _$PaymentModelFromJson(json); - - Map toJson() => _$PaymentModelToJson(this); - - // Backwards compatibility - factory PaymentModel.fromMap(Map map) => - PaymentModel.fromJson(map); + factory PaymentModel.fromMap(Map map) => PaymentModel( + id: map['id']?.toString() ?? '', + amount: map['amount'] as num? ?? 0, + currency: map['currency'] as String? ?? 'USD', + status: map['status'] as String? ?? 'pending', + ); - Map toMap() => toJson(); + Map toMap() => { + 'id': id, + 'amount': amount, + 'currency': currency, + 'status': status, + }; } diff --git a/lib/app/data/models/payment_model.g.dart b/lib/app/data/models/payment_model.g.dart deleted file mode 100644 index 5f82170..0000000 --- a/lib/app/data/models/payment_model.g.dart +++ /dev/null @@ -1,22 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'payment_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PaymentModel _$PaymentModelFromJson(Map json) => PaymentModel( - id: json['id'] as String, - amount: json['amount'] as num, - currency: json['currency'] as String? ?? 'USD', - status: json['status'] as String? ?? 'pending', -); - -Map _$PaymentModelToJson(PaymentModel instance) => - { - 'id': instance.id, - 'amount': instance.amount, - 'currency': instance.currency, - 'status': instance.status, - }; diff --git a/lib/app/data/models/review_model.dart b/lib/app/data/models/review_model.dart index ccabb3c..96332c8 100644 --- a/lib/app/data/models/review_model.dart +++ b/lib/app/data/models/review_model.dart @@ -1,14 +1,7 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'review_model.g.dart'; - -@JsonSerializable() class ReviewModel { final String id; final String bookingId; - @JsonKey(defaultValue: 5) final int rating; - @JsonKey(defaultValue: '') final String comment; const ReviewModel({ @@ -18,14 +11,17 @@ class ReviewModel { required this.comment, }); - factory ReviewModel.fromJson(Map json) => - _$ReviewModelFromJson(json); - - Map toJson() => _$ReviewModelToJson(this); - - // Backwards compatibility - factory ReviewModel.fromMap(Map map) => - ReviewModel.fromJson(map); + factory ReviewModel.fromMap(Map map) => ReviewModel( + id: map['id']?.toString() ?? '', + bookingId: map['bookingId']?.toString() ?? '', + rating: map['rating'] as int? ?? 5, + comment: map['comment'] as String? ?? '', + ); - Map toMap() => toJson(); + Map toMap() => { + 'id': id, + 'bookingId': bookingId, + 'rating': rating, + 'comment': comment, + }; } diff --git a/lib/app/data/models/review_model.g.dart b/lib/app/data/models/review_model.g.dart deleted file mode 100644 index 87d3c56..0000000 --- a/lib/app/data/models/review_model.g.dart +++ /dev/null @@ -1,22 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'review_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ReviewModel _$ReviewModelFromJson(Map json) => ReviewModel( - id: json['id'] as String, - bookingId: json['bookingId'] as String, - rating: (json['rating'] as num?)?.toInt() ?? 5, - comment: json['comment'] as String? ?? '', -); - -Map _$ReviewModelToJson(ReviewModel instance) => - { - 'id': instance.id, - 'bookingId': instance.bookingId, - 'rating': instance.rating, - 'comment': instance.comment, - }; diff --git a/lib/app/data/models/trip_model.dart b/lib/app/data/models/trip_model.dart index 8eef291..7ec78b5 100644 --- a/lib/app/data/models/trip_model.dart +++ b/lib/app/data/models/trip_model.dart @@ -1,20 +1,14 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'trip_model.g.dart'; - -@JsonSerializable() class TripModel { final String id; final String propertyName; final DateTime checkIn; final DateTime checkOut; - @JsonKey(defaultValue: 'pending') final String status; final String? propertyImage; final double? totalCost; final String? hostName; - const TripModel({ + TripModel({ required this.id, required this.propertyName, required this.checkIn, @@ -25,14 +19,25 @@ class TripModel { this.hostName, }); - factory TripModel.fromJson(Map json) => - _$TripModelFromJson(json); - - Map toJson() => _$TripModelToJson(this); - - // Backwards compatibility - factory TripModel.fromMap(Map map) => - TripModel.fromJson(map); + factory TripModel.fromMap(Map map) => TripModel( + id: map['id']?.toString() ?? '', + propertyName: map['propertyName'] as String? ?? '', + checkIn: DateTime.parse(map['checkIn'] as String), + checkOut: DateTime.parse(map['checkOut'] as String), + status: map['status'] as String? ?? 'pending', + propertyImage: map['propertyImage'] as String?, + totalCost: (map['totalCost'] as num?)?.toDouble(), + hostName: map['hostName'] as String?, + ); - Map toMap() => toJson(); + Map toMap() => { + 'id': id, + 'propertyName': propertyName, + 'checkIn': checkIn.toIso8601String(), + 'checkOut': checkOut.toIso8601String(), + 'status': status, + 'propertyImage': propertyImage, + 'totalCost': totalCost, + 'hostName': hostName, + }; } diff --git a/lib/app/data/models/trip_model.g.dart b/lib/app/data/models/trip_model.g.dart deleted file mode 100644 index a64154e..0000000 --- a/lib/app/data/models/trip_model.g.dart +++ /dev/null @@ -1,29 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'trip_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -TripModel _$TripModelFromJson(Map json) => TripModel( - id: json['id'] as String, - propertyName: json['propertyName'] as String, - checkIn: DateTime.parse(json['checkIn'] as String), - checkOut: DateTime.parse(json['checkOut'] as String), - status: json['status'] as String? ?? 'pending', - propertyImage: json['propertyImage'] as String?, - totalCost: (json['totalCost'] as num?)?.toDouble(), - hostName: json['hostName'] as String?, -); - -Map _$TripModelToJson(TripModel instance) => { - 'id': instance.id, - 'propertyName': instance.propertyName, - 'checkIn': instance.checkIn.toIso8601String(), - 'checkOut': instance.checkOut.toIso8601String(), - 'status': instance.status, - 'propertyImage': instance.propertyImage, - 'totalCost': instance.totalCost, - 'hostName': instance.hostName, -}; diff --git a/lib/app/data/models/unified_property_response.dart b/lib/app/data/models/unified_property_response.dart index 70d9ade..c9518e0 100644 --- a/lib/app/data/models/unified_property_response.dart +++ b/lib/app/data/models/unified_property_response.dart @@ -1,26 +1,17 @@ -import 'package:json_annotation/json_annotation.dart'; import 'property_model.dart'; -part 'unified_property_response.g.dart'; - -@JsonSerializable(createToJson: true) class UnifiedPropertyResponse { - @JsonKey(fromJson: _propertiesFromJson) final List properties; - @JsonKey(readValue: _readTotalCount) final int totalCount; - @JsonKey(readValue: _readCurrentPage) final int currentPage; - @JsonKey(readValue: _readTotalPages) final int totalPages; - @JsonKey(readValue: _readPageSize) final int pageSize; final Map? filters; bool get hasNextPage => currentPage < totalPages; bool get hasPreviousPage => currentPage > 1; - const UnifiedPropertyResponse({ + UnifiedPropertyResponse({ required this.properties, required this.totalCount, required this.currentPage, @@ -29,30 +20,33 @@ class UnifiedPropertyResponse { this.filters, }); - factory UnifiedPropertyResponse.fromJson(Map json) => - _$UnifiedPropertyResponseFromJson(json); - - Map toJson() => _$UnifiedPropertyResponseToJson(this); - - static List _propertiesFromJson(dynamic value) { - if (value is List) { - return value - .whereType() - .map((e) => Property.fromJson(Map.from(e))) - .toList(); - } - return []; + factory UnifiedPropertyResponse.fromJson(Map json) { + return UnifiedPropertyResponse( + properties: + (json['properties'] as List?) + ?.map((e) => Property.fromJson(Map.from(e))) + .toList() ?? + [], + totalCount: ((json['total'] ?? json['totalCount']) as num?)?.toInt() ?? 0, + currentPage: + ((json['page'] ?? json['currentPage']) as num?)?.toInt() ?? 1, + totalPages: + ((json['total_pages'] ?? json['totalPages']) as num?)?.toInt() ?? 1, + pageSize: + ((json['limit'] ?? json['pageSize'] ?? json['per_page']) as num?) + ?.toInt() ?? + 20, + filters: json['filters'] as Map?, + ); } - static Object? _readTotalCount(Map json, String key) => - json['total'] ?? json['totalCount'] ?? 0; - - static Object? _readCurrentPage(Map json, String key) => - json['page'] ?? json['currentPage'] ?? 1; - - static Object? _readTotalPages(Map json, String key) => - json['total_pages'] ?? json['totalPages'] ?? 1; - - static Object? _readPageSize(Map json, String key) => - json['limit'] ?? json['pageSize'] ?? json['per_page'] ?? 20; + Map toJson() => { + 'properties': properties.map((e) => e.toJson()).toList(), + 'totalCount': totalCount, + 'currentPage': currentPage, + 'totalPages': totalPages, + 'pageSize': pageSize, + 'limit': pageSize, + 'filters': filters, + }; } diff --git a/lib/app/data/models/unified_property_response.g.dart b/lib/app/data/models/unified_property_response.g.dart deleted file mode 100644 index 7215462..0000000 --- a/lib/app/data/models/unified_property_response.g.dart +++ /dev/null @@ -1,36 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'unified_property_response.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -UnifiedPropertyResponse _$UnifiedPropertyResponseFromJson( - Map json, -) => UnifiedPropertyResponse( - properties: UnifiedPropertyResponse._propertiesFromJson(json['properties']), - totalCount: - (UnifiedPropertyResponse._readTotalCount(json, 'totalCount') as num) - .toInt(), - currentPage: - (UnifiedPropertyResponse._readCurrentPage(json, 'currentPage') as num) - .toInt(), - totalPages: - (UnifiedPropertyResponse._readTotalPages(json, 'totalPages') as num) - .toInt(), - pageSize: (UnifiedPropertyResponse._readPageSize(json, 'pageSize') as num) - .toInt(), - filters: json['filters'] as Map?, -); - -Map _$UnifiedPropertyResponseToJson( - UnifiedPropertyResponse instance, -) => { - 'properties': instance.properties, - 'totalCount': instance.totalCount, - 'currentPage': instance.currentPage, - 'totalPages': instance.totalPages, - 'pageSize': instance.pageSize, - 'filters': instance.filters, -}; diff --git a/lib/app/data/models/user_model.dart b/lib/app/data/models/user_model.dart index f10c39c..f0bfb4f 100644 --- a/lib/app/data/models/user_model.dart +++ b/lib/app/data/models/user_model.dart @@ -1,11 +1,5 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'user_model.g.dart'; - -@JsonSerializable(createFactory: false) class UserModel { final String id; - @JsonKey(name: 'supabase_id') final String? supabaseId; final String? email; final String? phone; @@ -13,30 +7,19 @@ class UserModel { final String? lastName; final String? name; final String? avatarUrl; - @JsonKey(name: 'profile_image_url') final String? profileImageUrl; final String? bio; - @JsonKey(name: 'date_of_birth') final DateTime? dateOfBirth; final Map? preferences; - @JsonKey(name: 'notification_settings') final Map? notificationSettings; - @JsonKey(name: 'privacy_settings') final Map? privacySettings; - @JsonKey(name: 'current_latitude') final double? currentLatitude; - @JsonKey(name: 'current_longitude') final double? currentLongitude; - @JsonKey(name: 'is_active') final bool? isActive; - @JsonKey(name: 'is_verified') final bool? isVerified; - @JsonKey(name: 'created_at') final DateTime? createdAt; - @JsonKey(name: 'updated_at') final DateTime? updatedAt; final bool isSuperHost; - @JsonKey(name: 'agent_id') final String? agentId; final Map? metadata; @@ -167,10 +150,6 @@ class UserModel { ); } - // Custom fromJson to handle multiple field name variants from API - factory UserModel.fromJson(Map map) => - UserModel.fromMap(map); - factory UserModel.fromMap(Map map) { Map? parseMap(dynamic value) { if (value == null) return null; @@ -229,9 +208,37 @@ class UserModel { ); } - Map toJson() => _$UserModelToJson(this); + factory UserModel.fromJson(Map json) => + UserModel.fromMap(json); + + Map toMap() => { + 'id': id, + 'supabase_id': supabaseId, + 'email': email, + 'phone': phone, + 'firstName': firstName, + 'lastName': lastName, + 'name': name, + 'avatarUrl': avatarUrl, + 'profileImageUrl': profileImageUrl ?? avatarUrl, + 'profile_image_url': profileImageUrl ?? avatarUrl, + 'bio': bio, + 'date_of_birth': dateOfBirth?.toIso8601String(), + 'preferences': preferences, + 'notification_settings': notificationSettings, + 'privacy_settings': privacySettings, + 'current_latitude': currentLatitude, + 'current_longitude': currentLongitude, + 'is_active': isActive, + 'is_verified': isVerified, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'isSuperHost': isSuperHost, + 'agent_id': agentId, + 'metadata': metadata, + }; - Map toMap() => toJson(); + Map toJson() => toMap(); static double? _toDouble(dynamic value) { if (value == null) return null; diff --git a/lib/app/data/models/user_model.g.dart b/lib/app/data/models/user_model.g.dart deleted file mode 100644 index a18a753..0000000 --- a/lib/app/data/models/user_model.g.dart +++ /dev/null @@ -1,38 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'user_model.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Map _$UserModelToJson(UserModel instance) => { - 'id': instance.id, - 'supabase_id': instance.supabaseId, - 'email': instance.email, - 'phone': instance.phone, - 'firstName': instance.firstName, - 'lastName': instance.lastName, - 'name': instance.name, - 'avatarUrl': instance.avatarUrl, - 'profile_image_url': instance.profileImageUrl, - 'bio': instance.bio, - 'date_of_birth': instance.dateOfBirth?.toIso8601String(), - 'preferences': instance.preferences, - 'notification_settings': instance.notificationSettings, - 'privacy_settings': instance.privacySettings, - 'current_latitude': instance.currentLatitude, - 'current_longitude': instance.currentLongitude, - 'is_active': instance.isActive, - 'is_verified': instance.isVerified, - 'created_at': instance.createdAt?.toIso8601String(), - 'updated_at': instance.updatedAt?.toIso8601String(), - 'isSuperHost': instance.isSuperHost, - 'agent_id': instance.agentId, - 'metadata': instance.metadata, - 'fullName': instance.fullName, - 'displayName': instance.displayName, - 'initials': instance.initials, - 'effectiveAvatarUrl': instance.effectiveAvatarUrl, - 'hasProfileImage': instance.hasProfileImage, -}; diff --git a/lib/app/data/providers/base_provider.dart b/lib/app/data/providers/base_provider.dart index 185fb74..e0f1b86 100644 --- a/lib/app/data/providers/base_provider.dart +++ b/lib/app/data/providers/base_provider.dart @@ -1,14 +1,15 @@ -import 'package:get/get.dart'; - import 'dart:async'; +import 'package:get/get.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + import '../../../config/app_config.dart'; import '../../utils/logger/app_logger.dart'; -import '../services/storage_service.dart'; -import '../../utils/services/token_service.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; import '../../utils/exceptions/app_exceptions.dart'; +import '../../utils/services/connectivity_service.dart'; import '../../utils/services/error_service.dart'; +import '../../utils/services/token_service.dart'; +import '../services/storage_service.dart'; /// Retry configuration for transient failures const int _maxRetries = 3; @@ -185,7 +186,7 @@ abstract class BaseProvider extends GetConnect { Decoder? decoder, }) async { return _executeWithRetry( - () => get( + () => super.get( url, headers: headers, contentType: contentType, @@ -197,25 +198,141 @@ abstract class BaseProvider extends GetConnect { /// Execute a POST request with automatic retry for transient failures Future> postWithRetry( + String? url, + dynamic body, { + String? contentType, + Map? headers, + Map? query, + Decoder? decoder, + Progress? uploadProgress, + }) async { + return _executeWithRetry( + () => super.post( + url, + body, + contentType: contentType, + headers: headers, + query: query, + decoder: decoder, + uploadProgress: uploadProgress, + ), + ); + } + + /// Execute a PUT request with automatic retry for transient failures + Future> putWithRetry( String url, dynamic body, { String? contentType, Map? headers, Map? query, Decoder? decoder, + Progress? uploadProgress, }) async { return _executeWithRetry( - () => post( + () => super.put( url, body, contentType: contentType, headers: headers, query: query, decoder: decoder, + uploadProgress: uploadProgress, ), ); } + /// Execute a DELETE request with automatic retry for transient failures + Future> deleteWithRetry( + String url, { + Map? headers, + String? contentType, + Map? query, + Decoder? decoder, + }) async { + return _executeWithRetry( + () => super.delete( + url, + headers: headers, + contentType: contentType, + query: query, + decoder: decoder, + ), + ); + } + + @override + Future> get( + String url, { + Map? headers, + String? contentType, + Map? query, + Decoder? decoder, + }) => + getWithRetry( + url, + headers: headers, + contentType: contentType, + query: query, + decoder: decoder, + ); + + @override + Future> post( + String? url, + dynamic body, { + String? contentType, + Map? headers, + Map? query, + Decoder? decoder, + Progress? uploadProgress, + }) => + postWithRetry( + url, + body, + contentType: contentType, + headers: headers, + query: query, + decoder: decoder, + uploadProgress: uploadProgress, + ); + + @override + Future> put( + String url, + dynamic body, { + String? contentType, + Map? headers, + Map? query, + Decoder? decoder, + Progress? uploadProgress, + }) => + putWithRetry( + url, + body, + contentType: contentType, + headers: headers, + query: query, + decoder: decoder, + uploadProgress: uploadProgress, + ); + + @override + Future> delete( + String url, { + Map? headers, + String? contentType, + Map? query, + Decoder? decoder, + }) => + deleteWithRetry( + url, + headers: headers, + contentType: contentType, + query: query, + decoder: decoder, + ); + /// Internal retry logic with exponential backoff Future> _executeWithRetry( Future> Function() operation, @@ -224,6 +341,12 @@ abstract class BaseProvider extends GetConnect { Duration delay = _initialRetryDelay; while (true) { + if (!await _hasNetworkConnection()) { + throw ApiException( + message: 'No internet connection. Please check your network and try again.', + statusCode: 0, + ); + } try { final response = await operation(); @@ -252,6 +375,13 @@ abstract class BaseProvider extends GetConnect { delay *= 2; continue; } + if (_isRetryableError(e)) { + AppLogger.warning('Network request failed after $attempt retries: $e'); + throw ApiException( + message: _networkErrorMessage(e), + statusCode: 408, + ); + } rethrow; } } @@ -265,4 +395,28 @@ abstract class BaseProvider extends GetConnect { errorStr.contains('socket') || errorStr.contains('network'); } + + Future _hasNetworkConnection() async { + if (!Get.isRegistered()) { + return true; + } + final service = Get.find(); + if (service.isCurrentlyOnline) { + return true; + } + return service.checkConnection(); + } + + String _networkErrorMessage(dynamic error) { + final errorStr = error.toString().toLowerCase(); + if (errorStr.contains('timeout')) { + return 'Request timed out. Please try again.'; + } + if (errorStr.contains('socket') || + errorStr.contains('connection') || + errorStr.contains('network')) { + return 'Network error. Please check your connection and try again.'; + } + return 'Network request failed. Please try again.'; + } } diff --git a/lib/app/data/providers/bookings_provider.dart b/lib/app/data/providers/bookings_provider.dart index f97661e..93b5e56 100644 --- a/lib/app/data/providers/bookings_provider.dart +++ b/lib/app/data/providers/bookings_provider.dart @@ -7,7 +7,7 @@ class BookingsProvider extends BaseProvider { required String checkOutIso, required int guests, }) async { - final res = await postWithRetry('/api/v1/bookings/check-availability/', { + final res = await post('/api/v1/bookings/check-availability/', { 'property_id': propertyId, 'check_in_date': checkInIso, 'check_out_date': checkOutIso, @@ -25,7 +25,7 @@ class BookingsProvider extends BaseProvider { required String checkOutIso, required int guests, }) async { - final res = await postWithRetry('/api/v1/bookings/calculate-pricing/', { + final res = await post('/api/v1/bookings/calculate-pricing/', { 'property_id': propertyId, 'check_in_date': checkInIso, 'check_out_date': checkOutIso, @@ -40,7 +40,7 @@ class BookingsProvider extends BaseProvider { Future> createBooking( Map payload, ) async { - final res = await postWithRetry('/api/v1/bookings/', payload); + final res = await post('/api/v1/bookings/', payload); return handleResponse(res, (json) { final map = json as Map; return Map.from((map['data'] as Map?) ?? map); @@ -51,7 +51,7 @@ class BookingsProvider extends BaseProvider { int page = 1, int limit = 20, }) async { - final res = await getWithRetry( + final res = await get( '/api/v1/bookings/', query: {'page': '$page', 'limit': '$limit'}, ); @@ -62,7 +62,7 @@ class BookingsProvider extends BaseProvider { } Future> getBooking(int id) async { - final res = await getWithRetry('/api/v1/bookings/$id/'); + final res = await get('/api/v1/bookings/$id/'); return handleResponse(res, (json) { final map = json as Map; return Map.from((map['data'] as Map?) ?? map); @@ -84,7 +84,7 @@ class BookingsProvider extends BaseProvider { required int bookingId, required String reason, }) async { - final res = await postWithRetry('/api/v1/bookings/cancel/', { + final res = await post('/api/v1/bookings/cancel/', { 'booking_id': bookingId, 'reason': reason, }); diff --git a/lib/app/data/providers/message_provider.dart b/lib/app/data/providers/message_provider.dart index ee4b1af..b1ba947 100644 --- a/lib/app/data/providers/message_provider.dart +++ b/lib/app/data/providers/message_provider.dart @@ -1,210 +1,3 @@ -import '../models/message_model.dart'; import 'base_provider.dart'; -/// Model for a conversation -class ConversationModel { - const ConversationModel({ - required this.id, - required this.propertyId, - required this.participantIds, - required this.lastMessage, - required this.lastMessageAt, - required this.unreadCount, - this.propertyName, - this.propertyImageUrl, - }); - - final String id; - final int propertyId; - final List participantIds; - final String lastMessage; - final DateTime lastMessageAt; - final int unreadCount; - final String? propertyName; - final String? propertyImageUrl; - - factory ConversationModel.fromMap(Map map) { - final participantList = map['participant_ids'] ?? map['participantIds']; - return ConversationModel( - id: map['id']?.toString() ?? '', - propertyId: _parseInt(map['property_id'] ?? map['propertyId']) ?? 0, - participantIds: participantList is List - ? participantList.map((e) => e.toString()).toList() - : [], - lastMessage: _asString(map['last_message'] ?? map['lastMessage']) ?? '', - lastMessageAt: _parseDateTime( - map['last_message_at'] ?? map['lastMessageAt']) ?? - DateTime.now(), - unreadCount: _parseInt(map['unread_count'] ?? map['unreadCount']) ?? 0, - propertyName: _asString(map['property_name'] ?? map['propertyName']), - propertyImageUrl: - _asString(map['property_image_url'] ?? map['propertyImageUrl']), - ); - } - - Map toMap() => { - 'id': id, - 'property_id': propertyId, - 'participant_ids': participantIds, - 'last_message': lastMessage, - 'last_message_at': lastMessageAt.toIso8601String(), - 'unread_count': unreadCount, - 'property_name': propertyName, - 'property_image_url': propertyImageUrl, - }; - - // Helper methods for safe type conversion - static int? _parseInt(dynamic value) { - if (value == null) return null; - if (value is int) return value; - if (value is num) return value.toInt(); - if (value is String) return int.tryParse(value); - return null; - } - - static String? _asString(dynamic value) { - if (value == null) return null; - if (value is String) return value.isEmpty ? null : value; - return value.toString(); - } - - static DateTime? _parseDateTime(dynamic value) { - if (value == null) return null; - if (value is DateTime) return value; - if (value is String) return DateTime.tryParse(value); - return null; - } -} - -class MessageProvider extends BaseProvider { - static const String _basePath = '/api/v1/messages'; - - /// Get all conversations for the current user - Future> getConversations({ - int page = 1, - int limit = 20, - }) async { - final res = await getWithRetry>( - '$_basePath/conversations', - query: { - 'page': page.toString(), - 'limit': limit.toString(), - }, - ); - - return handleResponse(res, (data) { - if (data == null) return []; - final dataMap = data as Map; - final items = - (dataMap['data'] ?? dataMap['conversations'] ?? dataMap) as List?; - if (items == null) return []; - return items - .whereType>() - .map(ConversationModel.fromMap) - .toList(); - }); - } - - /// Get messages for a specific conversation - Future> getMessages( - String conversationId, { - int page = 1, - int limit = 20, - }) async { - final res = await getWithRetry>( - '$_basePath/conversations/$conversationId', - query: { - 'page': page.toString(), - 'limit': limit.toString(), - }, - ); - - return handleResponse(res, (data) { - if (data == null) return []; - final dataMap = data as Map; - final items = (dataMap['data'] ?? dataMap['messages'] ?? dataMap) as List?; - if (items == null) return []; - return items - .whereType>() - .map(MessageModel.fromMap) - .toList(); - }); - } - - /// Send a message in a conversation - Future sendMessage( - String conversationId, - String content, - ) async { - final res = await postWithRetry>( - '$_basePath/conversations/$conversationId', - { - 'content': content, - }, - ); - - return handleResponse(res, (data) { - if (data == null) { - throw Exception('Failed to send message: empty response'); - } - final dataMap = data as Map; - final messageData = - (dataMap['data'] ?? dataMap['message'] ?? dataMap) as Map; - return MessageModel.fromMap(messageData); - }); - } - - /// Mark a conversation as read - Future markAsRead(String conversationId) async { - final res = await postWithRetry>( - '$_basePath/conversations/$conversationId/read', - {}, - ); - - handleResponse(res, (_) => null); - } - - /// Start a new conversation with a property host - Future startConversation({ - required int propertyId, - required String initialMessage, - }) async { - final res = await postWithRetry>( - '$_basePath/conversations', - { - 'property_id': propertyId, - 'message': initialMessage, - }, - ); - - return handleResponse(res, (data) { - if (data == null) { - throw Exception('Failed to start conversation: empty response'); - } - final dataMap = data as Map; - final conversationData = - (dataMap['data'] ?? dataMap['conversation'] ?? dataMap) as Map; - return ConversationModel.fromMap(conversationData); - }); - } - - /// Delete a message - Future deleteMessage(String messageId) async { - final res = await delete>('$_basePath/$messageId'); - handleResponse(res, (_) => null); - } - - /// Get unread message count - Future getUnreadCount() async { - final res = await getWithRetry>('$_basePath/unread-count'); - - return handleResponse(res, (data) { - if (data == null) return 0; - final dataMap = data as Map; - final count = dataMap['count'] ?? dataMap['unread_count']; - if (count is int) return count; - if (count is num) return count.toInt(); - return 0; - }); - } -} +class MessageProvider extends BaseProvider {} diff --git a/lib/app/data/providers/payment_provider.dart b/lib/app/data/providers/payment_provider.dart index 7727672..d950d93 100644 --- a/lib/app/data/providers/payment_provider.dart +++ b/lib/app/data/providers/payment_provider.dart @@ -5,7 +5,7 @@ class PaymentProvider extends BaseProvider { String bookingId, num amount, ) async { - final response = await postWithRetry('/payments/intent', { + final response = await post('/payments/intent', { 'bookingId': bookingId, 'amount': amount, }); diff --git a/lib/app/data/providers/properties_provider.dart b/lib/app/data/providers/properties_provider.dart index 06084fb..c8605fe 100644 --- a/lib/app/data/providers/properties_provider.dart +++ b/lib/app/data/providers/properties_provider.dart @@ -21,7 +21,7 @@ class PropertiesProvider extends BaseProvider { 'radius': radiusKm, ...?filters, }; - final res = await getWithRetry('/api/v1/properties/', query: query.asQueryParams()); + final res = await get('/api/v1/properties/', query: query.asQueryParams()); return handleResponse(res, (json) { final map = json as Map; final rawList = @@ -65,7 +65,7 @@ class PropertiesProvider extends BaseProvider { } Future getDetails(int id) async { - final res = await getWithRetry('/api/v1/properties/$id'); + final res = await get('/api/v1/properties/$id'); return handleResponse(res, (json) { final map = json as Map; final data = map['data'] ?? map; @@ -76,7 +76,7 @@ class PropertiesProvider extends BaseProvider { } Future> recommendations({int limit = 10}) async { - final res = await getWithRetry( + final res = await get( '/api/v1/properties/recommendations/', query: {'limit': '$limit'}, ); diff --git a/lib/app/data/providers/review_provider.dart b/lib/app/data/providers/review_provider.dart index 95f60b2..29c8706 100644 --- a/lib/app/data/providers/review_provider.dart +++ b/lib/app/data/providers/review_provider.dart @@ -1,338 +1,3 @@ import 'base_provider.dart'; -/// Model for a review -class ReviewModel { - const ReviewModel({ - required this.id, - required this.propertyId, - required this.userId, - required this.rating, - required this.content, - required this.createdAt, - this.userName, - this.userAvatarUrl, - this.updatedAt, - this.hostResponse, - this.hostResponseAt, - }); - - final String id; - final int propertyId; - final String userId; - final int rating; - final String content; - final DateTime createdAt; - final String? userName; - final String? userAvatarUrl; - final DateTime? updatedAt; - final String? hostResponse; - final DateTime? hostResponseAt; - - factory ReviewModel.fromMap(Map map) { - return ReviewModel( - id: map['id']?.toString() ?? '', - propertyId: _parseInt(map['property_id'] ?? map['propertyId']) ?? 0, - userId: map['user_id']?.toString() ?? map['userId']?.toString() ?? '', - rating: _parseInt(map['rating']) ?? 0, - content: _asString(map['content'] ?? map['review'] ?? map['text']) ?? '', - createdAt: _parseDateTime(map['created_at'] ?? map['createdAt']) ?? - DateTime.now(), - userName: _asString(map['user_name'] ?? map['userName']), - userAvatarUrl: _asString(map['user_avatar_url'] ?? map['userAvatarUrl']), - updatedAt: _parseDateTime(map['updated_at'] ?? map['updatedAt']), - hostResponse: _asString(map['host_response'] ?? map['hostResponse']), - hostResponseAt: - _parseDateTime(map['host_response_at'] ?? map['hostResponseAt']), - ); - } - - Map toMap() => { - 'id': id, - 'property_id': propertyId, - 'user_id': userId, - 'rating': rating, - 'content': content, - 'created_at': createdAt.toIso8601String(), - 'user_name': userName, - 'user_avatar_url': userAvatarUrl, - if (updatedAt != null) 'updated_at': updatedAt!.toIso8601String(), - if (hostResponse != null) 'host_response': hostResponse, - if (hostResponseAt != null) - 'host_response_at': hostResponseAt!.toIso8601String(), - }; - - // Helper methods for safe type conversion - static int? _parseInt(dynamic value) { - if (value == null) return null; - if (value is int) return value; - if (value is num) return value.toInt(); - if (value is String) return int.tryParse(value); - return null; - } - - static String? _asString(dynamic value) { - if (value == null) return null; - if (value is String) return value.isEmpty ? null : value; - return value.toString(); - } - - static DateTime? _parseDateTime(dynamic value) { - if (value == null) return null; - if (value is DateTime) return value; - if (value is String) return DateTime.tryParse(value); - return null; - } - - /// Check if the review is valid (has content and reasonable rating) - bool get isValid => rating >= 1 && rating <= 5 && content.isNotEmpty; - - /// Get a display-friendly rating string (e.g., "4.0") - String get displayRating => rating.toStringAsFixed(1); -} - -/// Review statistics for a property -class ReviewStats { - const ReviewStats({ - required this.averageRating, - required this.totalReviews, - required this.ratingDistribution, - }); - - final double averageRating; - final int totalReviews; - final Map ratingDistribution; // 1-5 star counts - - factory ReviewStats.fromMap(Map map) { - final distribution = {}; - final distData = map['rating_distribution'] ?? map['ratingDistribution']; - if (distData is Map) { - for (final entry in distData.entries) { - final key = int.tryParse(entry.key.toString()); - final value = entry.value; - if (key != null && value is num) { - distribution[key] = value.toInt(); - } - } - } - - return ReviewStats( - averageRating: - _parseDouble(map['average_rating'] ?? map['averageRating']) ?? 0.0, - totalReviews: - _parseInt(map['total_reviews'] ?? map['totalReviews']) ?? 0, - ratingDistribution: distribution, - ); - } - - static int? _parseInt(dynamic value) { - if (value == null) return null; - if (value is int) return value; - if (value is num) return value.toInt(); - if (value is String) return int.tryParse(value); - return null; - } - - static double? _parseDouble(dynamic value) { - if (value == null) return null; - if (value is double) return value; - if (value is num) return value.toDouble(); - if (value is String) return double.tryParse(value); - return null; - } -} - -class ReviewProvider extends BaseProvider { - static const String _basePath = '/api/v1/reviews'; - - /// Get reviews for a specific property - Future> getPropertyReviews( - int propertyId, { - int page = 1, - int limit = 10, - String? sortBy, // 'newest', 'oldest', 'highest', 'lowest' - }) async { - final query = { - 'page': page.toString(), - 'limit': limit.toString(), - }; - if (sortBy != null) { - query['sort'] = sortBy; - } - - final res = await getWithRetry>( - '$_basePath/property/$propertyId', - query: query, - ); - - return handleResponse(res, (data) { - if (data == null) return []; - final dataMap = data as Map; - final items = (dataMap['data'] ?? dataMap['reviews'] ?? dataMap) as List?; - if (items == null) return []; - return items - .whereType>() - .map(ReviewModel.fromMap) - .toList(); - }); - } - - /// Get review statistics for a property - Future getPropertyReviewStats(int propertyId) async { - final res = await getWithRetry>( - '$_basePath/property/$propertyId/stats', - ); - - return handleResponse(res, (data) { - if (data == null) { - return const ReviewStats( - averageRating: 0.0, - totalReviews: 0, - ratingDistribution: {}, - ); - } - final dataMap = data as Map; - final statsData = (dataMap['data'] ?? dataMap['stats'] ?? dataMap) - as Map; - return ReviewStats.fromMap(statsData); - }); - } - - /// Get all reviews by the current user - Future> getUserReviews({ - int page = 1, - int limit = 10, - }) async { - final res = await getWithRetry>( - '$_basePath/user', - query: { - 'page': page.toString(), - 'limit': limit.toString(), - }, - ); - - return handleResponse(res, (data) { - if (data == null) return []; - final dataMap = data as Map; - final items = (dataMap['data'] ?? dataMap['reviews'] ?? dataMap) as List?; - if (items == null) return []; - return items - .whereType>() - .map(ReviewModel.fromMap) - .toList(); - }); - } - - /// Submit a new review for a property - Future submitReview({ - required int propertyId, - required int rating, - required String content, - }) async { - if (rating < 1 || rating > 5) { - throw ArgumentError('Rating must be between 1 and 5'); - } - if (content.trim().isEmpty) { - throw ArgumentError('Review content cannot be empty'); - } - - final res = await postWithRetry>( - _basePath, - { - 'property_id': propertyId, - 'rating': rating, - 'content': content.trim(), - }, - ); - - return handleResponse(res, (data) { - if (data == null) { - throw Exception('Failed to submit review: empty response'); - } - final dataMap = data as Map; - final reviewData = - (dataMap['data'] ?? dataMap['review'] ?? dataMap) as Map; - return ReviewModel.fromMap(reviewData); - }); - } - - /// Update an existing review - Future updateReview({ - required String reviewId, - int? rating, - String? content, - }) async { - if (rating != null && (rating < 1 || rating > 5)) { - throw ArgumentError('Rating must be between 1 and 5'); - } - - final body = {}; - if (rating != null) body['rating'] = rating; - if (content != null) body['content'] = content.trim(); - - if (body.isEmpty) { - throw ArgumentError('At least one field (rating or content) must be provided'); - } - - final res = await put>( - '$_basePath/$reviewId', - body, - ); - - return handleResponse(res, (data) { - if (data == null) { - throw Exception('Failed to update review: empty response'); - } - final dataMap = data as Map; - final reviewData = - (dataMap['data'] ?? dataMap['review'] ?? dataMap) as Map; - return ReviewModel.fromMap(reviewData); - }); - } - - /// Delete a review - Future deleteReview(String reviewId) async { - final res = await delete>('$_basePath/$reviewId'); - handleResponse(res, (_) => null); - } - - /// Check if the current user can review a property - /// (usually requires a completed stay) - Future canReviewProperty(int propertyId) async { - final res = await getWithRetry>( - '$_basePath/can-review/$propertyId', - ); - - return handleResponse(res, (data) { - if (data == null) return false; - final dataMap = data as Map; - final canReview = dataMap['can_review'] ?? dataMap['canReview']; - if (canReview is bool) return canReview; - return false; - }); - } - - /// Report a review as inappropriate - Future reportReview({ - required String reviewId, - required String reason, - }) async { - final res = await postWithRetry>( - '$_basePath/$reviewId/report', - { - 'reason': reason, - }, - ); - - handleResponse(res, (_) => null); - } - - /// Mark a review as helpful - Future markReviewHelpful(String reviewId) async { - final res = await postWithRetry>( - '$_basePath/$reviewId/helpful', - {}, - ); - - handleResponse(res, (_) => null); - } -} +class ReviewProvider extends BaseProvider {} diff --git a/lib/app/data/providers/users_provider.dart b/lib/app/data/providers/users_provider.dart index c7f8dbf..045c05b 100644 --- a/lib/app/data/providers/users_provider.dart +++ b/lib/app/data/providers/users_provider.dart @@ -7,7 +7,7 @@ import 'base_provider.dart'; class UsersProvider extends BaseProvider { Future getProfile() async { - final response = await getWithRetry('/api/v1/users/profile/'); + final response = await get('/api/v1/users/profile/'); return handleResponse(response, _parseUser); } @@ -87,7 +87,7 @@ class UsersProvider extends BaseProvider { final bytes = await file.readAsBytes(); final payload = {'filename': filename, 'file_base64': base64Encode(bytes)}; - final response = await postWithRetry('/api/v1/users/profile/avatar/', payload); + final response = await post('/api/v1/users/profile/avatar/', payload); return handleResponse(response, (body) { if (body is Map) { if (body['url'] is String) return body['url'] as String; @@ -104,7 +104,7 @@ class UsersProvider extends BaseProvider { } Future requestDataExport() async { - final response = await postWithRetry('/api/v1/users/export/', {}); + final response = await post('/api/v1/users/export/', {}); if (!response.isOk) { throw ApiException( message: response.statusText ?? 'Failed to request data export', @@ -135,7 +135,7 @@ class UsersProvider extends BaseProvider { if (appVersion != null) 'app_version': appVersion, if (locale != null) 'locale': locale, }; - final response = await postWithRetry( + final response = await post( '/api/v1/notifications/devices/register', payload, ); diff --git a/lib/app/data/repositories/auth_repository.dart b/lib/app/data/repositories/auth_repository.dart index 246142b..5ac845b 100644 --- a/lib/app/data/repositories/auth_repository.dart +++ b/lib/app/data/repositories/auth_repository.dart @@ -89,7 +89,13 @@ class AuthRepository { try { await _provider.logout(); } finally { - await _storage.clearTokens(); + try { + final tokenService = Get.find(); + await tokenService.ready; + await tokenService.clearTokens(); + } catch (_) { + await _storage.clearTokens(); + } await _storage.clearUserData(); } } @@ -124,23 +130,14 @@ class AuthRepository { } Future _persistTokens(ProviderAuthResult res) async { + if (res.accessToken == null) return; try { - if (res.accessToken != null) { - // Centralize via TokenService so in-memory state and storage stay in sync - try { - final tokenService = Get.find(); - await tokenService.storeTokens( - accessToken: res.accessToken!, - refreshToken: res.refreshToken, - ); - } catch (_) { - // Fallback if TokenService hasn't finished async init yet - await _storage.saveTokens( - accessToken: res.accessToken!, - refreshToken: res.refreshToken, - ); - } - } + final tokenService = Get.find(); + await tokenService.ready; + await tokenService.storeTokens( + accessToken: res.accessToken!, + refreshToken: res.refreshToken, + ); } catch (e) { AppLogger.warning('Failed to persist tokens: $e'); } diff --git a/lib/app/data/repositories/properties_repository.dart b/lib/app/data/repositories/properties_repository.dart index 61e069a..9b06c5c 100644 --- a/lib/app/data/repositories/properties_repository.dart +++ b/lib/app/data/repositories/properties_repository.dart @@ -1,12 +1,14 @@ -import 'package:get/get.dart'; import 'dart:async'; +import 'package:get/get.dart'; + import '../providers/properties_provider.dart'; -import '../models/unified_property_response.dart'; import '../models/property_model.dart'; +import '../models/unified_property_response.dart'; import '../services/location_service.dart'; import '../services/property_cache_service.dart'; import '../../utils/logger/app_logger.dart'; +import '../../utils/services/connectivity_service.dart'; class PropertiesRepository { final PropertiesProvider _provider; @@ -106,6 +108,10 @@ class PropertiesRepository { Map filters, ) async { try { + if (Get.isRegistered() && + !Get.find().isCurrentlyOnline) { + return; + } final response = await _provider.explore( lat: lat, lng: lng, @@ -144,6 +150,10 @@ class PropertiesRepository { Future _refreshDetailsInBackground(int id) async { try { + if (Get.isRegistered() && + !Get.find().isCurrentlyOnline) { + return; + } final property = await _provider.getDetails(id); await _cacheService?.cachePropertyDetails(property); } catch (e) { @@ -172,6 +182,10 @@ class PropertiesRepository { Future _refreshRecommendationsInBackground(int limit) async { try { + if (Get.isRegistered() && + !Get.find().isCurrentlyOnline) { + return; + } final properties = await _provider.recommendations(limit: limit); await _cacheService?.cacheRecommendations(properties); } catch (e) { diff --git a/lib/app/data/services/analytics_service.dart b/lib/app/data/services/analytics_service.dart index 59fdd5e..20f0538 100644 --- a/lib/app/data/services/analytics_service.dart +++ b/lib/app/data/services/analytics_service.dart @@ -6,7 +6,12 @@ import 'package:get/get.dart'; import '../../utils/logger/app_logger.dart'; class AnalyticsService extends GetxService { - static AnalyticsService get I => Get.find(); + AnalyticsService({required this.enabled}) { + _initializeFirebaseAnalytics(); + AppLogger.info( + 'AnalyticsService initialized (collection ${enabled ? 'enabled' : 'disabled'})', + ); + } final bool enabled; final List _eventQueue = []; @@ -19,12 +24,7 @@ class AnalyticsService extends GetxService { /// Get the Firebase Analytics observer for navigation tracking FirebaseAnalyticsObserver? get observer => _observer; - AnalyticsService({required this.enabled}) { - if (enabled) { - _initializeFirebaseAnalytics(); - AppLogger.info('AnalyticsService initialized with Firebase Analytics'); - } - } + static AnalyticsService get I => Get.find(); void _initializeFirebaseAnalytics() { try { @@ -32,7 +32,7 @@ class AnalyticsService extends GetxService { _observer = FirebaseAnalyticsObserver(analytics: _analytics!); // Enable analytics collection - _analytics!.setAnalyticsCollectionEnabled(true); + unawaited(_analytics!.setAnalyticsCollectionEnabled(enabled)); } catch (e) { AppLogger.warning('Failed to initialize Firebase Analytics: $e'); } @@ -40,7 +40,7 @@ class AnalyticsService extends GetxService { @override void onClose() { - _eventsController.close(); + unawaited(_eventsController.close()); super.onClose(); } @@ -51,7 +51,7 @@ class AnalyticsService extends GetxService { _eventsController.add(event); // Send to Firebase Analytics - _sendToFirebase(event); + unawaited(_sendToFirebase(event)); AppLogger.info('Analytics: ${event.name}', event.params); } @@ -63,10 +63,7 @@ class AnalyticsService extends GetxService { // Convert params to ensure all values are valid types for Firebase final sanitizedParams = _sanitizeParams(event.params); - await _analytics!.logEvent( - name: event.name, - parameters: sanitizedParams, - ); + await _analytics!.logEvent(name: event.name, parameters: sanitizedParams); } catch (e) { AppLogger.warning('Failed to send event to Firebase Analytics: $e'); } @@ -115,9 +112,11 @@ class AnalyticsService extends GetxService { void logScreenView(String screenName, [Map? params]) { if (_analytics != null) { // Use Firebase's built-in screen view logging - _analytics!.logScreenView( - screenName: screenName, - screenClass: params?['screen_class'] ?? screenName, + unawaited( + _analytics!.logScreenView( + screenName: screenName, + screenClass: params?['screen_class'] ?? screenName, + ), ); } @@ -131,7 +130,7 @@ class AnalyticsService extends GetxService { void logSearch(String query, [Map? params]) { if (_analytics != null) { - _analytics!.logSearch(searchTerm: query); + unawaited(_analytics!.logSearch(searchTerm: query)); } log( @@ -148,14 +147,16 @@ class AnalyticsService extends GetxService { void logPropertyView(String propertyId, String propertyName) { if (_analytics != null) { - _analytics!.logViewItem( - items: [ - AnalyticsEventItem( - itemId: propertyId, - itemName: propertyName, - itemCategory: 'property', - ), - ], + unawaited( + _analytics!.logViewItem( + items: [ + AnalyticsEventItem( + itemId: propertyId, + itemName: propertyName, + itemCategory: 'property', + ), + ], + ), ); } @@ -169,16 +170,18 @@ class AnalyticsService extends GetxService { void logBookingStarted(String propertyId, double price) { if (_analytics != null) { - _analytics!.logBeginCheckout( - value: price, - currency: 'INR', - items: [ - AnalyticsEventItem( - itemId: propertyId, - itemCategory: 'property', - price: price, - ), - ], + unawaited( + _analytics!.logBeginCheckout( + value: price, + currency: 'INR', + items: [ + AnalyticsEventItem( + itemId: propertyId, + itemCategory: 'property', + price: price, + ), + ], + ), ); } @@ -196,10 +199,12 @@ class AnalyticsService extends GetxService { String paymentMethod, ) { if (_analytics != null) { - _analytics!.logPurchase( - transactionId: bookingId, - value: amount, - currency: 'INR', + unawaited( + _analytics!.logPurchase( + transactionId: bookingId, + value: amount, + currency: 'INR', + ), ); } @@ -217,9 +222,7 @@ class AnalyticsService extends GetxService { void logBookingCancelled(String bookingId, String reason) { if (_analytics != null) { - _analytics!.logRefund( - transactionId: bookingId, - ); + unawaited(_analytics!.logRefund(transactionId: bookingId)); } log( @@ -232,13 +235,12 @@ class AnalyticsService extends GetxService { void logWishlistAdded(String propertyId) { if (_analytics != null) { - _analytics!.logAddToWishlist( - items: [ - AnalyticsEventItem( - itemId: propertyId, - itemCategory: 'property', - ), - ], + unawaited( + _analytics!.logAddToWishlist( + items: [ + AnalyticsEventItem(itemId: propertyId, itemCategory: 'property'), + ], + ), ); } @@ -261,7 +263,7 @@ class AnalyticsService extends GetxService { void logLogin(String method) { if (_analytics != null) { - _analytics!.logLogin(loginMethod: method); + unawaited(_analytics!.logLogin(loginMethod: method)); } log( @@ -274,7 +276,7 @@ class AnalyticsService extends GetxService { void logSignup(String method) { if (_analytics != null) { - _analytics!.logSignUp(signUpMethod: method); + unawaited(_analytics!.logSignUp(signUpMethod: method)); } log( @@ -287,7 +289,7 @@ class AnalyticsService extends GetxService { void logLogout() { // Clear user ID on logout - setUserId(null); + unawaited(setUserId(null)); log(AnalyticsEvent(name: AnalyticsEventNames.logout)); } @@ -335,10 +337,12 @@ class AnalyticsService extends GetxService { void logShare(String contentType, String contentId) { if (_analytics != null) { - _analytics!.logShare( - contentType: contentType, - itemId: contentId, - method: 'app', + unawaited( + _analytics!.logShare( + contentType: contentType, + itemId: contentId, + method: 'app', + ), ); } @@ -368,7 +372,7 @@ class AnalyticsService extends GetxService { ); } - void flush() async { + void flush() { if (_eventQueue.isNotEmpty) { AppLogger.info('Flushing ${_eventQueue.length} analytics events'); _eventQueue.clear(); @@ -377,16 +381,16 @@ class AnalyticsService extends GetxService { } class AnalyticsEvent { - final String name; - final Map params; - final DateTime timestamp; - AnalyticsEvent({ required this.name, this.params = const {}, DateTime? timestamp, }) : timestamp = timestamp ?? DateTime.now(); + final String name; + final Map params; + final DateTime timestamp; + Map toJson() { return { 'name': name, diff --git a/lib/app/data/services/crash_reporting_service.dart b/lib/app/data/services/crash_reporting_service.dart index 4b27421..a6fdda9 100644 --- a/lib/app/data/services/crash_reporting_service.dart +++ b/lib/app/data/services/crash_reporting_service.dart @@ -78,7 +78,7 @@ class CrashReportingService extends GetxService { // Capture errors from the platform dispatcher PlatformDispatcher.instance.onError = (error, stack) { - recordError(error, stackTrace: stack, fatal: true); + unawaited(recordError(error, stackTrace: stack, fatal: true)); return true; }; } @@ -91,7 +91,7 @@ class CrashReportingService extends GetxService { } // Record as fatal error - _crashlytics!.recordFlutterFatalError(details); + unawaited(_crashlytics!.recordFlutterFatalError(details)); AppLogger.error('Flutter error recorded to Crashlytics', details.exception, details.stack); } @@ -159,11 +159,13 @@ extension CrashReportingExtension on Object { bool fatal = false, }) { if (Get.isRegistered()) { - Get.find().recordError( - this, - stackTrace: stackTrace, - reason: reason, - fatal: fatal, + unawaited( + Get.find().recordError( + this, + stackTrace: stackTrace, + reason: reason, + fatal: fatal, + ), ); } } diff --git a/lib/app/data/services/image_prefetch_service.dart b/lib/app/data/services/image_prefetch_service.dart index 485a1db..84dddd2 100644 --- a/lib/app/data/services/image_prefetch_service.dart +++ b/lib/app/data/services/image_prefetch_service.dart @@ -1,204 +1,216 @@ - import 'package:cached_network_image/cached_network_image.dart'; - import 'package:flutter/material.dart'; - import 'package:flutter_cache_manager/flutter_cache_manager.dart'; - import 'package:get/get.dart'; - - import '../../utils/logger/app_logger.dart'; - import '../models/property_model.dart'; - - /// Service for prefetching images to improve perceived performance. - /// Preloads property images before they're displayed to eliminate loading delays. - class ImagePrefetchService extends GetxService { - static ImagePrefetchService get I => Get.find(); - - final DefaultCacheManager _cacheManager = DefaultCacheManager(); - - /// Track URLs currently being prefetched to avoid duplicates - final Set _prefetchingUrls = {}; - - /// Track URLs that have been successfully prefetched - final Set _prefetchedUrls = {}; - - /// Maximum concurrent prefetch operations - static const int _maxConcurrentPrefetch = 5; - - /// Current number of active prefetch operations - int _activePrefetches = 0; - - /// Queue of URLs waiting to be prefetched - final List _prefetchQueue = []; - - /// Initialize the service - Future init() async { - AppLogger.info('ImagePrefetchService initialized'); - return this; - } - - /// Prefetch a single image URL - Future prefetchImage(String? imageUrl) async { - if (imageUrl == null || imageUrl.isEmpty) return; - if (_prefetchedUrls.contains(imageUrl)) return; - if (_prefetchingUrls.contains(imageUrl)) return; - - // Add to queue if at capacity - if (_activePrefetches >= _maxConcurrentPrefetch) { - if (!_prefetchQueue.contains(imageUrl)) { - _prefetchQueue.add(imageUrl); - } - return; - } - - await _executePrefetch(imageUrl); - } - - Future _executePrefetch(String imageUrl) async { - _prefetchingUrls.add(imageUrl); - _activePrefetches++; - - try { - await _cacheManager.downloadFile(imageUrl); - _prefetchedUrls.add(imageUrl); - AppLogger.debug('Prefetched image: ${_truncateUrl(imageUrl)}'); - } catch (e) { - // Silently fail - prefetching is best-effort - AppLogger.debug('Failed to prefetch image: ${_truncateUrl(imageUrl)}'); - } finally { - _prefetchingUrls.remove(imageUrl); - _activePrefetches--; - _processQueue(); - } - } - - void _processQueue() { - while (_prefetchQueue.isNotEmpty && _activePrefetches < _maxConcurrentPrefetch) { - final nextUrl = _prefetchQueue.removeAt(0); - if (!_prefetchedUrls.contains(nextUrl) && !_prefetchingUrls.contains(nextUrl)) { - _executePrefetch(nextUrl); - } - } - } - - /// Prefetch images for a list of properties - Future prefetchPropertyImages(List properties, {int limit = 10}) async { - final urls = []; - - for (int i = 0; i < properties.length && i < limit; i++) { - final property = properties[i]; - final displayImage = property.displayImage; - if (displayImage != null && displayImage.isNotEmpty) { - urls.add(displayImage); - } - } - - for (final url in urls) { - prefetchImage(url); - } - - AppLogger.info('Queued ${urls.length} property images for prefetch'); - } - - /// Prefetch all images for a single property (for detail view) - Future prefetchPropertyDetailImages(Property property) async { - final urls = []; - - // Add display image - final displayImage = property.displayImage; - if (displayImage != null && displayImage.isNotEmpty) { - urls.add(displayImage); - } - - // Add all gallery images +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +import '../../utils/logger/app_logger.dart'; +import '../models/property_model.dart'; +import '../models/property_image_model.dart'; + +/// Service for prefetching images to improve perceived performance. +/// Preloads property images before they're displayed to eliminate loading delays. +class ImagePrefetchService extends GetxService { + static ImagePrefetchService get I => Get.find(); + + final DefaultCacheManager _cacheManager = DefaultCacheManager(); + + /// Track URLs currently being prefetched to avoid duplicates + final Set _prefetchingUrls = {}; + + /// Track URLs that have been successfully prefetched + final Set _prefetchedUrls = {}; + + /// Maximum concurrent prefetch operations + static const int _maxConcurrentPrefetch = 5; + + /// Current number of active prefetch operations + int _activePrefetches = 0; + + /// Queue of URLs waiting to be prefetched + final List _prefetchQueue = []; + + /// Initialize the service + Future init() async { + AppLogger.info('ImagePrefetchService initialized'); + return this; + } + + /// Prefetch a single image URL + Future prefetchImage(String? imageUrl) async { + if (imageUrl == null || imageUrl.isEmpty) return; + if (_prefetchedUrls.contains(imageUrl)) return; + if (_prefetchingUrls.contains(imageUrl)) return; + + // Add to queue if at capacity + if (_activePrefetches >= _maxConcurrentPrefetch) { + if (!_prefetchQueue.contains(imageUrl)) { + _prefetchQueue.add(imageUrl); + } + return; + } + + await _executePrefetch(imageUrl); + } + + Future _executePrefetch(String imageUrl) async { + _prefetchingUrls.add(imageUrl); + _activePrefetches++; + + try { + await _cacheManager.downloadFile(imageUrl); + _prefetchedUrls.add(imageUrl); + AppLogger.debug('Prefetched image: ${_truncateUrl(imageUrl)}'); + } catch (e) { + // Silently fail - prefetching is best-effort + AppLogger.debug('Failed to prefetch image: ${_truncateUrl(imageUrl)}'); + } finally { + _prefetchingUrls.remove(imageUrl); + _activePrefetches--; + _processQueue(); + } + } + + void _processQueue() { + while (_prefetchQueue.isNotEmpty && + _activePrefetches < _maxConcurrentPrefetch) { + final nextUrl = _prefetchQueue.removeAt(0); + if (!_prefetchedUrls.contains(nextUrl) && + !_prefetchingUrls.contains(nextUrl)) { + unawaited(_executePrefetch(nextUrl)); + } + } + } + + /// Prefetch images for a list of properties + Future prefetchPropertyImages( + List properties, { + int limit = 10, + }) async { + final urls = []; + + void collect(Property property) { + final displayImage = property.displayImage; + if (displayImage != null && displayImage.isNotEmpty) { + urls.add(displayImage); + } + } + + properties.take(limit).forEach(collect); + + void queue(String url) => unawaited(prefetchImage(url)); + urls.forEach(queue); + + AppLogger.info('Queued ${urls.length} property images for prefetch'); + } + + /// Prefetch all images for a single property (for detail view) + Future prefetchPropertyDetailImages(Property property) async { + final urls = []; + + // Add display image + final displayImage = property.displayImage; + if (displayImage != null && displayImage.isNotEmpty) { + urls.add(displayImage); + } + + // Add all gallery images final images = property.images; if (images != null) { - for (final image in images) { + void collect(PropertyImage image) { if (image.imageUrl.isNotEmpty) { urls.add(image.imageUrl); } - } - } - - for (final url in urls) { - prefetchImage(url); - } - - AppLogger.info('Queued ${urls.length} detail images for prefetch'); - } - - /// Prefetch images that will appear on next scroll - /// Call this when user is near the end of visible items - Future prefetchNextBatch( - List allProperties, - int currentVisibleIndex, - {int batchSize = 5} - ) async { - final startIndex = currentVisibleIndex + 1; - final endIndex = (startIndex + batchSize).clamp(0, allProperties.length); - - if (startIndex >= allProperties.length) return; - - final nextProperties = allProperties.sublist(startIndex, endIndex); - await prefetchPropertyImages(nextProperties, limit: batchSize); - } - - /// Precache an image into Flutter's image cache (for immediate display) - Future precacheForDisplay(BuildContext context, String? imageUrl) async { - if (imageUrl == null || imageUrl.isEmpty) return; - - try { - await precacheImage( - CachedNetworkImageProvider(imageUrl), - context, - ); - } catch (e) { - // Silently fail - } - } - - /// Check if an image is already cached - bool isImageCached(String? imageUrl) { - if (imageUrl == null || imageUrl.isEmpty) return false; - return _prefetchedUrls.contains(imageUrl); - } - - /// Clear all prefetch tracking (cache remains) - void clearPrefetchTracking() { - _prefetchedUrls.clear(); - _prefetchQueue.clear(); - } - - /// Get prefetch statistics - Map get stats => { - 'prefetched': _prefetchedUrls.length, - 'queued': _prefetchQueue.length, - 'active': _activePrefetches, - }; - - String _truncateUrl(String url) { - if (url.length <= 50) return url; - return '${url.substring(0, 30)}...${url.substring(url.length - 17)}'; - } - } - - /// Mixin for controllers that want to prefetch images - mixin ImagePrefetchMixin { - ImagePrefetchService? _imagePrefetchService; - - ImagePrefetchService get imagePrefetchService { - _imagePrefetchService ??= Get.find(); - return _imagePrefetchService!; - } - - /// Prefetch images for properties - void prefetchImages(List properties, {int limit = 10}) { - if (Get.isRegistered()) { - imagePrefetchService.prefetchPropertyImages(properties, limit: limit); - } - } - - /// Prefetch detail images for a property the user might tap - void prefetchDetailImages(Property property) { - if (Get.isRegistered()) { - imagePrefetchService.prefetchPropertyDetailImages(property); - } - } - } + } + + images.forEach(collect); + } + + void queue(String url) => unawaited(prefetchImage(url)); + urls.forEach(queue); + + AppLogger.info('Queued ${urls.length} detail images for prefetch'); + } + + /// Prefetch images that will appear on next scroll + /// Call this when user is near the end of visible items + Future prefetchNextBatch( + List allProperties, + int currentVisibleIndex, { + int batchSize = 5, + }) async { + final startIndex = currentVisibleIndex + 1; + final endIndex = + (startIndex + batchSize).clamp(0, allProperties.length).toInt(); + + if (startIndex >= allProperties.length) return; + + final nextProperties = allProperties.sublist(startIndex, endIndex); + await prefetchPropertyImages(nextProperties, limit: batchSize); + } + + /// Precache an image into Flutter's image cache (for immediate display) + Future precacheForDisplay(BuildContext context, String? imageUrl) async { + if (imageUrl == null || imageUrl.isEmpty) return; + + try { + await precacheImage( + CachedNetworkImageProvider(imageUrl), + context, + ); + } catch (_) { + // Silently fail + } + } + + /// Check if an image is already cached + bool isImageCached(String? imageUrl) { + if (imageUrl == null || imageUrl.isEmpty) return false; + return _prefetchedUrls.contains(imageUrl); + } + + /// Clear all prefetch tracking (cache remains) + void clearPrefetchTracking() { + _prefetchedUrls.clear(); + _prefetchQueue.clear(); + } + + /// Get prefetch statistics + Map get stats => { + 'prefetched': _prefetchedUrls.length, + 'queued': _prefetchQueue.length, + 'active': _activePrefetches, + }; + + String _truncateUrl(String url) { + if (url.length <= 50) return url; + return '${url.substring(0, 30)}...${url.substring(url.length - 17)}'; + } +} + +/// Mixin for controllers that want to prefetch images +mixin ImagePrefetchMixin { + ImagePrefetchService? _imagePrefetchService; + + ImagePrefetchService get imagePrefetchService { + _imagePrefetchService ??= Get.find(); + return _imagePrefetchService!; + } + + /// Prefetch images for properties + void prefetchImages(List properties, {int limit = 10}) { + if (Get.isRegistered()) { + unawaited( + imagePrefetchService.prefetchPropertyImages(properties, limit: limit), + ); + } + } + + /// Prefetch detail images for a property the user might tap + void prefetchDetailImages(Property property) { + if (Get.isRegistered()) { + unawaited(imagePrefetchService.prefetchPropertyDetailImages(property)); + } + } +} diff --git a/lib/app/data/services/location_service.dart b/lib/app/data/services/location_service.dart index caec2d2..e240498 100644 --- a/lib/app/data/services/location_service.dart +++ b/lib/app/data/services/location_service.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:geolocator/geolocator.dart'; import 'package:geocoding/geocoding.dart'; import 'package:get/get.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; class LocationService extends GetxService { @@ -34,10 +37,10 @@ class LocationService extends GetxService { @override void onInit() { super.onInit(); - _initLocationService(); + unawaited(_initLocationService()); } - void _initLocationService() async { + Future _initLocationService() async { await checkLocationPermission(); _isInitialized.value = true; AppLogger.info('LocationService initialization completed'); @@ -79,12 +82,11 @@ class LocationService extends GetxService { try { _isLoadingLocation.value = true; - bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + final bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { - Get.snackbar( - 'Location Services', - 'Please enable location services to get better recommendations', - snackPosition: SnackPosition.TOP, + AppSnackbar.warning( + title: 'Location Services', + message: 'Please enable location services to get better recommendations', ); return null; } @@ -98,10 +100,9 @@ class LocationService extends GetxService { } if (permission == LocationPermission.deniedForever) { - Get.snackbar( - 'Location Permission', - 'Location permissions are permanently denied, please enable from settings', - snackPosition: SnackPosition.TOP, + AppSnackbar.warning( + title: 'Location Permission', + message: 'Location permissions are permanently denied, please enable from settings', ); return null; } @@ -111,9 +112,7 @@ class LocationService extends GetxService { } final position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.best, - ), + locationSettings: const LocationSettings(), ); _currentPosition.value = position; @@ -146,7 +145,7 @@ class LocationService extends GetxService { Future _updateLocationNameFromPosition(Position position) async { try { - List placemarks = await placemarkFromCoordinates( + final List placemarks = await placemarkFromCoordinates( position.latitude, position.longitude, ); @@ -196,7 +195,7 @@ class LocationService extends GetxService { _selectedLng.value = lng; _locationName.value = locationName; // Best-effort: resolve and update city in background - _updateCityFromCoordinates(lat, lng); + unawaited(_updateCityFromCoordinates(lat, lng)); } // Clear manual selection so queries use current GPS location diff --git a/lib/app/data/services/places_service.dart b/lib/app/data/services/places_service.dart index 5055212..c067ca6 100644 --- a/lib/app/data/services/places_service.dart +++ b/lib/app/data/services/places_service.dart @@ -4,20 +4,22 @@ import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/app/utils/extensions/http_extensions.dart'; class PlacePrediction { + const PlacePrediction({required this.description, required this.placeId}); + final String description; final String placeId; - const PlacePrediction({required this.description, required this.placeId}); } class PlaceDetailsResult { - final double lat; - final double lng; - final String name; // formatted address or name const PlaceDetailsResult({ required this.lat, required this.lng, required this.name, }); + + final double lat; + final double lng; + final String name; // formatted address or name } class PlacesService extends GetConnect { diff --git a/lib/app/data/services/property_cache_service.dart b/lib/app/data/services/property_cache_service.dart index 6d82955..f8d82c3 100644 --- a/lib/app/data/services/property_cache_service.dart +++ b/lib/app/data/services/property_cache_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:get_storage/get_storage.dart'; @@ -142,7 +143,7 @@ class PropertyCacheService { final jsonStr = _storage.read(key); if (jsonStr == null) return null; // Update access order asynchronously (fire-and-forget) - _updateAccessOrder(id); + unawaited(_updateAccessOrder(id)); return Property.fromJson(jsonDecode(jsonStr) as Map); } catch (e) { @@ -199,8 +200,10 @@ class PropertyCacheService { Future clearExpired() async { _ensureInitialized(); final allKeys = _storage.getKeys(); - if (allKeys == null) return; - final keyList = allKeys.whereType().toList(); + final keyList = allKeys is Iterable + ? allKeys.whereType().toList() + : []; + if (keyList.isEmpty) return; for (final key in keyList) { if (!key.endsWith(_timestampSuffix) && _isCacheExpired(key)) { await _storage.remove(key); @@ -265,9 +268,19 @@ class PropertyCacheService { Map getCacheStats() { _ensureInitialized(); final allKeys = _storage.getKeys(); - final keyList = allKeys?.whereType().toList() ?? []; - final propertyKeys = keyList.where((k) => k.startsWith(_detailsPrefix) && !k.endsWith(_timestampSuffix)).length; - final exploreKeys = keyList.where((k) => k.startsWith(_exploreKey) && !k.endsWith(_timestampSuffix)).length; + final keyList = allKeys is Iterable + ? allKeys.whereType().toList() + : []; + final propertyKeys = keyList + .where( + (k) => k.startsWith(_detailsPrefix) && !k.endsWith(_timestampSuffix), + ) + .length; + final exploreKeys = keyList + .where( + (k) => k.startsWith(_exploreKey) && !k.endsWith(_timestampSuffix), + ) + .length; return { 'cachedProperties': propertyKeys, 'cachedExplorePages': exploreKeys, diff --git a/lib/app/data/services/push_notification_service.dart b/lib/app/data/services/push_notification_service.dart index d20e6fd..8590496 100644 --- a/lib/app/data/services/push_notification_service.dart +++ b/lib/app/data/services/push_notification_service.dart @@ -72,7 +72,7 @@ class PushNotificationService extends GetxService { } // Request permissions (iOS/macOS) - await _messaging.requestPermission(alert: true, badge: true, sound: true); + await _messaging.requestPermission(); // Fetch token for diagnostics try { diff --git a/lib/app/data/services/remember_me_service.dart b/lib/app/data/services/remember_me_service.dart index 1d997f6..21e744c 100644 --- a/lib/app/data/services/remember_me_service.dart +++ b/lib/app/data/services/remember_me_service.dart @@ -46,7 +46,7 @@ class RememberMeService extends GetxService { String? get storedRefreshToken => _storage.read(_refreshTokenKey); /// Enable or disable remember-me - Future setEnabled(bool value) async { + Future setEnabled({required bool value}) async { isEnabled.value = value; await _storage.write(_rememberMeFlagKey, value); if (!value) { diff --git a/lib/app/data/services/storage_service.dart b/lib/app/data/services/storage_service.dart index 5879efe..0ba16ad 100644 --- a/lib/app/data/services/storage_service.dart +++ b/lib/app/data/services/storage_service.dart @@ -17,22 +17,12 @@ class StorageService extends GetxService { late final GetStorage _box; late FlutterSecureStorage _secureStorage; - static const AndroidOptions _androidOptions = AndroidOptions( - encryptedSharedPreferences: true, - ); - - static const IOSOptions _iosOptions = IOSOptions( - accessibility: KeychainAccessibility.first_unlock_this_device, - ); Future initialize() async { try { await GetStorage.init(_boxName); _box = GetStorage(_boxName); - _secureStorage = const FlutterSecureStorage( - aOptions: _androidOptions, - iOptions: _iosOptions, - ); + _secureStorage = const FlutterSecureStorage(); if (!_ready.isCompleted) { _ready.complete(); } @@ -46,14 +36,15 @@ class StorageService extends GetxService { } // Secure token management + // DEPRECATED: Prefer using TokenService for token operations static const _tokenExpiresAtKey = 'token_expires_at'; + @Deprecated('Use TokenService.storeTokens() instead for centralized token management') Future saveTokens({ required String accessToken, String? refreshToken, String? expiresAt, }) async { - // Write with duplicate-safe fallback for iOS Keychain (-25299) await _writeSecure(_accessTokenKey, accessToken); if (refreshToken != null) { await _writeSecure(_refreshTokenKey, refreshToken); @@ -63,32 +54,34 @@ class StorageService extends GetxService { } else { await _deleteSecure(_tokenExpiresAtKey); } - // Sync to temp storage for middleware - await _syncTokensToTemp(); } + @Deprecated('Use TokenService.accessToken instead') Future getAccessToken() async { return await _secureStorage.read(key: _accessTokenKey); } + @Deprecated('Use TokenService.refreshToken instead') Future getRefreshToken() async { return await _secureStorage.read(key: _refreshTokenKey); } + @Deprecated('Use TokenService.tokenExpiration instead') Future getTokenExpiration() async { return await _secureStorage.read(key: _tokenExpiresAtKey); } + @Deprecated('Use TokenService.hasValidToken instead') Future hasAccessToken() async { final token = await getAccessToken(); return token != null && token.isNotEmpty; } + @Deprecated('Use TokenService.clearTokens() instead') Future clearTokens() async { await _deleteSecure(_accessTokenKey); await _deleteSecure(_refreshTokenKey); await _deleteSecure(_tokenExpiresAtKey); - // No temp cache of tokens } Future _writeSecure(String key, String? value) async { @@ -138,9 +131,6 @@ class StorageService extends GetxService { await _box.remove(_userDataKey); } - // Deprecated: temp sync removed for security. Kept as a no-op to avoid breakage. - Future _syncTokensToTemp() async {} - // Cache management (non-sensitive data) Future cache(String key, Map value) async { await _box.write('cache_$key', jsonEncode(value)); diff --git a/lib/app/data/services/supabase_service.dart b/lib/app/data/services/supabase_service.dart index cdb3fe1..782f100 100644 --- a/lib/app/data/services/supabase_service.dart +++ b/lib/app/data/services/supabase_service.dart @@ -2,12 +2,20 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import '../../utils/logger/app_logger.dart'; class SupabaseService { + SupabaseService({required this.url, required this.anonKey}); + final String url; final String anonKey; - SupabaseService({required this.url, required this.anonKey}); + bool _isInitialized = false; - SupabaseClient get client => Supabase.instance.client; + SupabaseClient get client { + assert( + _isInitialized, + 'SupabaseService must be initialized before accessing client', + ); + return Supabase.instance.client; + } static bool _initialized = false; @@ -22,6 +30,7 @@ class SupabaseService { try { await Supabase.initialize(url: url, anonKey: anonKey); _initialized = true; + _isInitialized = true; } catch (e, st) { AppLogger.error('Supabase initialize failed', e, st); } diff --git a/lib/app/middlewares/auth_middleware.dart b/lib/app/middlewares/auth_middleware.dart index 7f3f614..df7871f 100644 --- a/lib/app/middlewares/auth_middleware.dart +++ b/lib/app/middlewares/auth_middleware.dart @@ -5,6 +5,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:stays_app/app/routes/app_routes.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/features/auth/controllers/auth_controller.dart'; +import 'package:stays_app/app/utils/services/token_service.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -13,11 +14,17 @@ class AuthMiddleware extends GetMiddleware { // Check if AuthController exists and user is authenticated if (Get.isRegistered()) { final auth = Get.find(); - if (!auth.isAuthenticated.value) { - AppLogger.info('User not authenticated, redirecting to login'); - return const RouteSettings(name: Routes.login); + if (auth.isAuthenticated.value) { + return null; + } + } + + // Check TokenService for valid tokens (covers startup races) + if (Get.isRegistered()) { + final tokenService = Get.find(); + if (tokenService.hasValidToken) { + return null; } - return null; } // If controller doesn't exist, check Supabase session diff --git a/lib/app/ui/theme/app_animations.dart b/lib/app/ui/theme/app_animations.dart new file mode 100644 index 0000000..60b4550 --- /dev/null +++ b/lib/app/ui/theme/app_animations.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +/// Centralized animation configuration for the app. +/// Inspired by top-tier apps like Airbnb, Instagram, and Spotify. +class AppAnimations { + AppAnimations._(); + + // =============================================== + // DURATIONS + // =============================================== + + /// Instant - for immediate feedback (50ms) + static const Duration instant = Duration(milliseconds: 50); + + /// Fast - for quick micro-interactions (150ms) + static const Duration fast = Duration(milliseconds: 150); + + /// Medium - for standard transitions (250ms) + static const Duration medium = Duration(milliseconds: 250); + + /// Normal - for general animations (300ms) + static const Duration normal = Duration(milliseconds: 300); + + /// Slow - for complex transitions (400ms) + static const Duration slow = Duration(milliseconds: 400); + + /// Slower - for major layout changes (500ms) + static const Duration slower = Duration(milliseconds: 500); + + /// Extra slow - for hero animations (600ms) + static const Duration extraSlow = Duration(milliseconds: 600); + + // =============================================== + // CURVES + // =============================================== + + /// Ease out - starts fast, ends slow (most common) + static const Curve easeOut = Curves.easeOut; + + /// Ease in out - smooth acceleration and deceleration + static const Curve easeInOut = Curves.easeInOut; + + /// Ease out cubic - premium feel, used by Apple + static const Curve easeOutCubic = Curves.easeOutCubic; + + /// Ease out quart - more dramatic slowdown + static const Curve easeOutQuart = Curves.easeOutQuart; + + /// Ease out back - slight overshoot for playful feel + static const Curve easeOutBack = Curves.easeOutBack; + + /// Ease out expo - exponential deceleration + static const Curve easeOutExpo = Curves.easeOutExpo; + + /// Bounce - for attention-grabbing elements + static const Curve bounce = Curves.bounceOut; + + /// Elastic - for spring-like effects + static const Curve elastic = Curves.elasticOut; + + /// Fast out slow in - Material Design standard + static const Curve fastOutSlowIn = Curves.fastOutSlowIn; + + // =============================================== + // COMMON CURVE + DURATION COMBINATIONS + // =============================================== + + /// Standard card interaction + static const Curve cardPressCurve = easeOutCubic; + static const Duration cardPressDuration = fast; + + /// Page transition + static const Curve pageTransitionCurve = fastOutSlowIn; + static const Duration pageTransitionDuration = normal; + + /// Dialog/bottom sheet + static const Curve sheetCurve = easeOutCubic; + static const Duration sheetDuration = medium; + + /// List item animation + static const Curve listItemCurve = easeOut; + static const Duration listItemDuration = medium; + + /// Favorite animation + static const Curve favoriteCurve = elastic; + static const Duration favoriteDuration = slower; + + /// Button press + static const Curve buttonPressCurve = easeOutCubic; + static const Duration buttonPressDuration = fast; + + /// Stagger delay between list items + static const Duration staggerDelay = Duration(milliseconds: 50); + + /// Hero animation + static const Curve heroCurve = fastOutSlowIn; + static const Duration heroDuration = extraSlow; +} + +/// Extension for easy access to animation values +extension AppAnimationsExtension on BuildContext { + /// Access animation configurations via context + AppAnimations get animations => AppAnimations._(); +} diff --git a/lib/app/ui/theme/app_colors.dart b/lib/app/ui/theme/app_colors.dart index 1943e21..7472091 100644 --- a/lib/app/ui/theme/app_colors.dart +++ b/lib/app/ui/theme/app_colors.dart @@ -1,94 +1,380 @@ import 'package:flutter/material.dart'; +/// Premium color palette inspired by top-tier apps like Airbnb, Instagram, and Spotify. +/// Features sophisticated gradients, glassmorphism support, and carefully crafted semantic colors. class AppColors { AppColors._(); - static const Color primary = Color(0xFF60A5FA); - static const Color primaryDark = Color(0xFF2563EB); - static const Color primaryLight = Color(0xFF93C5FD); + // =============================================== + // PRIMARY COLORS - Premium Blue gradient system + // =============================================== + + /// Primary brand color - Vibrant sky blue + static const Color primary = Color(0xFF3B82F6); + + /// Primary dark - Deep royal blue + static const Color primaryDark = Color(0xFF1D4ED8); + + /// Primary light - Soft azure + static const Color primaryLight = Color(0xFF60A5FA); + + /// Primary pale - For subtle backgrounds + static const Color primaryPale = Color(0xFFDBEAFE); + + /// Content on primary - Always white static const Color onPrimary = Color(0xFFFFFFFF); - static const Color primaryContainer = Color(0xFFDBEAFE); + + /// Primary container - Soft blue background + static const Color primaryContainer = Color(0xFFEFF6FF); + + /// Content on primary container static const Color onPrimaryContainer = Color(0xFF1E3A8A); - static const Color secondary = Color(0xFF93C5FD); - static const Color secondaryDark = Color(0xFF60A5FA); - static const Color secondaryLight = Color(0xFFBFDBFE); - static const Color onSecondary = Color(0xFF1E3A8A); - static const Color secondaryContainer = Color(0xFFE0F2FE); - static const Color onSecondaryContainer = Color(0xFF1E40AF); + // =============================================== + // SECONDARY COLORS - Teal accents + // =============================================== + + /// Secondary - Teal accent for CTAs + static const Color secondary = Color(0xFF14B8A6); + + /// Secondary dark - Deep teal + static const Color secondaryDark = Color(0xFF0F766E); + + /// Secondary light - Soft teal + static const Color secondaryLight = Color(0xFF5EEAD4); + + /// Content on secondary + static const Color onSecondary = Color(0xFFFFFFFF); - static const Color tertiary = Color(0xFF818CF8); + /// Secondary container + static const Color secondaryContainer = Color(0xFFF0FDFA); + + // =============================================== + // TERTIARY COLORS - Purple gradients + // =============================================== + + /// Tertiary - Elegant violet + static const Color tertiary = Color(0xFF8B5CF6); + + /// Tertiary dark - Deep violet + static const Color tertiaryDark = Color(0xFF6D28D9); + + /// Tertiary light - Soft lavender + static const Color tertiaryLight = Color(0xFFC4B5FD); + + /// Content on tertiary static const Color onTertiary = Color(0xFFFFFFFF); - static const Color tertiaryContainer = Color(0xFFE0E7FF); - static const Color onTertiaryContainer = Color(0xFF3730A3); - static const Color textPrimary = Color(0xFF1E293B); + /// Tertiary container + static const Color tertiaryContainer = Color(0xFFEDE9FE); + + // =============================================== + // SURFACE & BACKGROUND COLORS + // =============================================== + + /// Primary background - Off-white for reduced eye strain + static const Color background = Color(0xFFFAFBFC); + + /// Background variant - Slightly darker + static const Color backgroundVariant = Color(0xFFF5F7FA); + + /// Surface - Pure white for cards + static const Color surface = Color(0xFFFFFFFF); + + /// Surface variant - Subtle gray + static const Color surfaceVariant = Color(0xFFF1F5F9); + + /// Surface elevated - For layered content + static const Color surfaceElevated = Color(0xFFFAFAFA); + + /// Surface dim - For disabled states + static const Color surfaceDim = Color(0xFFE8EAED); + + // =============================================== + // DARK MODE COLORS + // =============================================== + + /// Dark background - Deep navy (not pure black) + static const Color darkBackground = Color(0xFF0F172A); + + /// Dark surface - Slightly lighter + static const Color darkSurface = Color(0xFF1E293B); + + /// Dark surface elevated + static const Color darkSurfaceElevated = Color(0xFF334155); + + // =============================================== + // TEXT COLORS + // =============================================== + + /// Primary text - Near black for contrast + static const Color textPrimary = Color(0xFF0F172A); + + /// Secondary text - Medium gray static const Color textSecondary = Color(0xFF64748B); + + /// Tertiary text - Light gray static const Color textTertiary = Color(0xFF94A3B8); + + /// Text hint - Very light gray + static const Color textHint = Color(0xFFCBD5E1); + + /// Text inverse - For dark backgrounds static const Color textInverse = Color(0xFFFFFFFF); - static const Color onSurface = Color(0xFF1E293B); + + /// Text on surface + static const Color onSurface = Color(0xFF0F172A); + + /// Text on surface variant static const Color onSurfaceVariant = Color(0xFF475569); - static const Color background = Color(0xFFF8FBFF); - static const Color surface = Color(0xFFFFFFFF); - static const Color surfaceVariant = Color(0xFFF1F5F9); - static const Color surfaceContainerHighest = Color(0xFFE2E8F0); - static const Color surfaceContainerHigh = Color(0xFFEEF2F6); - static const Color surfaceContainer = Color(0xFFF8FAFC); - static const Color surfaceContainerLow = Color(0xFFFDFDFE); - static const Color surfaceContainerLowest = Color(0xFFFFFFFF); + /// Text on background static const Color onBackground = Color(0xFF1E293B); - static const Color error = Color(0xFFDC2626); + // =============================================== + // SEMANTIC COLORS + // =============================================== + + /// Error - Vibrant red + static const Color error = Color(0xFFEF4444); + + /// Error dark - Deep red + static const Color errorDark = Color(0xFFDC2626); + + /// Error container - Light red background static const Color errorContainer = Color(0xFFFEE2E2); + + /// Content on error static const Color onError = Color(0xFFFFFFFF); + + /// Content on error container static const Color onErrorContainer = Color(0xFF7F1D1D); - static const Color success = Color(0xFF16A34A); - static const Color successContainer = Color(0xFFDCFCE7); + /// Success - Vibrant green + static const Color success = Color(0xFF10B981); + + /// Success dark - Deep green + static const Color successDark = Color(0xFF059669); + + /// Success container - Light green background + static const Color successContainer = Color(0xFFD1FAE5); + + /// Content on success static const Color onSuccess = Color(0xFFFFFFFF); - static const Color onSuccessContainer = Color(0xFF14532D); - static const Color warning = Color(0xFFD97706); + /// Content on success container + static const Color onSuccessContainer = Color(0xFF064E3B); + + /// Warning - Amber + static const Color warning = Color(0xFFF59E0B); + + /// Warning dark - Deep amber + static const Color warningDark = Color(0xFFD97706); + + /// Warning container - Light amber background static const Color warningContainer = Color(0xFFFEF3C7); + + /// Content on warning static const Color onWarning = Color(0xFF1E293B); + + /// Content on warning container static const Color onWarningContainer = Color(0xFF78350F); - static const Color info = Color(0xFF0891B2); - static const Color infoContainer = Color(0xFFCCEEF3); + /// Info - Sky blue + static const Color info = Color(0xFF0EA5E9); + + /// Info dark - Deep sky + static const Color infoDark = Color(0xFF0284C7); + + /// Info container - Light blue background + static const Color infoContainer = Color(0xFFE0F2FE); + + /// Content on info static const Color onInfo = Color(0xFFFFFFFF); - static const Color onInfoContainer = Color(0xFF164E63); - static const Color outline = Color(0xFFCBD5E1); - static const Color outlineVariant = Color(0xFFE2E8F0); + /// Content on info container + static const Color onInfoContainer = Color(0xFF0C4A6E); + + // =============================================== + // UTILITY COLORS + // =============================================== + + /// Outline - For borders + static const Color outline = Color(0xFFE2E8F0); + /// Outline variant - Lighter borders + static const Color outlineVariant = Color(0xFFF1F5F9); + + /// Divider - For separators static const Color divider = Color(0xFFE2E8F0); - static const Color shadow = Color(0xFF1E293B); + /// Shadow - For elevation + static const Color shadow = Color(0xFF0F172A); + /// Overlay light - White overlay static const Color overlayLight = Color(0xFFFFFFFF); + + /// Overlay dark - Dark overlay static const Color overlayDark = Color(0xFF1E293B); - static const Color starActive = Color(0xFFFBBF24); + /// Transparent + static const Color transparent = Color(0x00000000); + + // =============================================== + // FEATURE-SPECIFIC COLORS + // =============================================== + + /// Star active - Gold rating + static const Color starActive = Color(0xFFF59E0B); + + /// Star inactive - Gray placeholder static const Color starInactive = Color(0xFFE2E8F0); + /// Favorite active - Heart red static const Color favoriteActive = Color(0xFFEF4444); - static const Color favoriteInactive = Color(0xFF94A3B8); - static const Color transparent = Color(0x00000000); + /// Favorite inactive - Heart outline + static const Color favoriteInactive = Color(0xFF9CA3AF); + + /// Verified badge - Blue checkmark + static const Color verified = Color(0xFF3B82F6); - // Use toARGB32() instead of deprecated .value for explicit color conversion + /// Premium/Gold badge + static const Color premium = Color(0xFFF59E0B); + + /// New badge - Fresh green + static const Color fresh = Color(0xFF10B981); + + // =============================================== + // GRADIENT COLORS + // =============================================== + + /// Primary gradient colors + static const List primaryGradient = [ + Color(0xFF3B82F6), + Color(0xFF2563EB), + ]; + + /// Sunset gradient + static const List sunsetGradient = [ + Color(0xFFF59E0B), + Color(0xFFEF4444), + ]; + + /// Aurora gradient + static const List auroraGradient = [ + Color(0xFF8B5CF6), + Color(0xFF3B82F6), + Color(0xFF14B8A6), + ]; + + /// Ocean gradient + static const List oceanGradient = [ + Color(0xFF0EA5E9), + Color(0xFF2563EB), + ]; + + /// Forest gradient + static const List forestGradient = [ + Color(0xFF10B981), + Color(0xFF059669), + ]; + + // =============================================== + // GLASSMORPHISM COLORS + // =============================================== + + /// Glass surface for light mode + static Color glassLight({double opacity = 0.7}) => + const Color(0xFFFFFFFF).withValues(alpha: opacity); + + /// Glass surface for dark mode + static Color glassDark({double opacity = 0.6}) => + const Color(0xFF1E293B).withValues(alpha: opacity); + + /// Glass border for light mode + static Color glassBorderLight({double opacity = 0.15}) => + const Color(0xFF000000).withValues(alpha: opacity); + + /// Glass border for dark mode + static Color glassBorderDark({double opacity = 0.1}) => + const Color(0xFFFFFFFF).withValues(alpha: opacity); + + // =============================================== + // MATERIAL COLOR SWATCH + // =============================================== + + /// Material color swatch for primary static MaterialColor get primarySwatch => MaterialColor(primary.toARGB32(), { - 50: primaryLight, - 100: primaryLight, - 200: primary, - 300: primary, - 400: primaryDark, + 50: const Color(0xFFEFF6FF), + 100: const Color(0xFFDBEAFE), + 200: const Color(0xFFBFDBFE), + 300: primaryLight, + 400: const Color(0xFF60A5FA), 500: primary, 600: primaryDark, - 700: primaryDark, - 800: primaryDark, - 900: primaryDark, + 700: const Color(0xFF1D4ED8), + 800: const Color(0xFF1E40AF), + 900: const Color(0xFF1E3A8A), }); } + +/// Premium gradient definitions for common use cases. +class AppGradients { + AppGradients._(); + + /// Primary gradient for buttons and cards + static const LinearGradient primary = LinearGradient( + colors: AppColors.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + /// Subtle vertical gradient for overlays + static const LinearGradient subtleOverlay = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x00000000), + Color(0x40000000), + Color(0x80000000), + ], + ); + + /// Glassmorphism gradient + static LinearGradient glass({bool isDark = false}) => LinearGradient( + colors: [ + isDark + ? AppColors.glassDark(opacity: 0.3) + : AppColors.glassLight(opacity: 0.5), + isDark + ? AppColors.glassDark(opacity: 0.1) + : AppColors.glassLight(opacity: 0.2), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + /// Shimmer loading gradient + static const LinearGradient shimmer = LinearGradient( + colors: [ + Color(0xFFE5E7EB), + Color(0xFFF3F4F6), + Color(0xFFE5E7EB), + ], + stops: [0.0, 0.5, 1.0], + begin: Alignment(-1.0, -0.5), + end: Alignment(1.0, 0.5), + ); + + /// Card hover gradient + static const LinearGradient cardHover = LinearGradient( + colors: [ + Color(0x00FFFFFF), + Color(0x10FFFFFF), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); +} diff --git a/lib/app/ui/theme/app_dimensions.dart b/lib/app/ui/theme/app_dimensions.dart index cf315aa..3427516 100644 --- a/lib/app/ui/theme/app_dimensions.dart +++ b/lib/app/ui/theme/app_dimensions.dart @@ -1,4 +1,78 @@ +import 'package:flutter/widgets.dart'; + +/// Responsive design system constants for the app. class AppDimensions { - static const double padding = 16; - static const double radius = 12; + // Spacing scale + static const double xs = 4; + static const double sm = 8; + static const double md = 12; + static const double lg = 16; + static const double xl = 20; + static const double xxl = 24; + static const double xxxl = 32; + static const double huge = 40; + + // Legacy support + static const double padding = lg; + static const double radius = radiusMd; + + // Border radius scale + static const double radiusSm = 8; + static const double radiusMd = 12; + static const double radiusLg = 16; + static const double radiusXl = 20; + static const double radiusXxl = 24; + + // Card aspect ratios + static const double cardPortraitRatio = 3 / 4; // Taller cards + static const double cardLandscapeRatio = 4 / 3; // Wider images + static const double cardSquareRatio = 1.0; + + // Responsive card sizing (percentages of screen width) + static const double cardWidthFraction = 0.75; // 75% of screen width + static const double cardCompactWidthFraction = 0.65; // 65% for compact cards + + // Image quality for caching + static const int imageCacheWidth = 600; + static const int imageDiskCacheWidth = 1200; + + // Hero image height (percentage of screen height) + static const double heroImageHeightFraction = 0.40; // 40% of screen height + static const double mapHeight = 200; // Fixed map height in dp + + // Section spacing + static const double sectionSpacingXs = 24; + static const double sectionSpacingSm = 28; + static const double sectionSpacingMd = 32; + static const double sectionSpacingLg = 36; + static const double sectionSpacingXl = 40; + + // Card content padding + static const double cardPaddingSm = 14; + static const double cardPaddingMd = 16; + static const double cardPaddingLg = 18; + + // Explore page specific dimensions + static const double heroSectionHeight = 100; + static const double featuredCardHeight = 180; + static const double horizontalSectionHeight = 220; + static const double exploreSectionSpacing = 20; + static const double exploreSectionSpacingLarge = 28; + static const double featuredCardAspectRatio = 16 / 9; +} + +/// Extension to provide responsive dimensions based on screen size. +extension ResponsiveDimensions on BuildContext { + /// Gets the screen width. + double get screenWidth => MediaQuery.of(this).size.width; + + /// Gets the screen height. + double get screenHeight => MediaQuery.of(this).size.height; + + /// Calculates responsive card width (75% of screen width by default). + double responsiveCardWidth([double fraction = AppDimensions.cardWidthFraction]) => + screenWidth * fraction; + + /// Calculates responsive hero image height (40% of screen height). + double get responsiveHeroHeight => screenHeight * AppDimensions.heroImageHeightFraction; } diff --git a/lib/app/ui/theme/app_theme.dart b/lib/app/ui/theme/app_theme.dart index 425b2a0..5a8922c 100644 --- a/lib/app/ui/theme/app_theme.dart +++ b/lib/app/ui/theme/app_theme.dart @@ -4,27 +4,55 @@ import 'package:flutter/services.dart'; import 'app_colors.dart'; import 'app_text_styles.dart'; +/// Premium theme configuration inspired by top-tier apps. +/// Features sophisticated shadows, smooth animations, and glassmorphism effects. class AppTheme { + /// Light color scheme with premium shadows and surfaces static final ColorScheme _lightColorScheme = ColorScheme.fromSeed( seedColor: AppColors.primary, brightness: Brightness.light, ).copyWith( surface: AppColors.surface, - surfaceContainerHighest: const Color(0xFFE6F0FF), - outlineVariant: const Color(0xFFD7E4FF), + surfaceContainerHighest: AppColors.surfaceVariant, + surfaceContainerHigh: const Color(0xFFF1F5F9), + surfaceContainer: const Color(0xFFF8FAFC), + outlineVariant: AppColors.outlineVariant, onSurface: AppColors.textPrimary, + primary: AppColors.primary, + onPrimary: AppColors.onPrimary, + primaryContainer: AppColors.primaryContainer, + onPrimaryContainer: AppColors.onPrimaryContainer, + secondary: AppColors.secondary, + onSecondary: AppColors.onSecondary, + tertiary: AppColors.tertiary, + onTertiary: AppColors.onTertiary, + error: AppColors.error, + onError: AppColors.onError, ); + /// Dark color scheme with deep navy tones static final ColorScheme _darkColorScheme = ColorScheme.fromSeed( seedColor: AppColors.primary, brightness: Brightness.dark, ).copyWith( - surface: const Color(0xFF1E293B), - surfaceContainerHighest: const Color(0xFF273449), + surface: AppColors.darkSurface, + surfaceContainerHighest: AppColors.darkSurfaceElevated, + surfaceContainerHigh: const Color(0xFF334155), + surfaceContainer: const Color(0xFF1E293B), outlineVariant: const Color(0xFF334155), onSurface: Colors.white, + primary: AppColors.primaryLight, + onPrimary: AppColors.onPrimary, + primaryContainer: const Color(0xFF1E3A8A), + onPrimaryContainer: const Color(0xFFDBEAFE), + secondary: AppColors.secondaryLight, + onSecondary: AppColors.onSecondary, + tertiary: AppColors.tertiaryLight, + onTertiary: AppColors.onTertiary, + error: AppColors.error, + onError: AppColors.onError, ); static ThemeData get lightTheme => _baseTheme(_lightColorScheme); @@ -40,10 +68,12 @@ class AppTheme { return ThemeData( useMaterial3: true, colorScheme: colorScheme, - scaffoldBackgroundColor: colorScheme.surface, + scaffoldBackgroundColor: isDark ? AppColors.darkBackground : AppColors.background, canvasColor: colorScheme.surface, - splashColor: colorScheme.primary.withValues(alpha: 0.1), - highlightColor: colorScheme.primary.withValues(alpha: 0.05), + splashFactory: InkRipple.splashFactory, + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + hoverColor: colorScheme.primary.withValues(alpha: 0.04), textTheme: baseTypography.apply( bodyColor: colorScheme.onSurface, displayColor: colorScheme.onSurface, @@ -52,46 +82,118 @@ class AppTheme { bodyColor: colorScheme.onPrimary, displayColor: colorScheme.onPrimary, ), + // Premium AppBar with glass effect appBarTheme: AppBarTheme( elevation: 0, + scrolledUnderElevation: 0, centerTitle: true, - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.surface.withValues(alpha: 0.95), foregroundColor: colorScheme.onSurface, - titleTextStyle: AppTextStyles.h2.copyWith(color: colorScheme.onSurface), + titleTextStyle: AppTextStyles.h2.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + ), systemOverlayStyle: isDark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, ), + // Premium Card theme with smooth shadows cardTheme: CardThemeData( color: colorScheme.surface, - elevation: 0, + elevation: isDark ? 0 : 2, margin: EdgeInsets.zero, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: isDark + ? BorderSide.none + : BorderSide( + color: AppColors.outline.withValues(alpha: 0.3), + width: 0.5, + ), + ), + shadowColor: isDark + ? Colors.black.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.06), ), + // Premium Dialog with rounded corners dialogTheme: DialogThemeData( backgroundColor: colorScheme.surface, - titleTextStyle: AppTextStyles.h2.copyWith(color: colorScheme.onSurface), + elevation: 24, + shadowColor: isDark + ? Colors.black.withValues(alpha: 0.5) + : Colors.black.withValues(alpha: 0.15), + titleTextStyle: AppTextStyles.h2.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), contentTextStyle: baseTypography.bodyMedium?.copyWith( color: colorScheme.onSurface, + height: 1.5, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + alignment: Alignment.center, + ), + // Premium bottom sheet + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: colorScheme.surface, + elevation: 16, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shadowColor: isDark + ? Colors.black.withValues(alpha: 0.4) + : Colors.black.withValues(alpha: 0.1), + modalElevation: 16, + dragHandleColor: colorScheme.onSurface.withValues(alpha: 0.4), + dragHandleSize: const Size(40, 4), + ), + // Premium divider + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + thickness: 1, + space: 1, ), - dividerTheme: DividerThemeData(color: colorScheme.outlineVariant), + // Premium snackbar snackBarTheme: SnackBarThemeData( backgroundColor: colorScheme.surface, + elevation: 12, contentTextStyle: baseTypography.bodyMedium?.copyWith( color: colorScheme.onSurface, + fontWeight: FontWeight.w500, ), behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + actionBackgroundColor: colorScheme.primary, + actionTextColor: colorScheme.onPrimary, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), + // Premium bottom navigation bottomNavigationBarTheme: BottomNavigationBarThemeData( - backgroundColor: colorScheme.surface, + backgroundColor: colorScheme.surface.withValues(alpha: 0.95), selectedItemColor: colorScheme.primary, - unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.6), + unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.5), showUnselectedLabels: true, type: BottomNavigationBarType.fixed, + elevation: isDark ? 0 : 8, + selectedLabelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.2, + ), ), + // Premium switch switchTheme: SwitchThemeData( thumbColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { @@ -101,59 +203,493 @@ class AppTheme { }), trackColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { - return colorScheme.primary.withValues(alpha: 0.4); + return colorScheme.primary.withValues(alpha: 0.5); + } + return colorScheme.outlineVariant.withValues(alpha: 0.4); + }), + trackOutlineColor: WidgetStateProperty.all(Colors.transparent), + ), + // Premium checkbox + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.primary; + } + return Colors.transparent; + }), + checkColor: WidgetStateProperty.all(colorScheme.onPrimary), + side: BorderSide( + color: colorScheme.outlineVariant, + width: 2, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + // Premium radio + radioTheme: RadioThemeData( + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.primary; } - return colorScheme.outlineVariant.withValues(alpha: 0.5); + return Colors.transparent; }), ), + // Premium elevated button with gradient support elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 48), + minimumSize: const Size(0, 52), + elevation: isDark ? 0 : 2, + shadowColor: colorScheme.primary.withValues(alpha: 0.3), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), ), foregroundColor: colorScheme.onPrimary, backgroundColor: colorScheme.primary, - textStyle: AppTextStyles.button, + textStyle: AppTextStyles.button.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), ), ), + // Premium filled button + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(0, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + foregroundColor: colorScheme.onPrimary, + backgroundColor: colorScheme.primary, + textStyle: AppTextStyles.button.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + ), + ), + // Premium outlined button + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 52), + side: BorderSide( + color: colorScheme.outlineVariant, + width: 1.5, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + foregroundColor: colorScheme.onSurface, + backgroundColor: Colors.transparent, + textStyle: AppTextStyles.button.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + ), + ), + // Premium text button + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size(0, 44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + foregroundColor: colorScheme.primary, + textStyle: AppTextStyles.button.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + // Premium icon button + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + foregroundColor: colorScheme.onSurface, + backgroundColor: Colors.transparent, + hoverColor: colorScheme.primary.withValues(alpha: 0.08), + focusColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + padding: const EdgeInsets.all(12), + ), + ), + // Premium floating action button + floatingActionButtonTheme: FloatingActionButtonThemeData( + elevation: isDark ? 2 : 4, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + splashColor: colorScheme.onPrimary.withValues(alpha: 0.3), + iconSize: 24, + ), + // Premium chip theme + chipTheme: ChipThemeData( + backgroundColor: colorScheme.surfaceContainerHighest, + brightness: colorScheme.brightness, + labelStyle: baseTextStyle(colorScheme.onSurface).copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide.none, + secondaryLabelStyle: baseTextStyle(colorScheme.onPrimary).copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.primary, + ), + // Premium text selection textSelectionTheme: TextSelectionThemeData( cursorColor: colorScheme.primary, - selectionColor: colorScheme.primary.withValues(alpha: 0.35), + selectionColor: colorScheme.primary.withValues(alpha: 0.3), selectionHandleColor: colorScheme.primary, ), + // Premium input decoration inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: isDark - ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.6) - : AppColors.background, + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : AppColors.surfaceVariant.withValues(alpha: 0.6), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.primary, width: 1.4), + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colorScheme.primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colorScheme.error, width: 1.5), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colorScheme.error, width: 1.5), ), contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, + horizontal: 18, + vertical: 18, ), hintStyle: TextStyle( - color: colorScheme.onSurface.withValues(alpha: 0.6), + color: colorScheme.onSurface.withValues(alpha: 0.5), + fontWeight: FontWeight.w400, + ), + labelStyle: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.7), + fontWeight: FontWeight.w500, + ), + floatingLabelStyle: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + prefixStyle: TextStyle( + color: colorScheme.onSurface, + ), + suffixStyle: TextStyle( + color: colorScheme.onSurface, ), - labelStyle: TextStyle(color: colorScheme.onSurface), - floatingLabelStyle: TextStyle(color: colorScheme.primary), - prefixStyle: TextStyle(color: colorScheme.onSurface), - suffixStyle: TextStyle(color: colorScheme.onSurface), counterStyle: TextStyle( - color: colorScheme.onSurface.withValues(alpha: 0.8), + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + errorStyle: TextStyle( + color: colorScheme.error, + fontWeight: FontWeight.w500, + ), + focusColor: colorScheme.primary.withValues(alpha: 0.1), + hoverColor: colorScheme.primary.withValues(alpha: 0.04), + ), + // Premium slider + sliderTheme: SliderThemeData( + activeTrackColor: colorScheme.primary, + inactiveTrackColor: colorScheme.surfaceContainerHighest, + thumbColor: colorScheme.primary, + overlayColor: colorScheme.primary.withValues(alpha: 0.12), + valueIndicatorColor: colorScheme.primary, + valueIndicatorTextStyle: baseTextStyle(colorScheme.onPrimary), + trackHeight: 4, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), + trackShape: const RoundedRectSliderTrackShape(), + ), + // Premium progress indicator + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + linearTrackColor: colorScheme.surfaceContainerHighest, + circularTrackColor: colorScheme.surfaceContainerHighest, + refreshBackgroundColor: colorScheme.surface, + ), + // Premium scrollbar + scrollbarTheme: ScrollbarThemeData( + thickness: WidgetStateProperty.all(6), + trackVisibility: WidgetStateProperty.all(false), + crossAxisMargin: 4, + radius: const Radius.circular(3), + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.dragged)) { + return colorScheme.onSurface.withValues(alpha: 0.6); + } + return colorScheme.onSurface.withValues(alpha: 0.3); + }), + ), + // Premium tooltip + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: isDark ? Colors.grey.shade800 : Colors.grey.shade900, + borderRadius: BorderRadius.circular(10), + ), + textStyle: baseTextStyle(Colors.white).copyWith( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + waitDuration: const Duration(milliseconds: 500), + showDuration: const Duration(seconds: 3), + ), + // Premium badge + badgeTheme: BadgeThemeData( + backgroundColor: colorScheme.error, + textColor: colorScheme.onError, + smallSize: 8, + largeSize: 20, + textStyle: baseTextStyle(colorScheme.onError).copyWith( + fontSize: 11, + fontWeight: FontWeight.w600, + ), + padding: const EdgeInsets.symmetric(horizontal: 6), + alignment: Alignment.topRight, + ), + // Premium list tile + listTileTheme: ListTileThemeData( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + tileColor: Colors.transparent, + selectedTileColor: colorScheme.primaryContainer.withValues(alpha: 0.5), + iconColor: colorScheme.onSurface.withValues(alpha: 0.6), + selectedColor: colorScheme.primary, + titleTextStyle: baseTextStyle(colorScheme.onSurface).copyWith( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + subtitleTextStyle: baseTextStyle(colorScheme.onSurfaceVariant).copyWith( + fontWeight: FontWeight.w400, + fontSize: 14, + ), + ), + // Premium navigation rail + navigationRailTheme: NavigationRailThemeData( + backgroundColor: colorScheme.surface, + elevation: isDark ? 0 : 4, + selectedIconTheme: IconThemeData(color: colorScheme.primary, size: 24), + unselectedIconTheme: IconThemeData( + color: colorScheme.onSurface.withValues(alpha: 0.6), + size: 24, + ), + selectedLabelTextStyle: baseTextStyle(colorScheme.primary).copyWith( + fontWeight: FontWeight.w600, + fontSize: 12, + ), + unselectedLabelTextStyle: baseTextStyle( + colorScheme.onSurface.withValues(alpha: 0.6), + ).copyWith( + fontWeight: FontWeight.w500, + fontSize: 12, + ), + labelType: NavigationRailLabelType.all, + ), + // Premium tab bar + tabBarTheme: TabBarThemeData( + labelColor: colorScheme.primary, + unselectedLabelColor: colorScheme.onSurface.withValues(alpha: 0.6), + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + letterSpacing: 0.3, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + letterSpacing: 0.3, + ), + indicator: BoxDecoration( + border: Border( + bottom: BorderSide( + color: colorScheme.primary, + width: 2, + ), + ), + ), + indicatorSize: TabBarIndicatorSize.label, + dividerColor: colorScheme.outlineVariant.withValues(alpha: 0.5), + splashFactory: InkRipple.splashFactory, + overlayColor: WidgetStateProperty.all( + colorScheme.primary.withValues(alpha: 0.08), + ), + ), + // Premium bottom app bar + bottomAppBarTheme: BottomAppBarThemeData( + color: colorScheme.surface, + elevation: isDark ? 0 : 8, + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: const CircularNotchedRectangle(), + ), + // Premium navigation drawer + navigationDrawerTheme: NavigationDrawerThemeData( + backgroundColor: colorScheme.surface, + elevation: isDark ? 0 : 16, + shadowColor: isDark + ? Colors.black.withValues(alpha: 0.4) + : Colors.black.withValues(alpha: 0.1), + indicatorColor: colorScheme.primaryContainer.withValues(alpha: 0.6), + indicatorShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + tileHeight: 56, + ), + // Premium search bar + searchBarTheme: SearchBarThemeData( + backgroundColor: WidgetStateProperty.all( + isDark + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : AppColors.surfaceVariant.withValues(alpha: 0.6), + ), + elevation: WidgetStateProperty.all(0), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide.none, + ), + ), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + textStyle: WidgetStateProperty.all( + baseTextStyle(colorScheme.onSurface).copyWith(fontSize: 16), + ), + hintStyle: WidgetStateProperty.all( + baseTextStyle(colorScheme.onSurface.withValues(alpha: 0.5)) + .copyWith(fontSize: 16), + ), + ), + // Premium search view + searchViewTheme: SearchViewThemeData( + backgroundColor: colorScheme.surface, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + headerTextStyle: baseTextStyle(colorScheme.onSurface).copyWith( + fontWeight: FontWeight.w600, + fontSize: 20, + ), + headerHintStyle: baseTextStyle( + colorScheme.onSurface.withValues(alpha: 0.5), + ).copyWith( + fontWeight: FontWeight.w400, + fontSize: 20, + ), + ), + // Premium menu + menuTheme: MenuThemeData( + style: MenuStyle( + backgroundColor: WidgetStateProperty.all(colorScheme.surface), + elevation: WidgetStateProperty.all(8), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + shadowColor: WidgetStateProperty.all( + isDark + ? Colors.black.withValues(alpha: 0.4) + : Colors.black.withValues(alpha: 0.1), + ), + ), + ), + // Premium popup menu + popupMenuTheme: PopupMenuThemeData( + color: colorScheme.surface, + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + textStyle: baseTextStyle(colorScheme.onSurface).copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + // Premium DataTable + dataTableTheme: DataTableThemeData( + headingTextStyle: baseTextStyle(colorScheme.onSurface).copyWith( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + dataTextStyle: baseTextStyle(colorScheme.onSurface).copyWith( + fontWeight: FontWeight.w400, + fontSize: 14, + ), + headingRowColor: WidgetStateProperty.all( + colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + ), + dataRowColor: WidgetStateProperty.all(Colors.transparent), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + width: 1, + ), + ), + ), + ), + // Premium time picker + timePickerTheme: TimePickerThemeData( + backgroundColor: colorScheme.surface, + hourMinuteColor: colorScheme.onSurface.withValues(alpha: 0.9), + hourMinuteTextColor: colorScheme.primary, + dialHandColor: colorScheme.primary, + dialBackgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + entryModeIconColor: colorScheme.primary, + ), + // Premium date picker + datePickerTheme: DatePickerThemeData( + backgroundColor: colorScheme.surface, + headerBackgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), + headerForegroundColor: colorScheme.primary, + todayForegroundColor: WidgetStateProperty.all(colorScheme.primary), + todayBackgroundColor: WidgetStateProperty.all( + colorScheme.primaryContainer.withValues(alpha: 0.5), + ), + dayForegroundColor: WidgetStateProperty.all(colorScheme.onSurface), + dayStyle: baseTextStyle(colorScheme.onSurface).copyWith( + fontWeight: FontWeight.w500, + ), + yearForegroundColor: WidgetStateProperty.all(colorScheme.onSurface), + yearStyle: baseTextStyle(colorScheme.onSurface).copyWith( + fontWeight: FontWeight.w500, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), ), ), ); } + + static TextStyle baseTextStyle(Color color) => TextStyle(color: color); } diff --git a/lib/app/ui/theme/page_transitions.dart b/lib/app/ui/theme/page_transitions.dart new file mode 100644 index 0000000..9eaf612 --- /dev/null +++ b/lib/app/ui/theme/page_transitions.dart @@ -0,0 +1,563 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'app_animations.dart'; + +// =============================================== +// PREMIUM PAGE TRANSITIONS +// =============================================== + +/// Premium fade-in slide-up transition with parallax. +/// Use for bottom sheets, dialogs, and modal presentations. +class FadeInUpTransition extends PageRouteBuilder { + FadeInUpTransition({ + required this.page, + this.duration = AppAnimations.pageTransitionDuration, + this.curve = AppAnimations.pageTransitionCurve, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: const Duration(milliseconds: 250), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + const begin = Offset(0.0, 0.08); + const end = Offset.zero; + final tween = Tween(begin: begin, end: end); + final offsetAnimation = animation.drive(tween.chain(CurveTween(curve: curve))); + + // Parallax effect on background + final parallaxAnimation = Tween( + begin: 0, + end: 0.03, + ).animate(CurvedAnimation(parent: secondaryAnimation, curve: curve)); + + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: offsetAnimation, + child: Transform.translate( + offset: Offset(0, -100 * parallaxAnimation.value), + child: child, + ), + ), + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; +} + +/// Slide-in from right transition (iOS style). +/// Use for navigation between related screens. +class SlideInRightTransition extends PageRouteBuilder { + SlideInRightTransition({ + required this.page, + this.duration = AppAnimations.pageTransitionDuration, + this.curve = AppAnimations.easeOutCubic, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: const Duration(milliseconds: 250), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + const begin = Offset(1.0, 0.0); + const end = Offset.zero; + final tween = Tween(begin: begin, end: end); + final offsetAnimation = animation.drive(tween.chain(CurveTween(curve: curve))); + + // Add slight fade for smoother entrance + final fadeAnimation = Tween(begin: 0.85, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOut), + ); + + // Parallax on outgoing page + final outgoingAnimation = Tween( + begin: Offset.zero, + end: const Offset(-0.1, 0.0), + ).animate(CurvedAnimation(parent: secondaryAnimation, curve: curve)); + + return SlideTransition( + position: outgoingAnimation, + child: Opacity( + opacity: fadeAnimation.value, + child: SlideTransition( + position: offsetAnimation, + child: child, + ), + ), + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; +} + +/// Scale and fade transition with bounce effect. +/// Use for focused content, image views, and hero-like transitions. +class ScaleFadeTransition extends PageRouteBuilder { + ScaleFadeTransition({ + required this.page, + this.duration = AppAnimations.pageTransitionDuration, + this.curve = AppAnimations.fastOutSlowIn, + this.alignment = Alignment.center, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: const Duration(milliseconds: 200), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + // Use scale from 0.95 to 1.0 for subtle effect + final scaleAnimation = Tween(begin: 0.92, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: curve), + ); + + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: scaleAnimation, + alignment: alignment, + child: child, + ), + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; + final Alignment alignment; +} + +/// Material-inspired shared axis transition. +/// Use for navigation that feels like moving along an axis. +class SharedAxisTransition extends PageRouteBuilder { + SharedAxisTransition({ + required this.page, + required this.type, + this.duration = AppAnimations.pageTransitionDuration, + this.fillColor = Colors.black, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + switch (type) { + case SharedAxisTransitionType.horizontal: + return _buildHorizontalTransition(animation, secondaryAnimation, child); + case SharedAxisTransitionType.vertical: + return _buildVerticalTransition(animation, secondaryAnimation, child); + case SharedAxisTransitionType.scaled: + return _buildScaledTransition(animation, secondaryAnimation, child); + } + }, + ); + + final Widget page; + final SharedAxisTransitionType type; + final Duration duration; + final Color fillColor; + + static Widget _buildHorizontalTransition(Animation animation, Animation secondaryAnimation, Widget child) { + return SlideTransition( + position: Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), + child: SlideTransition( + position: Tween( + begin: Offset.zero, + end: const Offset(-0.3, 0.0), + ).animate(CurvedAnimation(parent: secondaryAnimation, curve: Curves.easeOutCubic)), + child: child, + ), + ); + } + + static Widget _buildVerticalTransition(Animation animation, Animation secondaryAnimation, Widget child) { + return SlideTransition( + position: Tween( + begin: const Offset(0.0, 1.0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), + child: FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOut), + ), + child: child, + ), + ); + } + + static Widget _buildScaledTransition(Animation animation, Animation secondaryAnimation, Widget child) { + return FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOut), + ), + child: ScaleTransition( + scale: Tween(begin: 0.9, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ); + } +} + +enum SharedAxisTransitionType { horizontal, vertical, scaled } + +/// iOS-style parallax transition. +/// Simulates iOS navigation with parallax effect on the previous page. +class IOSParallaxTransition extends PageRouteBuilder { + IOSParallaxTransition({ + required this.page, + this.duration = AppAnimations.pageTransitionDuration, + this.curve = AppAnimations.easeOutCubic, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + // Parallax effect on incoming page + final slideIn = Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: curve)); + + return SlideTransition( + position: slideIn, + child: Opacity( + opacity: Tween(begin: 0.95, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: curve), + ).value, + child: child, + ), + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; +} + +/// Rotation 3D flip transition. +/// Use for unique, memorable transitions between unrelated screens. +class Flip3DTransition extends PageRouteBuilder { + Flip3DTransition({ + required this.page, + this.duration = AppAnimations.slower, + this.curve = AppAnimations.easeOutCubic, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + final angle = animation.value * 3.14159; + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(angle), + child: animation.value < 0.5 + ? Opacity( + opacity: 1 - animation.value * 2, + child: child, + ) + : Opacity( + opacity: (animation.value - 0.5) * 2, + child: child, + ), + ); + }, + child: child, + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; +} + +/// Premium glassmorphism transition with blur effect. +/// Creates a frosted glass effect during the transition. +class GlassTransition extends PageRouteBuilder { + GlassTransition({ + required this.page, + this.duration = AppAnimations.pageTransitionDuration, + this.curve = AppAnimations.easeOutCubic, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: const Duration(milliseconds: 250), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: curve), + ); + + final scaleAnimation = Tween(begin: 0.95, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: curve), + ); + + final blurAnimation = Tween(begin: 10.0, end: 0.0).animate( + CurvedAnimation(parent: animation, curve: curve), + ); + + return ClipRect( + child: FadeTransition( + opacity: opacityAnimation, + child: ScaleTransition( + scale: scaleAnimation, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: blurAnimation.value, + sigmaY: blurAnimation.value, + ), + child: child, + ), + ), + ), + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; +} + +/// Rotate and scale transition with depth effect. +/// Creates a cinematic entrance with rotation. +class RotateScaleTransition extends PageRouteBuilder { + RotateScaleTransition({ + required this.page, + this.duration = AppAnimations.slower, + this.curve = AppAnimations.easeOutCubic, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: const Duration(milliseconds: 300), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curvedAnimation = CurvedAnimation(parent: animation, curve: curve); + + final opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: const Interval(0.0, 0.6, curve: Curves.easeOut)), + ); + + final rotationAnimation = Tween(begin: -0.1, end: 0.0).animate(curvedAnimation); + final scaleAnimation = Tween(begin: 0.85, end: 1.0).animate(curvedAnimation); + + return Opacity( + opacity: opacityAnimation.value, + child: Transform.rotate( + angle: rotationAnimation.value, + child: Transform.scale( + scale: scaleAnimation.value, + alignment: Alignment.center, + child: child, + ), + ), + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; +} + +/// Expand from corner transition. +/// Creates a circular reveal effect from the bottom-right corner. +class ExpandFromCornerTransition extends PageRouteBuilder { + ExpandFromCornerTransition({ + required this.page, + this.duration = AppAnimations.normal, + this.curve = AppAnimations.easeOutCubic, + this.alignment = Alignment.bottomRight, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: const Duration(milliseconds: 200), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curvedAnimation = CurvedAnimation(parent: animation, curve: curve); + + final scaleAnimation = Tween(begin: 0.0, end: 1.0).animate(curvedAnimation); + final opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: const Interval(0.3, 1.0)), + ); + + return ClipRect( + child: Align( + alignment: alignment, + child: FadeTransition( + opacity: opacityAnimation, + child: FractionalTranslation( + translation: Offset.zero, + child: Transform.scale( + scale: scaleAnimation.value, + alignment: alignment, + child: child, + ), + ), + ), + ), + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; + final Alignment alignment; +} + +/// Slide and fade transition with stagger effect. +/// Elements slide in with different timing for a cascading effect. +class StaggeredSlideTransition extends PageRouteBuilder { + StaggeredSlideTransition({ + required this.page, + this.duration = AppAnimations.normal, + this.curve = AppAnimations.easeOutCubic, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: const Duration(milliseconds: 200), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curvedAnimation = CurvedAnimation(parent: animation, curve: curve); + + final slideAnimation = Tween( + begin: const Offset(0.0, 0.15), + end: Offset.zero, + ).animate(curvedAnimation); + + final opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: const Interval(0.2, 1.0)), + ); + + return SlideTransition( + position: slideAnimation, + child: FadeTransition( + opacity: opacityAnimation, + child: child, + ), + ); + }, + ); + + final Widget page; + final Duration duration; + final Curve curve; +} + +/// No transition - instant page change. +/// Use for tab switching or when transitions aren't desired. +class InstantTransition extends PageRouteBuilder { + InstantTransition({required Widget page}) + : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + transitionsBuilder: (context, animation, secondaryAnimation, child) => child, + ); +} + +// =============================================== +// NAVIGATION HELPERS +// =============================================== + +/// Extension for easy navigation with custom transitions. +extension CustomNavigationExtension on BuildContext { + /// Push with fade-in-up transition + Future fadeInUp({required Widget page}) { + return Navigator.push( + this, + FadeInUpTransition(page: page) as Route, + ); + } + + /// Push with slide-in-right transition + Future slideInRight({required Widget page}) { + return Navigator.push( + this, + SlideInRightTransition(page: page) as Route, + ); + } + + /// Push with scale-fade transition + Future scaleFade({required Widget page, Alignment alignment = Alignment.center}) { + return Navigator.push( + this, + ScaleFadeTransition(page: page, alignment: alignment) as Route, + ); + } + + /// Push with shared axis transition + Future sharedAxis({ + required Widget page, + SharedAxisTransitionType type = SharedAxisTransitionType.horizontal, + }) { + return Navigator.push( + this, + SharedAxisTransition(page: page, type: type) as Route, + ); + } + + /// Push with iOS parallax transition + Future iosParallax({required Widget page}) { + return Navigator.push( + this, + IOSParallaxTransition(page: page) as Route, + ); + } + + /// Push with 3D flip transition + Future flip3D({required Widget page}) { + return Navigator.push( + this, + Flip3DTransition(page: page) as Route, + ); + } + + /// Replace current route with custom transition + Future replaceWithTransition({required Widget page, required Widget newPage}) { + return Navigator.pushReplacement( + this, + FadeInUpTransition(page: newPage) as Route, + ); + } + + /// Push with glassmorphism transition + Future glass({required Widget page}) { + return Navigator.push( + this, + GlassTransition(page: page) as Route, + ); + } + + /// Push with rotate and scale transition + Future rotateScale({required Widget page}) { + return Navigator.push( + this, + RotateScaleTransition(page: page) as Route, + ); + } + + /// Push with expand from corner transition + Future expandFromCorner({ + required Widget page, + Alignment alignment = Alignment.bottomRight, + }) { + return Navigator.push( + this, + ExpandFromCornerTransition(page: page, alignment: alignment) as Route, + ); + } + + /// Push with staggered slide transition + Future staggeredSlide({required Widget page}) { + return Navigator.push( + this, + StaggeredSlideTransition(page: page) as Route, + ); + } +} diff --git a/lib/app/ui/widgets/cards/base_card.dart b/lib/app/ui/widgets/cards/base_card.dart new file mode 100644 index 0000000..b50acf3 --- /dev/null +++ b/lib/app/ui/widgets/cards/base_card.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; + +import '../../theme/app_dimensions.dart'; +import '../../theme/theme_extensions.dart'; + +/// Base card widget that provides common styling and behavior for property cards. +/// +/// This widget encapsulates the shared visual design patterns used across +/// PropertyCard, PropertyGridCard, and other card types, including: +/// - Consistent border radius +/// - Elevation and shadow handling +/// - Dark mode support +/// - Inkwell splash effects +abstract class BaseCard extends StatelessWidget { + const BaseCard({ + super.key, + required this.onTap, + this.borderRadius, + this.elevation, + this.padding, + this.margin, + this.backgroundColor, + this.borderRadiusValue = AppDimensions.radiusLg, + }); + + final VoidCallback onTap; + final BorderRadius? borderRadius; + final double? elevation; + final EdgeInsets? padding; + final EdgeInsets? margin; + final Color? backgroundColor; + final double borderRadiusValue; + + /// Builds the card content. + Widget buildContent(BuildContext context); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final isDark = context.isDark; + final effectiveBorderRadius = + borderRadius ?? BorderRadius.circular(borderRadiusValue); + final effectiveElevation = elevation ?? (isDark ? 2.0 : 6.0); + final effectiveBackgroundColor = backgroundColor ?? + colors.surface.withValues(alpha: isDark ? 0.97 : 0.995); + + final effectiveMargin = margin ?? + const EdgeInsets.symmetric( + horizontal: AppDimensions.sm, + vertical: AppDimensions.xs, + ); + + final effectivePadding = padding ?? + const EdgeInsets.symmetric( + horizontal: AppDimensions.cardPaddingMd, + vertical: AppDimensions.cardPaddingSm, + ); + + final shadowColor = Colors.black.withValues(alpha: isDark ? 0.4 : 0.12); + + return Container( + margin: effectiveMargin, + child: Material( + color: Colors.transparent, + elevation: effectiveElevation, + shadowColor: shadowColor, + borderRadius: effectiveBorderRadius, + clipBehavior: Clip.antiAlias, + child: Ink( + decoration: BoxDecoration( + borderRadius: effectiveBorderRadius, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + effectiveBackgroundColor, + Color.alphaBlend( + colors.primary.withValues(alpha: isDark ? 0.08 : 0.04), + colors.surface.withValues(alpha: isDark ? 0.95 : 0.985), + ), + ], + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: effectiveBorderRadius, + splashColor: colors.primary.withValues(alpha: 0.12), + highlightColor: colors.primary.withValues(alpha: 0.06), + child: Padding( + padding: effectivePadding, + child: buildContent(context), + ), + ), + ), + ), + ); + } +} + +/// Base image card widget for cards with hero images at the top. +abstract class BaseImageCard extends StatelessWidget { + const BaseImageCard({ + super.key, + required this.onTap, + required this.imageUrl, + required this.heroTag, + this.borderRadius, + this.onFavoriteToggle, + this.isFavorite = false, + this.aspectRatio = AppDimensions.cardLandscapeRatio, + this.overlayInset, + }); + + final VoidCallback onTap; + final String? imageUrl; + final String heroTag; + final BorderRadius? borderRadius; + final VoidCallback? onFavoriteToggle; + final bool isFavorite; + final double aspectRatio; + final double? overlayInset; + + /// Builds the overlay content on top of the image. + Widget? buildOverlayContent(BuildContext context); + + /// Builds the content below the image. + Widget? buildBelowImageContent(BuildContext context) => null; + + /// Builds the placeholder widget when no image is available. + Widget buildPlaceholder(BuildContext context) { + final colors = context.colors; + return Container( + color: colors.surfaceContainerHighest.withValues(alpha: 0.35), + alignment: Alignment.center, + child: Icon( + Icons.hotel, + size: 48, + color: colors.onSurface.withValues(alpha: 0.5), + ), + ); + } + + @override + Widget build(BuildContext context) { + final effectiveBorderRadius = borderRadius ?? BorderRadius.circular(18); + final effectiveInset = overlayInset ?? AppDimensions.cardPaddingLg.toDouble(); + + final imageWidget = imageUrl != null && imageUrl!.isNotEmpty + ? _buildCachedImage(context) + : buildPlaceholder(context); + + return GestureDetector( + onTap: onTap, + child: Hero( + tag: heroTag, + child: Material( + color: Colors.transparent, + borderRadius: effectiveBorderRadius, + elevation: context.isDark ? 2.0 : 6.0, + child: ClipRRect( + borderRadius: effectiveBorderRadius, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: aspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + imageWidget, + // Subtle gradient overlay + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0), + Colors.black.withValues(alpha: 0.25), + ], + ), + ), + ), + ), + // Favorite button + if (onFavoriteToggle != null) + _buildFavoriteButton(context, effectiveInset), + // Overlay content + if (buildOverlayContent(context) != null) + buildOverlayContent(context)!, + ], + ), + ), + // Content below image + if (buildBelowImageContent(context) != null) + buildBelowImageContent(context)!, + ], + ), + ), + ), + ), + ); + } + + Widget _buildCachedImage(BuildContext context) { + return Image.network( + imageUrl!, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) => buildPlaceholder(context), + ); + } + + Widget _buildFavoriteButton(BuildContext context, double inset) { + final colors = context.colors; + return Positioned( + top: inset, + right: inset, + child: Material( + color: colors.surface.withValues(alpha: context.isDark ? 0.6 : 0.92), + shape: const CircleBorder(), + elevation: context.isDark ? 0 : 4, + shadowColor: Colors.black.withValues(alpha: 0.18), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onFavoriteToggle, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + isFavorite ? Icons.favorite : Icons.favorite_border, + color: isFavorite ? colors.error : colors.onSurface, + size: 20, + ), + ), + ), + ), + ); + } +} diff --git a/lib/app/ui/widgets/cards/featured_property_card.dart b/lib/app/ui/widgets/cards/featured_property_card.dart new file mode 100644 index 0000000..02f4f18 --- /dev/null +++ b/lib/app/ui/widgets/cards/featured_property_card.dart @@ -0,0 +1,663 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:stays_app/app/data/models/property_model.dart'; +import 'package:stays_app/app/ui/theme/theme_extensions.dart'; +import 'package:stays_app/app/ui/theme/app_animations.dart'; +import 'package:stays_app/app/ui/widgets/common/animated_widgets.dart'; +import 'package:stays_app/app/ui/widgets/common/animated_favorite_button.dart'; + +/// A large, prominent featured property card for the Explore page. +/// Displays a full-width card with cinematic 16:9 aspect ratio, gradient overlay, +/// and "Nearest to you" badge with premium animations and effects. +class FeaturedPropertyCard extends StatefulWidget { + final Property property; + final VoidCallback onTap; + final VoidCallback? onFavoriteToggle; + final bool isFavorite; + final String? heroPrefix; + + const FeaturedPropertyCard({ + super.key, + required this.property, + required this.onTap, + this.onFavoriteToggle, + this.isFavorite = false, + this.heroPrefix, + }); + + @override + State createState() => _FeaturedPropertyCardState(); +} + +class _FeaturedPropertyCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final borderRadius = BorderRadius.circular(24); + + return AnimatedScaleWrapper( + onTap: widget.onTap, + scaleFactor: 0.97, + duration: AppAnimations.cardPressDuration, + curve: AppAnimations.cardPressCurve, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 200, + decoration: BoxDecoration( + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: context.isDark ? 0.4 : 0.12), + blurRadius: 32, + offset: const Offset(0, 16), + spreadRadius: -4, + ), + BoxShadow( + color: colors.primary.withValues(alpha: context.isDark ? 0.15 : 0.08), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: borderRadius, + child: Stack( + fit: StackFit.expand, + children: [ + _buildImage(context), + _buildGradientOverlay(), + _buildShimmerEffect(), + _buildContent(context), + if (widget.onFavoriteToggle != null) + _buildFavoriteButton(context), + _buildNearestBadge(context), + _buildGlossOverlay(), + ], + ), + ), + ), + ); + } + + Widget _buildGlossOverlay() { + return Positioned.fill( + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withValues(alpha: 0.05), + Colors.transparent, + Colors.transparent, + Colors.white.withValues(alpha: 0.02), + ], + stops: const [0.0, 0.3, 0.7, 1.0], + ), + ), + ), + ), + ); + } + + Widget _buildShimmerEffect() { + return Positioned.fill( + child: AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.transparent, + Colors.white.withValues(alpha: 0.1), + Colors.transparent, + ], + stops: const [0.0, 0.5, 1.0], + transform: _SlidingGradientTransform( + slidePercent: _shimmerController.value, + ), + ).createShader(bounds); + }, + blendMode: BlendMode.overlay, + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + ); + }, + ), + ); + } + + Widget _buildImage(BuildContext context) { + final heroTag = '${widget.heroPrefix ?? 'featured'}-${widget.property.id}'; + final colors = Theme.of(context).colorScheme; + final imageUrl = widget.property.displayImage; + + Widget placeholder() { + return Container( + color: colors.surfaceContainerHighest.withValues(alpha: 0.4), + alignment: Alignment.center, + child: Icon( + Icons.hotel, + size: 56, + color: colors.onSurface.withValues(alpha: 0.4), + ), + ); + } + + final image = imageUrl != null && imageUrl.isNotEmpty + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Shimmer.fromColors( + baseColor: colors.surfaceContainerHighest, + highlightColor: colors.surface, + child: Container(color: colors.surface), + ), + errorWidget: (_, __, ___) => placeholder(), + ) + : placeholder(); + + return Hero(tag: heroTag, child: image); + } + + Widget _buildGradientOverlay() { + return Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.15), + Colors.black.withValues(alpha: 0.85), + ], + stops: const [0.3, 1.0], + ), + ), + ), + ); + } + + Widget _buildContent(BuildContext context) { + final textStyles = context.textStyles; + final colors = context.colors; + + return Positioned( + left: 20, + right: 20, + bottom: 20, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.property.propertyTypeDisplay, + style: textStyles.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + fontSize: 11, + ), + ), + ), + const SizedBox(height: 6), + Text( + widget.property.name, + style: textStyles.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + height: 1.1, + fontSize: 18, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + Icons.location_on_outlined, + size: 16, + color: Colors.white70, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + widget.property.fullAddress, + style: textStyles.bodyMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.85), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + widget.property.displayPrice, + style: textStyles.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + Text( + ' / night', + style: textStyles.bodyMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.8), + ), + ), + const SizedBox(width: 16), + if (widget.property.rating != null && widget.property.rating! > 0) ...[ + const Icon(Icons.star_rounded, color: Colors.amber, size: 18), + const SizedBox(width: 4), + Text( + widget.property.ratingText, + style: textStyles.bodyMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildFavoriteButton(BuildContext context) { + final colors = context.colors; + return Positioned( + top: 16, + right: 16, + child: Container( + decoration: BoxDecoration( + color: colors.surface.withValues(alpha: context.isDark ? 0.55 : 0.85), + shape: BoxShape.circle, + boxShadow: context.isDark + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: AnimatedFavoriteButton( + isFavorite: widget.isFavorite, + onToggle: (_) => widget.onFavoriteToggle?.call(), + size: 22, + normalColor: Colors.white.withValues(alpha: 0.9), + favoriteColor: colors.error, + hasBackground: false, + ), + ), + ); + } + + Widget _buildNearestBadge(BuildContext context) { + final distance = widget.property.distanceKm; + final colors = context.colors; + + return Positioned( + left: 16, + bottom: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: colors.primary.withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.near_me, color: Colors.white, size: 16), + const SizedBox(width: 6), + const Text( + 'Nearest to you', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + if (distance != null && distance > 0) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${distance.toStringAsFixed(1)} km', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +/// A compact horizontal strip for the featured/nearest property on Explore. +class FeaturedPropertyStrip extends StatelessWidget { + final Property property; + final VoidCallback onTap; + final VoidCallback? onFavoriteToggle; + final bool isFavorite; + final String? heroPrefix; + + const FeaturedPropertyStrip({ + super.key, + required this.property, + required this.onTap, + this.onFavoriteToggle, + this.isFavorite = false, + this.heroPrefix, + }); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final borderRadius = BorderRadius.circular(16); + + return AnimatedScaleWrapper( + onTap: onTap, + scaleFactor: 0.97, + duration: AppAnimations.cardPressDuration, + curve: AppAnimations.cardPressCurve, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: borderRadius, + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.4), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: context.isDark ? 0.25 : 0.08), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + _FeaturedStripImage( + property: property, + heroPrefix: heroPrefix, + size: 88, + ), + const SizedBox(width: 12), + Expanded(child: _FeaturedStripInfo(property: property)), + if (onFavoriteToggle != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: AnimatedFavoriteButton( + isFavorite: isFavorite, + onToggle: (_) => onFavoriteToggle?.call(), + size: 20, + normalColor: colors.onSurface.withValues(alpha: 0.7), + favoriteColor: colors.error, + hasBackground: false, + ), + ), + ], + ), + ), + ); + } +} + +class _FeaturedStripImage extends StatelessWidget { + final Property property; + final String? heroPrefix; + final double size; + + const _FeaturedStripImage({ + required this.property, + required this.size, + this.heroPrefix, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final heroTag = '${heroPrefix ?? 'featured_strip'}-${property.id}'; + final imageUrl = property.displayImage; + + Widget placeholder() { + return Container( + color: colors.surfaceContainerHighest.withValues(alpha: 0.4), + alignment: Alignment.center, + child: Icon( + Icons.hotel, + size: 32, + color: colors.onSurface.withValues(alpha: 0.5), + ), + ); + } + + final image = imageUrl != null && imageUrl.isNotEmpty + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: colors.surfaceContainerHighest, + ), + errorWidget: (_, __, ___) => placeholder(), + ) + : placeholder(); + + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: size, + height: size, + child: Hero(tag: heroTag, child: image), + ), + ); + } +} + +class _FeaturedStripInfo extends StatelessWidget { + final Property property; + + const _FeaturedStripInfo({required this.property}); + + @override + Widget build(BuildContext context) { + final textStyles = context.textStyles; + final colors = context.colors; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + property.propertyTypeDisplay, + style: textStyles.labelSmall?.copyWith( + color: colors.primary.withValues(alpha: 0.8), + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 4), + Text( + property.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyles.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + const SizedBox(height: 4), + Text( + property.fullAddress, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyles.bodySmall?.copyWith( + color: colors.onSurface.withValues(alpha: 0.65), + ), + ), + const SizedBox(height: 6), + Text( + property.displayPrice, + style: textStyles.labelLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} + +/// A shimmer placeholder for the featured strip. +class FeaturedPropertyStripShimmer extends StatelessWidget { + const FeaturedPropertyStripShimmer({super.key}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final borderRadius = BorderRadius.circular(16); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 108, + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: ClipRRect( + borderRadius: borderRadius, + child: Shimmer.fromColors( + baseColor: colors.surfaceContainerHighest, + highlightColor: colors.surface, + child: Container(color: colors.surface), + ), + ), + ); + } +} + +/// A shimmer placeholder for the featured property card. +class FeaturedPropertyCardShimmer extends StatelessWidget { + const FeaturedPropertyCardShimmer({super.key}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final borderRadius = BorderRadius.circular(24); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 200, + decoration: BoxDecoration( + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: context.isDark ? 0.35 : 0.12), + blurRadius: 32, + offset: const Offset(0, 16), + spreadRadius: -4, + ), + BoxShadow( + color: colors.primary.withValues(alpha: context.isDark ? 0.15 : 0.08), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: borderRadius, + child: Shimmer.fromColors( + baseColor: colors.surfaceContainerHighest, + highlightColor: colors.surface, + child: Container(color: colors.surface), + ), + ), + ); + } +} + +/// Gradient transform for sliding shimmer effect. +class _SlidingGradientTransform extends GradientTransform { + const _SlidingGradientTransform({ + required this.slidePercent, + }); + + final double slidePercent; + + @override + Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { + return Matrix4.translationValues( + bounds.width * slidePercent, + 0.0, + 0.0, + ); + } +} diff --git a/lib/app/ui/widgets/cards/property_grid_card.dart b/lib/app/ui/widgets/cards/property_grid_card.dart index a586eb2..6f883a6 100644 --- a/lib/app/ui/widgets/cards/property_grid_card.dart +++ b/lib/app/ui/widgets/cards/property_grid_card.dart @@ -4,6 +4,9 @@ import 'package:shimmer/shimmer.dart'; import '../../../data/models/property_model.dart'; import '../../theme/theme_extensions.dart'; +import '../../theme/app_animations.dart'; +import '../common/animated_widgets.dart'; +import '../common/animated_favorite_button.dart'; class PropertyGridCard extends StatelessWidget { final Property property; @@ -33,9 +36,11 @@ class PropertyGridCard extends StatelessWidget { ? (context.isDark ? 1.5 : 5.0) : (context.isDark ? 2.0 : 6.0); - return Semantics( - label: '${property.name}, ${property.city}, ${property.pricePerNight} per night', - button: true, + return AnimatedScaleWrapper( + onTap: onTap, + scaleFactor: 0.97, + duration: AppAnimations.cardPressDuration, + curve: AppAnimations.cardPressCurve, child: Material( color: Colors.transparent, elevation: elevation, @@ -57,25 +62,47 @@ class PropertyGridCard extends StatelessWidget { ], ), ), - child: InkWell( - onTap: onTap, - borderRadius: borderRadius, - splashColor: colors.primary.withValues(alpha: 0.12), - highlightColor: colors.primary.withValues(alpha: 0.06), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildImage(context), - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: verticalPadding, + child: LayoutBuilder( + builder: (context, constraints) { + final hasBoundedHeight = constraints.hasBoundedHeight && + constraints.maxHeight.isFinite; + if (!hasBoundedHeight) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildImage(context), + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + child: _buildInfo(context), + ), + ], + ); + } + + const imageFlex = 6; + const infoFlex = 5; + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: imageFlex, child: _buildImage(context)), + Expanded( + flex: infoFlex, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + child: _buildInfo(context), + ), ), - child: _buildInfo(context), - ), - ], - ), + ], + ); + }, ), ), ), @@ -87,7 +114,8 @@ class PropertyGridCard extends StatelessWidget { final colors = Theme.of(context).colorScheme; final imageUrl = property.displayImage; final overlayInset = isCompact ? 12.0 : 14.0; - final aspectRatio = isCompact ? 2.05 : 3 / 2; + // Use 4/3 ratio for larger images instead of 3/2 + final aspectRatio = isCompact ? 1.9 : 4 / 3; Widget placeholder() { return Container( @@ -149,8 +177,6 @@ class PropertyGridCard extends StatelessWidget { ), if (onFavoriteToggle != null) _buildFavoriteButton(context, overlayInset), - if (property.distanceKm != null && property.distanceKm! > 0) - _buildDistanceBadge(overlayInset), ], ), ), @@ -161,7 +187,6 @@ class PropertyGridCard extends StatelessWidget { final theme = Theme.of(context); final colors = theme.colorScheme; final mutedColor = colors.onSurface.withValues(alpha: 0.68); - final metaDetails = _buildMetaDetails(context); final titleStyle = theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, height: 1.2, @@ -226,10 +251,6 @@ class PropertyGridCard extends StatelessWidget { ), ], ), - if (metaDetails != null) ...[ - SizedBox(height: isCompact ? 10 : 12), - metaDetails, - ], ], ); } @@ -263,142 +284,35 @@ class PropertyGridCard extends StatelessWidget { ); } - Widget? _buildMetaDetails(BuildContext context) { - final chips = []; - - void addChip(IconData icon, String label) { - chips.add(_buildMetaChip(context, icon, label)); - } - - if (property.bedrooms != null && property.bedrooms! > 0) { - final beds = property.bedrooms!; - addChip(Icons.bed_outlined, '$beds ${beds == 1 ? 'Bed' : 'Beds'}'); - } - - if (property.bathrooms != null && property.bathrooms! > 0) { - final baths = property.bathrooms!; - addChip( - Icons.bathtub_outlined, - '$baths ${baths == 1 ? 'Bath' : 'Baths'}', - ); - } - - if (property.squareFeet != null && property.squareFeet! > 0) { - final sqft = property.squareFeet!; - final sqftText = sqft.remainder(1) == 0 - ? sqft.toStringAsFixed(0) - : sqft.toStringAsFixed(1); - addChip(Icons.square_foot, '$sqftText sqft'); - } - - if (property.rating != null && property.rating! > 0) { - addChip(Icons.star_rate_rounded, property.ratingText); - } - - if (chips.isEmpty) return null; - return Wrap( - spacing: isCompact ? 6 : 8, - runSpacing: isCompact ? 6 : 8, - children: chips, - ); - } - - Widget _buildMetaChip(BuildContext context, IconData icon, String label) { - final colors = Theme.of(context).colorScheme; - final iconSize = isCompact ? 13.0 : 14.0; - final textStyle = Theme.of(context).textTheme.labelMedium?.copyWith( - color: colors.onSurface.withValues(alpha: 0.75), - fontWeight: FontWeight.w500, - fontSize: isCompact ? 12.5 : null, - ); - final horizontal = isCompact ? 8.0 : 10.0; - final vertical = isCompact ? 5.0 : 6.0; - - return DecoratedBox( - decoration: BoxDecoration( - color: colors.surfaceVariant.withValues( - alpha: context.isDark ? 0.35 : 0.6, - ), - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontal, - vertical: vertical, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: iconSize, - color: colors.onSurfaceVariant.withValues(alpha: 0.8), - ), - SizedBox(width: isCompact ? 3 : 4), - Text(label, style: textStyle), - ], - ), - ), - ); - } - Positioned _buildFavoriteButton(BuildContext context, double inset) { final colors = Theme.of(context).colorScheme; return Positioned( top: inset, right: inset, - child: Material( - color: colors.surface.withValues(alpha: context.isDark ? 0.6 : 0.92), - shape: const CircleBorder(), - elevation: context.isDark ? 0 : 4, - shadowColor: Colors.black.withValues(alpha: 0.18), - child: InkWell( - customBorder: const CircleBorder(), - onTap: onFavoriteToggle, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - isFavorite ? Icons.favorite : Icons.favorite_border, - color: isFavorite ? colors.error : colors.onSurface, - size: 20, - ), - ), - ), - ), - ); - } - - Positioned _buildDistanceBadge(double inset) { - return Positioned( - left: inset, - bottom: inset, - child: DecoratedBox( + child: Container( decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(14), + color: colors.surface.withValues(alpha: context.isDark ? 0.6 : 0.92), + shape: BoxShape.circle, + boxShadow: context.isDark + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: isCompact ? 9 : 10, - vertical: isCompact ? 4 : 5, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.place, color: Colors.white, size: 14), - SizedBox(width: isCompact ? 3 : 4), - Text( - '${property.distanceKm!.toStringAsFixed(1)} km', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), + child: AnimatedFavoriteButton( + isFavorite: isFavorite, + onToggle: (_) => onFavoriteToggle?.call(), + size: 20, + normalColor: colors.onSurface.withValues(alpha: 0.7), + favoriteColor: colors.error, + hasBackground: false, ), ), ); } + } diff --git a/lib/app/ui/widgets/common/animated_favorite_button.dart b/lib/app/ui/widgets/common/animated_favorite_button.dart new file mode 100644 index 0000000..f42d54a --- /dev/null +++ b/lib/app/ui/widgets/common/animated_favorite_button.dart @@ -0,0 +1,249 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../../theme/app_animations.dart'; + +// =============================================== +// ANIMATED FAVORITE HEART BUTTON +// =============================================== + +/// A heart-shaped favorite button with premium animation. +/// Features: scale burst, particle explosion, color transition, and bounce. +class AnimatedFavoriteButton extends StatefulWidget { + const AnimatedFavoriteButton({ + super.key, + required this.isFavorite, + required this.onToggle, + this.size = 24, + this.normalColor = Colors.white, + this.favoriteColor = Colors.red, + this.hasBackground = true, + }); + + final bool isFavorite; + final ValueChanged onToggle; + final double size; + final Color normalColor; + final Color favoriteColor; + final bool hasBackground; + + @override + State createState() => _AnimatedFavoriteButtonState(); +} + +class _AnimatedFavoriteButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _particleAnimation; + late Animation _rotationAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: AppAnimations.favoriteDuration, + vsync: this, + ); + + // Scale animation with bounce effect + _scaleAnimation = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.3), + weight: 20, + ), + TweenSequenceItem( + tween: Tween(begin: 0.3, end: 1.4), + weight: 40, + ), + TweenSequenceItem( + tween: Tween(begin: 1.4, end: 1.0), + weight: 40, + ), + ]).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + )); + + // Particle explosion animation + _particleAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.3, 1.0, curve: Curves.easeOut), + ), + ); + + // Subtle rotation for added flair + _rotationAnimation = Tween(begin: 0.0, end: 0.15).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ); + } + + @override + void didUpdateWidget(AnimatedFavoriteButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isFavorite != widget.isFavorite) { + if (widget.isFavorite) { + _controller.forward(from: 0); + } else { + _controller.reverse(from: 1); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTap() { + widget.onToggle(!widget.isFavorite); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _handleTap, + child: SizedBox( + width: widget.size + 16, + height: widget.size + 16, + child: Stack( + alignment: Alignment.center, + children: [ + // Particle effects when favoriting + if (widget.isFavorite) + AnimatedBuilder( + animation: _particleAnimation, + builder: (context, child) { + return _ParticleExplosion( + progress: _particleAnimation.value, + color: widget.favoriteColor, + size: widget.size, + ); + }, + ), + + // Background circle + if (widget.hasBackground) + AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Container( + width: widget.size + 12, + height: widget.size + 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.3), + ), + ); + }, + ), + + // Heart icon + AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Transform.rotate( + angle: _rotationAnimation.value * + (widget.isFavorite ? 1 : -1), + child: Icon( + widget.isFavorite ? Icons.favorite : Icons.favorite_border, + color: widget.isFavorite + ? widget.favoriteColor + : widget.normalColor, + size: widget.size, + ), + ), + ); + }, + ), + ], + ), + ), + ); + } +} + +// =============================================== +// PARTICLE EXPLOSION WIDGET +// =============================================== + +class _ParticleExplosion extends StatelessWidget { + const _ParticleExplosion({ + required this.progress, + required this.color, + required this.size, + }); + + final double progress; + final Color color; + final double size; + + @override + Widget build(BuildContext context) { + if (progress <= 0) return const SizedBox.shrink(); + + return SizedBox( + width: size * 3, + height: size * 3, + child: Stack( + children: List.generate(8, (index) { + final angle = index * 45.0 * math.pi / 180; + return _Particle( + angle: angle, + progress: progress, + color: color, + size: size / 4, + ); + }), + ), + ); + } +} + +class _Particle extends StatelessWidget { + const _Particle({ + required this.angle, + required this.progress, + required this.color, + required this.size, + }); + + final double angle; + final double progress; + final Color color; + final double size; + + @override + Widget build(BuildContext context) { + final distance = 30.0 * progress; + final opacity = (1 - progress).clamp(0.0, 1.0); + final scale = (1 - progress * 0.5).clamp(0.5, 1.0); + + return Transform.translate( + offset: Offset( + distance * math.cos(angle), + distance * math.sin(angle), + ), + child: Opacity( + opacity: opacity, + child: Transform.scale( + scale: scale, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + ), + ), + ), + ); + } +} diff --git a/lib/app/ui/widgets/common/animated_refresh.dart b/lib/app/ui/widgets/common/animated_refresh.dart new file mode 100644 index 0000000..7049e99 --- /dev/null +++ b/lib/app/ui/widgets/common/animated_refresh.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +// =============================================== +// ANIMATED REFRESH ICON +// =============================================== + +/// Animated refresh icon with rotation and pulse +class AnimatedRefreshIcon extends StatefulWidget { + const AnimatedRefreshIcon({super.key}); + + @override + State createState() => _AnimatedRefreshIconState(); +} + +class _AnimatedRefreshIconState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _rotationAnimation; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + ); + + _rotationAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.linear), + ); + + _scaleAnimation = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 1.15), + weight: 50, + ), + TweenSequenceItem( + tween: Tween(begin: 1.15, end: 1.0), + weight: 50, + ), + ]).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + + _controller.repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Transform.rotate( + angle: _rotationAnimation.value * 6.28318, // 2π + child: Icon( + Icons.refresh_rounded, + color: colorScheme.primary, + size: 28, + ), + ), + ); + }, + ); + } +} + +// =============================================== +// SMART REFRESH WIDGET +// =============================================== + +/// A smart refresh wrapper that adds pull-to-refresh with animated feedback. +/// Use this to easily add refresh functionality to any scrollable content. +class SmartRefresh extends StatefulWidget { + const SmartRefresh({ + super.key, + required this.onRefresh, + required this.child, + this.enabled = true, + this.color, + this.backgroundColor, + }); + + final Future Function() onRefresh; + final Widget child; + final bool enabled; + final Color? color; + final Color? backgroundColor; + + @override + State createState() => _SmartRefreshState(); +} + +class _SmartRefreshState extends State { + bool _isRefreshing = false; + + Future _handleRefresh() async { + if (_isRefreshing) return; + + setState(() => _isRefreshing = true); + + try { + await widget.onRefresh(); + } finally { + if (mounted) { + setState(() => _isRefreshing = false); + } + } + } + + @override + Widget build(BuildContext context) { + if (!widget.enabled) { + return widget.child; + } + + return RefreshIndicator( + onRefresh: _handleRefresh, + color: widget.color ?? Theme.of(context).colorScheme.primary, + backgroundColor: widget.backgroundColor ?? + Theme.of(context).colorScheme.surface, + displacement: 60.0, + strokeWidth: 3, + child: widget.child, + ); + } +} diff --git a/lib/app/ui/widgets/common/animated_search_bar.dart b/lib/app/ui/widgets/common/animated_search_bar.dart new file mode 100644 index 0000000..810c9f0 --- /dev/null +++ b/lib/app/ui/widgets/common/animated_search_bar.dart @@ -0,0 +1,440 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_animations.dart'; + +// =============================================== +// ANIMATED SEARCH BAR +// =============================================== + +/// A premium animated search bar that expands/collapses smoothly. +/// Features focus animation, clear button, and search suggestions. +class AnimatedSearchBar extends StatefulWidget { + const AnimatedSearchBar({ + super.key, + required this.onChanged, + required this.onSubmitted, + this.hintText = 'Search...', + this.leadingIcon, + this.trailingIcon, + this.onTrailingPressed, + this.isExpanded = false, + this.expandedWidth, + this.collapsedWidth = 56.0, + this.duration = AppAnimations.medium, + this.curve = AppAnimations.easeOutCubic, + this.backgroundColor, + this.textColor, + }); + + final ValueChanged onChanged; + final ValueChanged onSubmitted; + final String hintText; + final IconData? leadingIcon; + final IconData? trailingIcon; + final VoidCallback? onTrailingPressed; + final bool isExpanded; + final double? expandedWidth; + final double collapsedWidth; + final Duration duration; + final Curve curve; + final Color? backgroundColor; + final Color? textColor; + + @override + State createState() => _AnimatedSearchBarState(); +} + +class _AnimatedSearchBarState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _widthAnimation; + late Animation _fadeAnimation; + late Animation _iconSlideAnimation; + + final TextEditingController _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + bool _isFocused = false; + bool _hasText = false; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + + _focusNode.addListener(() { + setState(() { + _isFocused = _focusNode.hasFocus; + }); + }); + + _textController.addListener(() { + setState(() { + _hasText = _textController.text.isNotEmpty; + }); + }); + + if (widget.isExpanded) { + _controller.value = 1.0; + } + } + + @override + void didUpdateWidget(AnimatedSearchBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded != oldWidget.isExpanded) { + if (widget.isExpanded) { + _controller.forward(); + // Auto-focus when expanded + Future.delayed(const Duration(milliseconds: 100), () { + _focusNode.requestFocus(); + }); + } else { + _controller.reverse(); + _focusNode.unfocus(); + _textController.clear(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _handleClear() { + _textController.clear(); + widget.onChanged(''); + _focusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + + final backgroundColor = widget.backgroundColor ?? + (isDark + ? colorScheme.surfaceContainerHigh + : colorScheme.surfaceContainerHighest); + + final expandedWidth = widget.expandedWidth ?? + (MediaQuery.of(context).size.width - 32); + + _widthAnimation = Tween( + begin: widget.collapsedWidth, + end: expandedWidth, + ).animate(CurvedAnimation( + parent: _controller, + curve: widget.curve, + )); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.3, 1.0, curve: Curves.easeOut), + ), + ); + + _iconSlideAnimation = Tween(begin: 0.0, end: -8.0).animate( + CurvedAnimation( + parent: _controller, + curve: widget.curve, + ), + ); + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final currentWidth = _widthAnimation.value; + final isFullyExpanded = _controller.value > 0.8; + + return Container( + width: currentWidth, + height: 56, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(28), + boxShadow: _isFocused + ? [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Row( + children: [ + // Leading icon / Search icon + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Transform.translate( + offset: Offset(_iconSlideAnimation.value, 0), + child: Icon( + widget.leadingIcon ?? Icons.search_rounded, + color: _isFocused + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ), + + // Text field + Expanded( + child: Opacity( + opacity: _fadeAnimation.value, + child: TextField( + controller: _textController, + focusNode: _focusNode, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + style: TextStyle( + color: widget.textColor ?? colorScheme.onSurface, + fontSize: 16, + ), + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 12, + ), + suffixIcon: _hasText && isFullyExpanded + ? _AnimatedClearButton( + onPressed: _handleClear, + ) + : null, + ), + ), + ), + ), + + // Trailing icon + if (widget.trailingIcon != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + icon: Icon(widget.trailingIcon), + onPressed: widget.onTrailingPressed, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + ); + } +} + +/// Animated clear button for search bar +class _AnimatedClearButton extends StatefulWidget { + const _AnimatedClearButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + State<_AnimatedClearButton> createState() => _AnimatedClearButtonState(); +} + +class _AnimatedClearButtonState extends State<_AnimatedClearButton> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: AppAnimations.fast, + vsync: this, + ); + + _scaleAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.easeOutBack, + ), + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.easeOut, + ), + ); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition( + scale: _scaleAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + _controller.reverse().then((_) { + widget.onPressed(); + _controller.forward(); + }); + }, + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + ), + ); + } +} + +// =============================================== +// EXPANDING SEARCH FIELD +// =============================================== + +/// A search field that expands from a collapsed icon to a full search bar. +/// Use this in app bars or toolbars where space is limited. +class ExpandingSearchField extends StatefulWidget { + const ExpandingSearchField({ + super.key, + required this.onChanged, + required this.onSubmitted, + this.hintText = 'Search...', + this.onTap, + this.onClose, + }); + + final ValueChanged onChanged; + final ValueChanged onSubmitted; + final String hintText; + final VoidCallback? onTap; + final VoidCallback? onClose; + + @override + State createState() => _ExpandingSearchFieldState(); +} + +class _ExpandingSearchFieldState extends State + with SingleTickerProviderStateMixin { + bool _isExpanded = false; + late AnimationController _controller; + late Animation _expandAnimation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: AppAnimations.medium, + vsync: this, + ); + + _expandAnimation = CurvedAnimation( + parent: _controller, + curve: AppAnimations.easeOutCubic, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggleExpand() { + setState(() { + _isExpanded = !_isExpanded; + if (_isExpanded) { + _controller.forward(); + } else { + _controller.reverse(); + widget.onClose?.call(); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AnimatedBuilder( + animation: _expandAnimation, + builder: (context, child) { + return Container( + width: _isExpanded + ? MediaQuery.of(context).size.width - 32 + : 56 * _expandAnimation.value + 56 * (1 - _expandAnimation.value), + height: 56, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(28), + ), + child: _isExpanded + ? Row( + children: [ + const Padding( + padding: EdgeInsets.only(left: 16), + child: Icon(Icons.search_rounded, size: 24), + ), + Expanded( + child: TextField( + autofocus: true, + onChanged: widget.onChanged, + onSubmitted: (value) { + widget.onSubmitted(value); + _toggleExpand(); + }, + decoration: InputDecoration( + hintText: widget.hintText, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: _toggleExpand, + ), + ], + ) + : IconButton( + icon: const Icon(Icons.search_rounded), + onPressed: _toggleExpand, + ), + ); + }, + ); + } +} diff --git a/lib/app/ui/widgets/common/animated_toast.dart b/lib/app/ui/widgets/common/animated_toast.dart new file mode 100644 index 0000000..d748dc9 --- /dev/null +++ b/lib/app/ui/widgets/common/animated_toast.dart @@ -0,0 +1,326 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_animations.dart'; + +// =============================================== +// ANIMATED TOAST NOTIFICATIONS +// =============================================== + +/// Toast notification types +enum ToastType { success, error, warning, info } + +/// A premium animated toast notification system. +/// Shows slide-in notifications with icons and smooth animations. +class AnimatedToast extends StatefulWidget { + const AnimatedToast({ + super.key, + required this.message, + this.type = ToastType.info, + this.duration = const Duration(seconds: 3), + this.position = ToastPosition.top, + this.onDismiss, + }); + + final String message; + final ToastType type; + final Duration duration; + final ToastPosition position; + final VoidCallback? onDismiss; + + /// Show a toast notification + static void show( + BuildContext context, { + required String message, + ToastType type = ToastType.info, + Duration duration = const Duration(seconds: 3), + ToastPosition position = ToastPosition.top, + }) { + final overlay = Overlay.of(context); + late OverlayEntry overlayEntry; + + overlayEntry = OverlayEntry( + builder: (context) => Positioned( + top: position == ToastPosition.top ? 50 : null, + bottom: position == ToastPosition.bottom ? 20 : null, + left: 16, + right: 16, + child: AnimatedToast( + message: message, + type: type, + duration: duration, + position: position, + onDismiss: () => overlayEntry.remove(), + ), + ), + ); + + overlay.insert(overlayEntry); + } + + /// Convenience method for success toast + static void success( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + }) { + show(context, message: message, type: ToastType.success, duration: duration); + } + + /// Convenience method for error toast + static void error( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 4), + }) { + show(context, message: message, type: ToastType.error, duration: duration); + } + + /// Convenience method for warning toast + static void warning( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + }) { + show(context, message: message, type: ToastType.warning, duration: duration); + } + + /// Convenience method for info toast + static void info( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + }) { + show(context, message: message, type: ToastType.info, duration: duration); + } + + @override + State createState() => _AnimatedToastState(); +} + +class _AnimatedToastState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _slideAnimation; + late Animation _fadeAnimation; + late Animation _scaleAnimation; + + bool _isDismissed = false; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: AppAnimations.medium, + vsync: this, + ); + + final begin = widget.position == ToastPosition.top + ? const Offset(0, -1) + : const Offset(0, 1); + + _slideAnimation = Tween(begin: begin, end: Offset.zero).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.easeOutCubic, + ), + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.2, 1.0, curve: Curves.easeOut), + ), + ); + + _scaleAnimation = Tween(begin: 0.9, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.easeOutCubic, + ), + ); + + _controller.forward(); + + // Auto-dismiss after duration + Future.delayed(widget.duration, () { + if (mounted) { + _dismiss(); + } + }); + } + + void _dismiss() { + if (_isDismissed) return; + _isDismissed = true; + _controller.reverse().then((_) { + widget.onDismiss?.call(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.translate( + offset: Offset( + _slideAnimation.value.dx * MediaQuery.of(context).size.width, + _slideAnimation.value.dy * 100, + ), + child: Opacity( + opacity: _fadeAnimation.value, + child: Transform.scale( + scale: _scaleAnimation.value, + child: child, + ), + ), + ); + }, + child: _ToastContent( + message: widget.message, + type: widget.type, + onDismiss: _dismiss, + ), + ), + ); + } +} + +enum ToastPosition { top, bottom } + +class _ToastContent extends StatelessWidget { + const _ToastContent({ + required this.message, + required this.type, + required this.onDismiss, + }); + + final String message; + final ToastType type; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + final config = _getToastConfig(type); + + return SafeArea( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: config.backgroundColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.1), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + // Icon container + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: config.iconColor.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + config.icon, + color: config.iconColor, + size: 18, + ), + ), + const SizedBox(width: 12), + + // Message + Expanded( + child: Text( + message, + style: TextStyle( + color: config.textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + + // Dismiss button + GestureDetector( + onTap: onDismiss, + child: Container( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.close, + color: config.textColor.withValues(alpha: 0.6), + size: 18, + ), + ), + ), + ], + ), + ), + ); + } + + _ToastConfig _getToastConfig(ToastType type) { + switch (type) { + case ToastType.success: + return _ToastConfig( + icon: Icons.check_circle, + iconColor: const Color(0xFF4CAF50), + backgroundColor: const Color(0xFF1B5E20), + textColor: Colors.white, + ); + case ToastType.error: + return _ToastConfig( + icon: Icons.error, + iconColor: const Color(0xFFEF5350), + backgroundColor: const Color(0xFFB71C1C), + textColor: Colors.white, + ); + case ToastType.warning: + return _ToastConfig( + icon: Icons.warning, + iconColor: const Color(0xFFFFA726), + backgroundColor: const Color(0xFFE65100), + textColor: Colors.white, + ); + case ToastType.info: + return _ToastConfig( + icon: Icons.info, + iconColor: const Color(0xFF42A5F5), + backgroundColor: const Color(0xFF0D47A1), + textColor: Colors.white, + ); + } + } +} + +class _ToastConfig { + const _ToastConfig({ + required this.icon, + required this.iconColor, + required this.backgroundColor, + required this.textColor, + }); + + final IconData icon; + final Color iconColor; + final Color backgroundColor; + final Color textColor; +} diff --git a/lib/app/ui/widgets/common/animated_widgets.dart b/lib/app/ui/widgets/common/animated_widgets.dart new file mode 100644 index 0000000..4a8285e --- /dev/null +++ b/lib/app/ui/widgets/common/animated_widgets.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_animations.dart'; + +// =============================================== +// STAGGERED LIST ITEM WIDGET +// =============================================== + +/// A list item that animates in with a staggered delay. +/// Use this for any list that should animate items sequentially. +class StaggeredListItem extends StatelessWidget { + const StaggeredListItem({ + super.key, + required this.index, + required this.child, + this.duration = AppAnimations.listItemDuration, + this.curve = AppAnimations.listItemCurve, + this.staggerDelay = AppAnimations.staggerDelay, + this.offset = const Offset(0, 30), + }); + + final int index; + final Widget child; + final Duration duration; + final Curve curve; + final Duration staggerDelay; + final Offset offset; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: duration, + curve: curve, + builder: (context, value, child) { + return Transform.translate( + offset: Offset(offset.dx, offset.dy * (1 - value)), + child: Opacity( + opacity: value, + child: child, + ), + ); + }, + child: child, + ); + } +} + +// =============================================== +// ANIMATED SCALE WRAPPER +// =============================================== + +/// Wraps a widget with press-scale animation. +/// Use this for buttons, cards, and interactive elements. +class AnimatedScaleWrapper extends StatefulWidget { + const AnimatedScaleWrapper({ + super.key, + required this.child, + this.onTap, + this.onLongPress, + this.scaleFactor = 0.95, + this.duration = AppAnimations.cardPressDuration, + this.curve = AppAnimations.cardPressCurve, + this.enabled = true, + }); + + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final double scaleFactor; + final Duration duration; + final Curve curve; + final bool enabled; + + @override + State createState() => _AnimatedScaleWrapperState(); +} + +class _AnimatedScaleWrapperState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + bool _isPressed = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + _scaleAnimation = Tween( + begin: 1.0, + end: widget.scaleFactor, + ).animate(CurvedAnimation( + parent: _controller, + curve: widget.curve, + reverseCurve: widget.curve.flipped, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTapDown(TapDownDetails details) { + if (!widget.enabled) return; + setState(() => _isPressed = true); + _controller.forward(); + } + + void _handleTapUp(TapUpDetails details) { + if (_isPressed) { + setState(() => _isPressed = false); + _controller.reverse(); + } + } + + void _handleTapCancel() { + if (_isPressed) { + setState(() => _isPressed = false); + _controller.reverse(); + } + } + + void _handleTap() { + widget.onTap?.call(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onTapCancel: _handleTapCancel, + onTap: widget.onTap != null ? _handleTap : null, + onLongPress: widget.onLongPress, + behavior: HitTestBehavior.opaque, + child: ScaleTransition( + scale: _scaleAnimation, + child: widget.child, + ), + ); + } +} + +// =============================================== +// ANIMATED FADE IN WIDGET +// =============================================== + +/// A widget that fades in when first displayed. +/// Use this for page content, dialogs, and overlays. +class AnimatedFadeIn extends StatelessWidget { + const AnimatedFadeIn({ + super.key, + required this.child, + this.duration = AppAnimations.normal, + this.curve = AppAnimations.easeOut, + this.delay = Duration.zero, + this.slideOffset, + }); + + final Widget child; + final Duration duration; + final Curve curve; + final Duration delay; + final Offset? slideOffset; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: delay + duration, + curve: curve, + builder: (context, value, child) { + final animatedChild = Opacity( + opacity: value, + child: child, + ); + + if (slideOffset != null) { + return Transform.translate( + offset: Offset( + slideOffset!.dx * (1 - value), + slideOffset!.dy * (1 - value), + ), + child: animatedChild, + ); + } + + return animatedChild; + }, + child: child, + ); + } +} + +// =============================================== +// ANIMATED SIZE WIDGET +// =============================================== + +/// A widget that animates size changes smoothly. +/// Use this for expandable sections, accordions, etc. +class AnimatedSizeWrapper extends StatelessWidget { + const AnimatedSizeWrapper({ + super.key, + required this.child, + required this.isExpanded, + this.duration = AppAnimations.medium, + this.curve = AppAnimations.easeOutCubic, + this.alignment = Alignment.topCenter, + }); + + final Widget child; + final bool isExpanded; + final Duration duration; + final Curve curve; + final Alignment alignment; + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: duration, + curve: curve, + alignment: alignment, + child: isExpanded + ? SizedBox( + width: double.infinity, + child: child, + ) + : const SizedBox.shrink(), + ); + } +} + +// =============================================== +// ANIMATED OPACITY WIDGET +// =============================================== + +/// A widget that animates opacity based on a boolean condition. +class AnimatedVisibility extends StatelessWidget { + const AnimatedVisibility({ + super.key, + required this.child, + required this.visible, + this.duration = AppAnimations.fast, + this.curve = AppAnimations.easeOut, + this.includeSemantics = true, + }); + + final Widget child; + final bool visible; + final Duration duration; + final Curve curve; + final bool includeSemantics; + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: duration, + curve: curve, + child: Visibility( + visible: visible || includeSemantics, + maintainState: true, + maintainAnimation: true, + maintainSize: true, + child: child, + ), + ); + } +} + +// =============================================== +// PULSE ANIMATION WIDGET +// =============================================== + +/// A widget that pulses continuously. +/// Use for attention-grabbing elements like notification badges. +class AnimatedPulse extends StatefulWidget { + const AnimatedPulse({ + super.key, + required this.child, + this.minScale = 1.0, + this.maxScale = 1.1, + this.duration = const Duration(milliseconds: 1000), + this.curve = Curves.easeInOut, + }); + + final Widget child; + final double minScale; + final double maxScale; + final Duration duration; + final Curve curve; + + @override + State createState() => _AnimatedPulseState(); +} + +class _AnimatedPulseState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + )..repeat(reverse: true); + + _scaleAnimation = Tween( + begin: widget.minScale, + end: widget.maxScale, + ).animate(CurvedAnimation( + parent: _controller, + curve: widget.curve, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition( + scale: _scaleAnimation, + child: widget.child, + ); + } +} + +// =============================================== +// SHIMMER LOADING WIDGET +// =============================================== + +/// A shimmer effect for loading states. +/// Use this for skeleton loaders. +class ShimmerLoading extends StatefulWidget { + const ShimmerLoading({ + super.key, + required this.child, + this.baseColor = const Color(0xFFE0E0E0), + this.highlightColor = const Color(0xFFF5F5F5), + this.direction = ShimmerDirection.ltr, + }); + + final Widget child; + final Color baseColor; + final Color highlightColor; + final ShimmerDirection direction; + + @override + State createState() => _ShimmerLoadingState(); +} + +enum ShimmerDirection { ltr, rtl, ttb, btt } + +class _ShimmerLoadingState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(); + + _animation = Tween(begin: -2, end: 2).animate(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) { + final begin = widget.direction == ShimmerDirection.ltr || + widget.direction == ShimmerDirection.rtl + ? Alignment.centerLeft + : Alignment.topCenter; + + final end = widget.direction == ShimmerDirection.ltr || + widget.direction == ShimmerDirection.rtl + ? Alignment.centerRight + : Alignment.bottomCenter; + + return LinearGradient( + begin: begin, + end: end, + colors: [ + widget.baseColor, + widget.highlightColor, + widget.baseColor, + ], + stops: const [0.0, 0.5, 1.0], + transform: _SlidingGradientTransform( + slidePercent: _animation.value, + direction: widget.direction, + ), + ).createShader(bounds); + }, + child: widget.child, + ); + } +} + +class _SlidingGradientTransform extends GradientTransform { + const _SlidingGradientTransform({ + required this.slidePercent, + required this.direction, + }); + + final double slidePercent; + final ShimmerDirection direction; + + @override + Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { + switch (direction) { + case ShimmerDirection.ltr: + return Matrix4.translationValues(bounds.width * slidePercent, 0, 0); + case ShimmerDirection.rtl: + return Matrix4.translationValues(-bounds.width * slidePercent, 0, 0); + case ShimmerDirection.ttb: + return Matrix4.translationValues(0, bounds.height * slidePercent, 0); + case ShimmerDirection.btt: + return Matrix4.translationValues(0, -bounds.height * slidePercent, 0); + } + } +} diff --git a/lib/app/ui/widgets/common/custom_button.dart b/lib/app/ui/widgets/common/custom_button.dart index 5e1143b..c5699ab 100644 --- a/lib/app/ui/widgets/common/custom_button.dart +++ b/lib/app/ui/widgets/common/custom_button.dart @@ -153,7 +153,6 @@ class CustomButton extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; final buttonStyle = _buildStyle(context); return Semantics( diff --git a/lib/app/ui/widgets/common/explore_hero_header.dart b/lib/app/ui/widgets/common/explore_hero_header.dart new file mode 100644 index 0000000..1a18b3d --- /dev/null +++ b/lib/app/ui/widgets/common/explore_hero_header.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:stays_app/app/ui/theme/theme_extensions.dart'; + +/// A hero greeting section for the Explore page. +/// Displays a time-based greeting without location badge. +class ExploreHeroHeader extends StatelessWidget { + const ExploreHeroHeader({super.key}); + + /// Returns a time-based greeting ("Good morning", "Good afternoon", "Good evening") + static String getTimeBasedGreeting() { + final hour = DateTime.now().hour; + if (hour < 12) { + return 'Good morning'; + } else if (hour < 17) { + return 'Good afternoon'; + } else { + return 'Good evening'; + } + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final textStyles = context.textStyles; + final greeting = getTimeBasedGreeting(); + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Greeting text + Text( + greeting, + style: textStyles.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: colors.onSurface, + height: 1.1, + fontSize: 18, + ), + ), + const SizedBox(height: 2), + // Subtitle + Text( + 'Discover stays around you', + style: textStyles.bodySmall?.copyWith( + color: colors.onSurface.withValues(alpha: 0.7), + fontWeight: FontWeight.w400, + height: 1.15, + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/ui/widgets/common/location_filter_app_bar.dart b/lib/app/ui/widgets/common/location_filter_app_bar.dart index 10586cc..79ab21d 100644 --- a/lib/app/ui/widgets/common/location_filter_app_bar.dart +++ b/lib/app/ui/widgets/common/location_filter_app_bar.dart @@ -4,6 +4,8 @@ import 'package:get/get.dart'; import '../../../controllers/filter_controller.dart'; import '../../../data/services/location_service.dart'; import '../../../routes/app_routes.dart'; +import '../../../utils/helpers/app_snackbar.dart'; +import '../../../utils/logger/app_logger.dart'; import '../../theme/theme_extensions.dart'; import 'filter_button.dart'; import 'search_bar_widget.dart'; @@ -156,18 +158,16 @@ class LocationFilterAppBar extends StatelessWidget if (locationService == null) return; try { await locationService.updateLocation(ensurePrecise: true); - Get.snackbar( - 'Location updated', - 'Using your current location for nearby stays', - snackPosition: SnackPosition.TOP, + AppSnackbar.success( + title: 'Location updated', + message: 'Using your current location for nearby stays', duration: const Duration(seconds: 2), ); - } catch (_) { - Get.snackbar( - 'Location unavailable', - 'Unable to fetch current location. Check permissions.', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), + } catch (error, stackTrace) { + AppLogger.error('Failed to get current location', error, stackTrace); + AppSnackbar.warning( + title: 'Location unavailable', + message: 'Unable to fetch current location. Check permissions.', ); } } diff --git a/lib/app/ui/widgets/common/prefetch_scroll_listener.dart b/lib/app/ui/widgets/common/prefetch_scroll_listener.dart index d07481d..5a47bf2 100644 --- a/lib/app/ui/widgets/common/prefetch_scroll_listener.dart +++ b/lib/app/ui/widgets/common/prefetch_scroll_listener.dart @@ -51,10 +51,11 @@ super.dispose(); } - void _onScroll() { - if (_prefetchService == null) return; - if (widget.properties.isEmpty) return; - + void _onScroll() { + if (_prefetchService == null) return; + if (widget.properties.isEmpty) return; + if (!widget.scrollController.hasClients) return; + final currentScroll = widget.scrollController.position.pixels; // Calculate approximate current item index based on scroll position diff --git a/lib/app/ui/widgets/common/premium_animated_section.dart b/lib/app/ui/widgets/common/premium_animated_section.dart new file mode 100644 index 0000000..09caab7 --- /dev/null +++ b/lib/app/ui/widgets/common/premium_animated_section.dart @@ -0,0 +1,331 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:stays_app/app/ui/theme/app_animations.dart'; + +/// A premium animated section wrapper that staggers entrance animations +/// for child widgets, creating a cascading reveal effect. +class PremiumAnimatedSection extends StatefulWidget { + final Widget child; + final int index; + final Duration delay; + final Duration duration; + final Curve curve; + final Offset slideOffset; + final bool autoStart; + + const PremiumAnimatedSection({ + super.key, + required this.child, + this.index = 0, + this.delay = Duration.zero, + this.duration = AppAnimations.medium, + this.curve = AppAnimations.easeOutCubic, + this.slideOffset = const Offset(0, 0.05), + this.autoStart = true, + }); + + @override + State createState() => _PremiumAnimatedSectionState(); +} + +class _PremiumAnimatedSectionState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacityAnimation; + late Animation _slideAnimation; + late Animation _scaleAnimation; + bool _hasStarted = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + + // Opacity animation + _opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, 0.6, curve: widget.curve), + ), + ); + + // Slide animation + _slideAnimation = Tween( + begin: widget.slideOffset, + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, 1.0, curve: widget.curve), + ), + ); + + // Subtle scale animation + _scaleAnimation = Tween(begin: 0.96, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, 0.8, curve: widget.curve), + ), + ); + + if (widget.autoStart) { + _startAnimation(); + } + } + + void _startAnimation() { + if (!_hasStarted) { + _hasStarted = true; + Future.delayed(widget.delay, () { + if (mounted) { + _controller.forward(); + } + }); + } + } + + @override + void didUpdateWidget(PremiumAnimatedSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.autoStart && !_hasStarted) { + _startAnimation(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Opacity( + opacity: _opacityAnimation.value, + child: Transform.translate( + offset: Offset( + _slideAnimation.value.dx * MediaQuery.of(context).size.width, + _slideAnimation.value.dy * MediaQuery.of(context).size.height, + ), + child: Transform.scale( + scale: _scaleAnimation.value, + child: child, + ), + ), + ); + }, + child: widget.child, + ); + } +} + +/// A list builder with staggered entrance animations. +class StaggeredAnimatedList extends StatelessWidget { + final int itemCount; + final Widget Function(BuildContext, int) itemBuilder; + final Duration delay; + final Duration duration; + final Curve curve; + final Offset slideOffset; + + const StaggeredAnimatedList({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.delay = Duration.zero, + this.duration = AppAnimations.medium, + this.curve = AppAnimations.easeOutCubic, + this.slideOffset = const Offset(0, 0.05), + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < itemCount; i++) + PremiumAnimatedSection( + key: ValueKey('item_$i'), + index: i, + delay: delay, + duration: duration, + curve: curve, + slideOffset: slideOffset, + child: itemBuilder(context, i), + ), + ], + ); + } +} + +/// A container with premium entrance animation. +class PremiumAnimatedContainer extends StatefulWidget { + final Widget child; + final VoidCallback? onTap; + final Duration duration; + final Curve curve; + final double scaleOnPress; + final bool enableRipple; + + const PremiumAnimatedContainer({ + super.key, + required this.child, + this.onTap, + this.duration = AppAnimations.fast, + this.curve = AppAnimations.easeOutCubic, + this.scaleOnPress = 0.96, + this.enableRipple = true, + }); + + @override + State createState() => _PremiumAnimatedContainerState(); +} + +class _PremiumAnimatedContainerState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: widget.scaleOnPress, + ).animate( + CurvedAnimation( + parent: _controller, + curve: widget.curve, + ), + ); + + // Start entrance animation + _controller.forward(); + } + + void _handleTapDown(TapDownDetails details) { + _controller.reverse(); + } + + void _handleTapUp(TapUpDetails details) { + _controller.forward(); + } + + void _handleTapCancel() { + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.onTap, + onTapDown: widget.onTap != null ? _handleTapDown : null, + onTapUp: widget.onTap != null ? _handleTapUp : null, + onTapCancel: widget.onTap != null ? _handleTapCancel : null, + child: ScaleTransition( + scale: _scaleAnimation, + child: widget.child, + ), + ); + } +} + +/// A glassmorphism container with blur effect. +class GlassContainer extends StatelessWidget { + final Widget child; + final double blur; + final double opacity; + final double borderRadius; + final Border? border; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry margin; + final LinearGradient? gradient; + final BoxShadow? shadow; + + const GlassContainer({ + super.key, + required this.child, + this.blur = 10, + this.opacity = 0.1, + this.borderRadius = 20, + this.border, + this.padding = const EdgeInsets.all(16), + this.margin = EdgeInsets.zero, + this.gradient, + this.shadow, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + margin: margin, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + boxShadow: shadow != null + ? [shadow!] + : [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: blur == 0 + ? ImageFilter.blur(sigmaX: 0, sigmaY: 0) + : ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: Container( + padding: padding, + decoration: BoxDecoration( + gradient: gradient ?? + LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDark + ? [ + const Color(0xFFFFFFFF).withValues(alpha: opacity * 0.3), + const Color(0xFFFFFFFF).withValues(alpha: opacity * 0.1), + ] + : [ + const Color(0xFFFFFFFF).withValues(alpha: opacity * 0.7), + const Color(0xFFFFFFFF).withValues(alpha: opacity * 0.3), + ], + ), + borderRadius: BorderRadius.circular(borderRadius), + border: border ?? + Border.all( + color: isDark + ? const Color(0xFFFFFFFF).withValues(alpha: 0.1) + : const Color(0xFFFFFFFF).withValues(alpha: 0.3), + width: 1, + ), + ), + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/app/ui/widgets/common/premium_button.dart b/lib/app/ui/widgets/common/premium_button.dart new file mode 100644 index 0000000..0c1b9b7 --- /dev/null +++ b/lib/app/ui/widgets/common/premium_button.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; +import 'package:stays_app/app/ui/theme/app_animations.dart'; + +/// Premium elevated button with gradient background and smooth animations. +class PremiumButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final IconData? icon; + final bool isLoading; + final bool isDisabled; + final Color? backgroundColor; + final Color? foregroundColor; + final double? width; + final double height; + final BorderRadius? borderRadius; + final EdgeInsetsGeometry? padding; + + const PremiumButton({ + super.key, + required this.text, + this.onPressed, + this.icon, + this.isLoading = false, + this.isDisabled = false, + this.backgroundColor, + this.foregroundColor, + this.width, + this.height = 56, + this.borderRadius, + this.padding, + }); + + @override + State createState() => _PremiumButtonState(); +} + +class _PremiumButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + bool _isPressed = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: AppAnimations.fast, + vsync: this, + ); + + _scaleAnimation = Tween(begin: 1.0, end: 0.96).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.buttonPressCurve, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTapDown(TapDownDetails details) { + if (!_isPressed && !widget.isLoading && !widget.isDisabled) { + setState(() => _isPressed = true); + _controller.forward(); + } + } + + void _handleTapUp(TapUpDetails details) { + if (_isPressed) { + setState(() => _isPressed = false); + _controller.reverse(); + } + } + + void _handleTapCancel() { + if (_isPressed) { + setState(() => _isPressed = false); + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final backgroundColor = widget.backgroundColor ?? colorScheme.primary; + final foregroundColor = widget.foregroundColor ?? colorScheme.onPrimary; + final isEnabled = !widget.isLoading && !widget.isDisabled && widget.onPressed != null; + + return GestureDetector( + onTapDown: isEnabled ? _handleTapDown : null, + onTapUp: isEnabled ? _handleTapUp : null, + onTapCancel: isEnabled ? _handleTapCancel : null, + onTap: isEnabled ? widget.onPressed : null, + child: AnimatedOpacity( + duration: AppAnimations.fast, + opacity: widget.isDisabled ? 0.5 : 1.0, + child: ScaleTransition( + scale: _scaleAnimation, + child: SizedBox( + width: widget.width, + height: widget.height, + child: _buildButtonContent(context, backgroundColor, foregroundColor), + ), + ), + ), + ); + } + + Widget _buildButtonContent(BuildContext context, Color bgColor, Color fgColor) { + final borderRadius = widget.borderRadius ?? BorderRadius.circular(18); + + return Container( + padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + bgColor, + bgColor.withValues(alpha: 0.85), + ], + ), + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: bgColor.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: Center( + child: widget.isLoading + ? _buildLoadingIndicator(fgColor) + : _buildButtonLabel(fgColor), + ), + ); + } + + Widget _buildButtonLabel(Color fgColor) { + if (widget.icon != null) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(widget.icon, size: 20, color: fgColor), + const SizedBox(width: 12), + Text( + widget.text, + style: TextStyle( + color: fgColor, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ], + ); + } + return Text( + widget.text, + style: TextStyle( + color: fgColor, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ); + } + + Widget _buildLoadingIndicator(Color fgColor) { + return SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(fgColor), + ), + ); + } +} + +/// Premium outlined button with border animation. +class PremiumOutlinedButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final IconData? icon; + final bool isLoading; + final bool isDisabled; + final Color? borderColor; + final Color? textColor; + final double? width; + final double height; + final BorderRadius? borderRadius; + + const PremiumOutlinedButton({ + super.key, + required this.text, + this.onPressed, + this.icon, + this.isLoading = false, + this.isDisabled = false, + this.borderColor, + this.textColor, + this.width, + this.height = 52, + this.borderRadius, + }); + + @override + State createState() => _PremiumOutlinedButtonState(); +} + +class _PremiumOutlinedButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _borderAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: AppAnimations.fast, + vsync: this, + ); + + _scaleAnimation = Tween(begin: 1.0, end: 0.97).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.buttonPressCurve, + ), + ); + + _borderAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.easeOutCubic, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final borderColor = widget.borderColor ?? colorScheme.outline; + final textColor = widget.textColor ?? colorScheme.onSurface; + final isEnabled = !widget.isLoading && !widget.isDisabled && widget.onPressed != null; + + return GestureDetector( + onTapDown: isEnabled ? (_) => _controller.forward() : null, + onTapUp: isEnabled ? (_) => _controller.reverse() : null, + onTapCancel: () => _controller.reverse(), + onTap: isEnabled ? widget.onPressed : null, + child: AnimatedOpacity( + duration: AppAnimations.fast, + opacity: widget.isDisabled ? 0.5 : 1.0, + child: ScaleTransition( + scale: _scaleAnimation, + child: SizedBox( + width: widget.width, + height: widget.height, + child: AnimatedBuilder( + animation: _borderAnimation, + builder: (context, child) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + borderRadius: widget.borderRadius ?? BorderRadius.circular(16), + border: Border.all( + color: borderColor.withValues( + alpha: 0.5 + (_borderAnimation.value * 0.5), + ), + width: 1.5, + ), + ), + child: Center( + child: widget.isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(textColor), + ), + ) + : _buildContent(textColor), + ), + ); + }, + ), + ), + ), + ), + ); + } + + Widget _buildContent(Color textColor) { + if (widget.icon != null) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(widget.icon, size: 18, color: textColor), + const SizedBox(width: 10), + Text( + widget.text, + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ], + ); + } + return Text( + widget.text, + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ); + } +} + +/// Premium icon button with ripple effect. +class PremiumIconButton extends StatefulWidget { + final IconData icon; + final VoidCallback? onPressed; + final String? tooltip; + final Color? backgroundColor; + final Color? iconColor; + final double size; + final bool isLoading; + + const PremiumIconButton({ + super.key, + required this.icon, + this.onPressed, + this.tooltip, + this.backgroundColor, + this.iconColor, + this.size = 48, + this.isLoading = false, + }); + + @override + State createState() => _PremiumIconButtonState(); +} + +class _PremiumIconButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _rippleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: AppAnimations.fast, + vsync: this, + ); + + _scaleAnimation = Tween(begin: 1.0, end: 0.9).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.buttonPressCurve, + ), + ); + + _rippleAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final backgroundColor = widget.backgroundColor ?? colorScheme.surfaceContainerHighest; + final iconColor = widget.iconColor ?? colorScheme.onSurface; + + final button = GestureDetector( + onTapDown: widget.onPressed != null && !widget.isLoading + ? (_) => _controller.forward() + : null, + onTapUp: widget.onPressed != null && !widget.isLoading + ? (_) => _controller.reverse() + : null, + onTapCancel: () => _controller.reverse(), + onTap: widget.isLoading ? null : widget.onPressed, + child: ScaleTransition( + scale: _scaleAnimation, + child: SizedBox( + width: widget.size, + height: widget.size, + child: Stack( + alignment: Alignment.center, + children: [ + // Ripple effect + if (widget.onPressed != null) + AnimatedBuilder( + animation: _rippleAnimation, + builder: (context, child) { + return Container( + width: widget.size * _rippleAnimation.value, + height: widget.size * _rippleAnimation.value, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: backgroundColor.withValues(alpha: 0.3), + ), + ); + }, + ), + // Background + Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: backgroundColor.withValues(alpha: 0.5), + ), + ), + // Icon or loading indicator + widget.isLoading + ? SizedBox( + width: widget.size * 0.4, + height: widget.size * 0.4, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(iconColor), + ), + ) + : Icon( + widget.icon, + size: widget.size * 0.4, + color: iconColor, + ), + ], + ), + ), + ), + ); + + if (widget.tooltip != null) { + return Tooltip(message: widget.tooltip!, child: button); + } + return button; + } +} diff --git a/lib/app/ui/widgets/common/property_horizontal_section.dart b/lib/app/ui/widgets/common/property_horizontal_section.dart new file mode 100644 index 0000000..fb8b1ba --- /dev/null +++ b/lib/app/ui/widgets/common/property_horizontal_section.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:stays_app/app/data/models/property_model.dart'; +import 'package:stays_app/app/ui/theme/theme_extensions.dart'; +import 'package:stays_app/app/ui/widgets/cards/property_grid_card.dart'; +import 'package:stays_app/app/ui/widgets/common/section_header.dart'; +import 'package:stays_app/app/ui/widgets/common/animated_widgets.dart'; + +/// A reusable horizontal scrolling section for property cards. +/// Displays a section title and a horizontally scrollable list of property cards. +class PropertyHorizontalSection extends StatelessWidget { + final String title; + final String? subtitle; + final IconData? leadingIcon; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; + final List properties; + final bool isLoading; + final VoidCallback? onViewAll; + final void Function(Property)? onPropertyTap; + final void Function(Property)? onFavoriteToggle; + final bool Function(int)? isPropertyFavorite; + final String sectionPrefix; + final EdgeInsetsGeometry? padding; + final String? emptyMessage; + final double? cardHeight; + final double? cardWidth; + + const PropertyHorizontalSection({ + super.key, + required this.title, + required this.properties, + this.subtitle, + this.leadingIcon, + this.titleStyle, + this.subtitleStyle, + this.isLoading = false, + this.onViewAll, + this.onPropertyTap, + this.onFavoriteToggle, + this.isPropertyFavorite, + this.sectionPrefix = 'section', + this.padding, + this.emptyMessage, + this.cardHeight, + this.cardWidth, + }); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + + if (isLoading) { + return _buildLoadingSection(context); + } + + if (properties.isEmpty) { + if (emptyMessage != null) { + return _buildEmptySection(context, colors); + } + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + title: title, + subtitle: subtitle, + leadingIcon: leadingIcon, + titleStyle: titleStyle, + subtitleStyle: subtitleStyle, + onViewAll: properties.length > 3 ? onViewAll : null, + padding: padding ?? const EdgeInsets.symmetric(horizontal: 20), + ), + const SizedBox(height: 14), + SizedBox( + height: cardHeight ?? 260, + child: ListView.builder( + key: ValueKey('${sectionPrefix}_list_${properties.length}'), + padding: padding ?? const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: properties.length, + itemBuilder: (context, index) { + final property = properties[index]; + final width = cardWidth ?? 240; + return StaggeredListItem( + index: index, + child: RepaintBoundary( + child: Padding( + padding: const EdgeInsets.only(right: 14), + child: SizedBox( + width: width, + child: PropertyGridCard( + property: property, + isCompact: true, + heroPrefix: '${sectionPrefix}_$index', + isFavorite: isPropertyFavorite?.call(property.id) ?? false, + onTap: () => onPropertyTap?.call(property), + onFavoriteToggle: onFavoriteToggle != null + ? () => onFavoriteToggle!(property) + : null, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildLoadingSection(BuildContext context) { + final colors = context.colors; + final width = cardWidth ?? 240; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + title: title, + subtitle: subtitle, + leadingIcon: leadingIcon, + titleStyle: titleStyle, + subtitleStyle: subtitleStyle, + padding: padding ?? const EdgeInsets.symmetric(horizontal: 20), + ), + const SizedBox(height: 14), + SizedBox( + height: cardHeight ?? 260, + child: ListView.builder( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + itemCount: 3, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(right: 14), + child: _buildShimmerCard(context, colors, width), + ), + ), + ), + ], + ); + } + + Widget _buildShimmerCard( + BuildContext context, + ColorScheme colors, + double width, + ) { + return SizedBox( + width: width, + child: Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colors.outline.withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Shimmer image + Expanded( + flex: 3, + child: Shimmer.fromColors( + baseColor: colors.surfaceContainerHighest, + highlightColor: colors.surface, + child: Container( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + ), + ), + ), + // Shimmer content + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Shimmer.fromColors( + baseColor: colors.surfaceContainerHighest, + highlightColor: colors.surface, + child: Container( + height: 14, + width: double.infinity, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 8), + Shimmer.fromColors( + baseColor: colors.surfaceContainerHighest, + highlightColor: colors.surface, + child: Container( + height: 12, + width: 120, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildEmptySection(BuildContext context, ColorScheme colors) { + return Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(title: title), + const SizedBox(height: 16), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + Icon( + Icons.hotel_outlined, + size: 48, + color: colors.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 12), + Text( + emptyMessage!, + style: context.textStyles.bodyMedium?.copyWith( + color: colors.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/ui/widgets/common/section_header.dart b/lib/app/ui/widgets/common/section_header.dart index 8a3f893..a278dfb 100644 --- a/lib/app/ui/widgets/common/section_header.dart +++ b/lib/app/ui/widgets/common/section_header.dart @@ -4,6 +4,10 @@ import '../../theme/theme_extensions.dart'; class SectionHeader extends StatelessWidget { final String title; + final String? subtitle; + final IconData? leadingIcon; + final Color? leadingIconColor; + final TextStyle? subtitleStyle; final VoidCallback? onViewAll; final EdgeInsetsGeometry padding; final TextStyle? titleStyle; @@ -11,6 +15,10 @@ class SectionHeader extends StatelessWidget { const SectionHeader({ super.key, required this.title, + this.subtitle, + this.leadingIcon, + this.leadingIconColor, + this.subtitleStyle, this.onViewAll, this.padding = const EdgeInsets.symmetric(horizontal: 20), this.titleStyle, @@ -18,45 +26,83 @@ class SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final colors = context.colors; + final resolvedTitleStyle = + titleStyle ?? + context.textStyles.titleMedium?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w700, + color: colors.onSurface, + ); + final resolvedSubtitleStyle = + subtitleStyle ?? + context.textStyles.bodySmall?.copyWith( + color: colors.onSurface.withValues(alpha: 0.65), + ); + return Padding( padding: padding, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: subtitle == null + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, children: [ Expanded( - child: Text( - title, - style: - titleStyle ?? - context.textStyles.titleMedium?.copyWith( - fontSize: 20, - fontWeight: FontWeight.w700, - color: context.colors.onSurface, + child: Row( + crossAxisAlignment: subtitle == null + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + if (leadingIcon != null) ...[ + Icon( + leadingIcon, + size: 18, + color: leadingIconColor ?? colors.primary, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (onViewAll != null) - Semantics( - label: 'View all $title', - button: true, - child: GestureDetector( - onTap: onViewAll, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( + const SizedBox(width: 8), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.arrow_forward_ios_rounded, - size: 16, - color: context.colors.primary, - semanticLabel: 'Navigate forward', + Text( + title, + style: resolvedTitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: resolvedSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ], ), ), + ], + ), + ), + if (onViewAll != null) + GestureDetector( + onTap: onViewAll, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.arrow_forward_ios_rounded, + size: 16, + color: colors.primary, + ), + ], + ), ), ), ], diff --git a/lib/app/ui/widgets/common/swipeable_item.dart b/lib/app/ui/widgets/common/swipeable_item.dart new file mode 100644 index 0000000..28e74dd --- /dev/null +++ b/lib/app/ui/widgets/common/swipeable_item.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_animations.dart'; + +// =============================================== +// ANIMATED SWIPE TO DISMISS +// =============================================== + +/// A swipeable list item with smooth animations. +/// Features swipe-to-delete with confirmation, custom actions, and haptic feedback. +class SwipeableItem extends StatefulWidget { + const SwipeableItem({ + super.key, + required this.child, + this.onDelete, + this.onEdit, + this.onArchive, + this.confirmBeforeDelete = true, + this.deleteConfirmDuration = const Duration(seconds: 3), + this.backgroundColor, + }) : assert(key != null, 'SwipeableItem requires a key for Dismissible'); + + final Widget child; + final VoidCallback? onDelete; + final VoidCallback? onEdit; + final VoidCallback? onArchive; + final bool confirmBeforeDelete; + final Duration deleteConfirmDuration; + final Color? backgroundColor; + + @override + State createState() => _SwipeableItemState(); +} + +class _SwipeableItemState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _fadeAnimation; + + bool _isDeleting = false; + bool _isConfirmingDelete = false; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: AppAnimations.medium, + vsync: this, + ); + + _scaleAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.easeOutCubic, + ), + ); + + _fadeAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation( + parent: _controller, + curve: AppAnimations.easeOut, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _cancelDelete() { + if (_isConfirmingDelete) { + setState(() => _isConfirmingDelete = false); + HapticFeedback.lightImpact(); + } + } + + @override + Widget build(BuildContext context) { + if (_isDeleting) { + return SizeTransition( + axis: Axis.vertical, + sizeFactor: _scaleAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: widget.child, + ), + ); + } + + return ClipRect( + child: Dismissible( + key: widget.key!, + direction: DismissDirection.endToStart, + dismissThresholds: const { + DismissDirection.endToStart: 0.7, + }, + onDismissed: (_) { + widget.onDelete?.call(); + }, + confirmDismiss: (direction) async { + if (widget.confirmBeforeDelete && !_isConfirmingDelete) { + setState(() => _isConfirmingDelete = true); + + HapticFeedback.mediumImpact(); + + // Wait for confirmation + await Future.delayed(widget.deleteConfirmDuration); + + if (mounted && _isConfirmingDelete) { + setState(() => _isConfirmingDelete = false); + } + + return false; + } + + HapticFeedback.heavyImpact(); + return true; + }, + background: _buildBackground(context), + child: GestureDetector( + onTap: _isConfirmingDelete ? _cancelDelete : null, + child: Stack( + children: [ + widget.child, + if (_isConfirmingDelete) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: _DeleteConfirmation( + onCancel: _cancelDelete, + onConfirm: () { + _controller.forward().then((_) { + widget.onDelete?.call(); + }); + }, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildBackground(BuildContext context) { + return Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: _isConfirmingDelete ? Colors.red : Colors.red.shade400, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _isConfirmingDelete ? Icons.warning : Icons.delete_rounded, + color: Colors.white, + size: 28, + ), + ); + } +} + +/// Delete confirmation button group +class _DeleteConfirmation extends StatelessWidget { + const _DeleteConfirmation({ + required this.onCancel, + required this.onConfirm, + }); + + final VoidCallback onCancel; + final VoidCallback onConfirm; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.delete_rounded, + color: Colors.white, + size: 24, + ), + const SizedBox(width: 8), + const Text( + 'Delete?', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 16), + // Cancel button + GestureDetector( + onTap: onCancel, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'No', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 8), + // Confirm button + GestureDetector( + onTap: onConfirm, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Yes', + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + } +} + +// =============================================== +// SIMPLE SWIPE TO DISMISS +// =============================================== + +/// A simpler swipe-to-dismiss without confirmation. +class SimpleSwipeable extends StatelessWidget { + const SimpleSwipeable({ + required super.key, + required this.child, + required this.onDismissed, + this.direction = DismissDirection.endToStart, + this.backgroundIcon = Icons.delete_rounded, + this.backgroundColor, + }); + + final Widget child; + final DismissDirectionCallback onDismissed; + final DismissDirection direction; + final IconData backgroundIcon; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ClipRect( + child: Dismissible( + key: key!, + direction: direction, + onDismissed: onDismissed, + background: Container( + alignment: direction == DismissDirection.endToStart + ? Alignment.centerRight + : Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: backgroundColor ?? theme.colorScheme.error, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + backgroundIcon, + color: Colors.white, + size: 28, + ), + ), + child: child, + ), + ); + } +} diff --git a/lib/app/ui/widgets/filters/property_filter_sheet.dart b/lib/app/ui/widgets/filters/property_filter_sheet.dart index a21729e..8591bb8 100644 --- a/lib/app/ui/widgets/filters/property_filter_sheet.dart +++ b/lib/app/ui/widgets/filters/property_filter_sheet.dart @@ -3,23 +3,71 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import '../../../data/models/unified_filter_model.dart'; +import '../../theme/app_animations.dart'; Future showPropertyFilterSheet({ required BuildContext context, required UnifiedFilterModel initial, -}) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (ctx) => _PropertyFilterSheet(initial: initial), +}) async { + final result = await Navigator.of(context).push<_FilterSheetRouteResult>( + _FilterSheetRoute( + initial: initial, + curve: AppAnimations.sheetCurve, + duration: AppAnimations.sheetDuration, + ), ); + return result?.filters; +} + +class _FilterSheetRouteResult { + const _FilterSheetRouteResult({required this.filters}); + final UnifiedFilterModel filters; +} + +class _FilterSheetRoute extends PopupRoute<_FilterSheetRouteResult> { + _FilterSheetRoute({ + required this.initial, + required this.curve, + required this.duration, + }); + + final UnifiedFilterModel initial; + final Curve curve; + final Duration duration; + + @override + Color? get barrierColor => Colors.black.withValues(alpha: 0.5); + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => 'Dismiss filter sheet'; + + @override + Duration get transitionDuration => duration; + + @override + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { + return _PropertyFilterSheet( + initial: initial, + animation: animation, + curve: curve, + ); + } } class _PropertyFilterSheet extends StatefulWidget { - const _PropertyFilterSheet({required this.initial}); + const _PropertyFilterSheet({ + required this.initial, + required this.animation, + required this.curve, + }); final UnifiedFilterModel initial; + final Animation animation; + final Curve curve; @override State<_PropertyFilterSheet> createState() => _PropertyFilterSheetState(); @@ -158,56 +206,75 @@ class _PropertyFilterSheetState extends State<_PropertyFilterSheet> { final mediaQuery = MediaQuery.of(context); final height = mediaQuery.size.height * 0.85; - return GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: Container( - height: height, - padding: EdgeInsets.only(bottom: mediaQuery.viewInsets.bottom), - decoration: BoxDecoration( - color: _colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: [ - BoxShadow( - color: _colorScheme.shadow.withValues( - alpha: _isDarkTheme ? 0.6 : 0.12, - ), - blurRadius: 24, - offset: const Offset(0, -4), - ), - ], - ), - child: SafeArea( - top: false, - child: Column( - children: [ - _buildHeader(context), - Divider(height: 1, thickness: 1, color: _dividerColor), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), + final curvedAnimation = CurvedAnimation( + parent: widget.animation, + curve: widget.curve, + ); + + return AnimatedBuilder( + animation: curvedAnimation, + builder: (context, child) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(curvedAnimation), + child: FadeTransition( + opacity: curvedAnimation, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + height: height, + padding: EdgeInsets.only(bottom: mediaQuery.viewInsets.bottom), + decoration: BoxDecoration( + color: _colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: _colorScheme.shadow.withValues( + alpha: _isDarkTheme ? 0.6 : 0.12, + ), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildPriceSection(context), - const SizedBox(height: 24), - _buildPropertyTypeSection(context), - const SizedBox(height: 24), - _buildRatingSection(context), - const SizedBox(height: 24), - _buildExperienceSection(context), + _buildHeader(context), + Divider(height: 1, thickness: 1, color: _dividerColor), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPriceSection(context), + const SizedBox(height: 24), + _buildPropertyTypeSection(context), + const SizedBox(height: 24), + _buildRatingSection(context), + const SizedBox(height: 24), + _buildExperienceSection(context), + ], + ), + ), + ), + Divider(height: 1, thickness: 1, color: _dividerColor), + _buildFooter(context), ], ), ), ), - Divider(height: 1, thickness: 1, color: _dividerColor), - _buildFooter(context), - ], + ), ), - ), - ), + ); + }, ); } @@ -500,7 +567,7 @@ class _PropertyFilterSheetState extends State<_PropertyFilterSheet> { petsAllowed: _petsAllowed ? true : null, smokingAllowed: _smokingAllowed ? true : null, ); - Navigator.of(context).pop(model); + Navigator.of(context).pop(_FilterSheetRouteResult(filters: model)); } } diff --git a/lib/app/ui/widgets/listing/interactive_virtual_tour.dart b/lib/app/ui/widgets/listing/interactive_virtual_tour.dart index 3d3838f..5eca520 100644 --- a/lib/app/ui/widgets/listing/interactive_virtual_tour.dart +++ b/lib/app/ui/widgets/listing/interactive_virtual_tour.dart @@ -36,7 +36,6 @@ class _InteractiveVirtualTourState extends State { void _holdParentScroll() { if (_scrollHoldController != null) return; final scrollable = Scrollable.of(context); - if (scrollable == null) return; _scrollHoldController = scrollable.position.hold(_onParentScrollReleased); } diff --git a/lib/app/ui/widgets/profile/profile_header.dart b/lib/app/ui/widgets/profile/profile_header.dart index 14ba32e..9fcfce6 100644 --- a/lib/app/ui/widgets/profile/profile_header.dart +++ b/lib/app/ui/widgets/profile/profile_header.dart @@ -26,7 +26,7 @@ class ProfileHeader extends StatelessWidget { final horizontalPadding = dense ? 8.0 : 16.0; final verticalPadding = dense ? 6.0 : 12.0; // Scale avatar back to roughly 70% of the previous size. - final avatarSize = dense ? 60.0 : 90.0; + final avatarSize = dense ? 52.0 : 76.0; final avatarRadius = avatarSize / 2; return Container( @@ -85,9 +85,10 @@ class ProfileHeader extends StatelessWidget { ] else ...[ Text( userName, - style: theme.textTheme.titleLarge?.copyWith( + style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, + fontSize: 18, ), overflow: TextOverflow.ellipsis, ), @@ -95,10 +96,11 @@ class ProfileHeader extends StatelessWidget { const SizedBox(height: 2), Text( userEmail, - style: theme.textTheme.bodyMedium?.copyWith( + style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues( - alpha: 0.9, + alpha: 0.75, ), + fontSize: 12, ), overflow: TextOverflow.ellipsis, ), @@ -116,7 +118,7 @@ class ProfileHeader extends StatelessWidget { return Center( child: Text( initials, - style: theme.textTheme.headlineSmall?.copyWith( + style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onPrimary, fontWeight: FontWeight.bold, ), diff --git a/lib/app/utils/extensions/dynamic_extensions.dart b/lib/app/utils/extensions/dynamic_extensions.dart new file mode 100644 index 0000000..edd77ab --- /dev/null +++ b/lib/app/utils/extensions/dynamic_extensions.dart @@ -0,0 +1,20 @@ +/// Helpers for parsing dynamic values to bool. + +/// Parses a dynamic value to a boolean with a fallback. +/// +/// Handles bool, num (non-zero = true), and String ('true', '1', 'yes'). +/// Returns [fallback] if the value is null or cannot be parsed. +bool parseBool(dynamic value, {required bool fallback}) { + if (value is bool) return value; + if (value is num) return value != 0; + if (value is String) { + final normalized = value.toLowerCase(); + if (normalized == 'true' || normalized == '1' || normalized == 'yes') { + return true; + } + if (normalized == 'false' || normalized == '0' || normalized == 'no') { + return false; + } + } + return fallback; +} diff --git a/lib/app/utils/helpers/app_snackbar.dart b/lib/app/utils/helpers/app_snackbar.dart index 6e0e10f..394afd2 100644 --- a/lib/app/utils/helpers/app_snackbar.dart +++ b/lib/app/utils/helpers/app_snackbar.dart @@ -19,6 +19,17 @@ class AppSnackbar { static const double _borderRadius = 16.0; static const EdgeInsets _margin = EdgeInsets.all(16); + /// Check if GetX is ready to show snackbars (has valid overlay context) + static bool get _canShowSnackbar { + try { + // Try to access the current route - if it fails, navigator isn't ready + Get.currentRoute; + return true; + } catch (_) { + return false; + } + } + /// Show a success snackbar static void success({ required String title, @@ -92,6 +103,7 @@ class AppSnackbar { /// Show a simple message snackbar (no title) static void show(String message, {bool isError = false}) { + if (!_canShowSnackbar) return; // Skip if GetX overlay isn't ready final colors = Get.theme.colorScheme; Get.snackbar( '', @@ -119,6 +131,7 @@ class AppSnackbar { required Color iconColor, required Duration duration, }) { + if (!_canShowSnackbar) return; // Skip if GetX overlay isn't ready Get.snackbar( '', '', diff --git a/lib/app/utils/helpers/booking_helpers.dart b/lib/app/utils/helpers/booking_helpers.dart new file mode 100644 index 0000000..7049b79 --- /dev/null +++ b/lib/app/utils/helpers/booking_helpers.dart @@ -0,0 +1,26 @@ +/// Helpers for booking status checks. + +/// Keywords indicating a negative/excluded booking status. +const _negativeStatusKeywords = [ + 'cancel', + 'refund', + 'fail', + 'decline', + 'reject', + 'void', + 'expired', +]; + +/// Returns true if the booking status should be counted in spend/stats. +/// +/// Excludes cancelled, refunded, failed, and other negative statuses. +bool shouldCountBookingStatus(String? status) { + if (status == null) return false; + final normalized = status.trim().toLowerCase(); + if (normalized.isEmpty) return false; + + if (_negativeStatusKeywords.any((keyword) => normalized.contains(keyword))) { + return false; + } + return true; +} diff --git a/lib/app/utils/helpers/error_handler.dart b/lib/app/utils/helpers/error_handler.dart index 5cabbfb..f4a82e7 100644 --- a/lib/app/utils/helpers/error_handler.dart +++ b/lib/app/utils/helpers/error_handler.dart @@ -1,9 +1,8 @@ -import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../../ui/theme/app_colors.dart'; import '../../routes/app_routes.dart'; import '../exceptions/app_exceptions.dart'; +import '../helpers/app_snackbar.dart'; import '../logger/app_logger.dart'; class ErrorHandler { @@ -38,49 +37,41 @@ class ErrorHandler { message = 'Server error. Please try again later.'; break; } - Get.snackbar( - 'Error', - message, - snackPosition: SnackPosition.TOP, - backgroundColor: AppColors.error, - colorText: Colors.white, - ); + AppSnackbar.error(title: 'Error', message: message); } static void _handleNetworkException(NetworkException error) { - Get.snackbar( - 'Network Error', - 'Please check your internet connection.', - snackPosition: SnackPosition.TOP, + AppSnackbar.error( + title: 'Network Error', + message: 'Please check your internet connection.', ); } static void _handleAuthException(AuthException error) { - Get.snackbar( - 'Authentication Error', - error.message, - snackPosition: SnackPosition.TOP, - ); + AppSnackbar.error(title: 'Authentication Error', message: error.message); if (error.code == 'token_expired' || error.code == 'invalid_token') { Get.offAllNamed(Routes.login); } } static void _handleValidationException(ValidationException error) { - final firstError = error.errors.values.first.first; - Get.snackbar( - 'Validation Error', - firstError, - snackPosition: SnackPosition.TOP, - ); + final errorValues = error.errors.values; + if (errorValues.isEmpty || errorValues.first.isEmpty) { + AppSnackbar.warning( + title: 'Validation Error', + message: 'Please check your input.', + ); + return; + } + final firstError = errorValues.first.first; + AppSnackbar.warning(title: 'Validation Error', message: firstError); } static void _handleGenericError(dynamic error) { AppLogger.error('Unhandled error', error); - Get.snackbar( - 'Error', - 'An unexpected error occurred. Please try again.', - snackPosition: SnackPosition.TOP, + AppSnackbar.error( + title: 'Error', + message: 'An unexpected error occurred. Please try again.', ); } } diff --git a/lib/app/utils/services/connectivity_service.dart b/lib/app/utils/services/connectivity_service.dart index d4b5f02..99e1951 100644 --- a/lib/app/utils/services/connectivity_service.dart +++ b/lib/app/utils/services/connectivity_service.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'dart:io'; -import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:get/get.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +import 'package:stays_app/config/app_config.dart'; import '../logger/app_logger.dart'; @@ -20,13 +23,15 @@ class ConnectivityService extends GetxService { @override void onInit() { super.onInit(); - _initialize(); + unawaited(_initialize()); } @override void onClose() { - _subscription?.cancel(); - _connectionChangeController.close(); + if (_subscription != null) { + unawaited(_subscription!.cancel()); + } + unawaited(_connectionChangeController.close()); super.onClose(); } @@ -75,14 +80,14 @@ class ConnectivityService extends GetxService { {'previous': hadConnection, 'current': nowConnected}, ); } + + // Verify actual internet reachability (transport alone can be wrong). + unawaited(_confirmInternetAccess(hadConnection: hadConnection)); } Future checkConnection() async { try { - final result = await Connectivity().checkConnectivity(); - return result.contains(ConnectivityResult.wifi) || - result.contains(ConnectivityResult.mobile) || - result.contains(ConnectivityResult.ethernet); + return await _hasInternetAccess(); } catch (e) { AppLogger.error('Failed to check connection', e); return false; @@ -94,4 +99,66 @@ class ConnectivityService extends GetxService { bool get isOnWifi => status.value == ConnectivityStatus.wifi; bool get isOnMobile => status.value == ConnectivityStatus.mobile; + + Future _confirmInternetAccess({required bool hadConnection}) async { + final reachable = await _hasInternetAccess(); + if (isOnline.value != reachable) { + isOnline.value = reachable; + _connectionChangeController.add(reachable); + AppLogger.info( + 'Connection verified: ${reachable ? "Online" : "Offline"}', + {'previous': hadConnection, 'current': reachable}, + ); + } + } + + Future _hasInternetAccess() async { + final apiHost = _resolveApiHost(); + final hosts = { + if (apiHost.isNotEmpty) apiHost, + 'google.com', + }; + + for (final host in hosts) { + if (await _canReachHost( + host, + host == apiHost ? _resolveHealthPort() : 443, + )) { + return true; + } + } + + return false; + } + + String _resolveApiHost() { + try { + final uri = Uri.parse(AppConfig.I.apiBaseUrl); + if (uri.host.isNotEmpty) return uri.host; + } catch (_) {} + return ''; + } + + int _resolveHealthPort() { + try { + final uri = Uri.parse(AppConfig.I.apiBaseUrl); + if (uri.port > 0) return uri.port; + return uri.scheme == 'http' ? 80 : 443; + } catch (_) { + return 443; + } + } + + Future _canReachHost(String host, int port) async { + try { + final socket = await Socket.connect( + host, + port, + timeout: const Duration(seconds: 3), + ); + socket.destroy(); + return true; + } catch (_) {} + return false; + } } diff --git a/lib/app/utils/services/error_service.dart b/lib/app/utils/services/error_service.dart index d1cd1df..d272aa2 100644 --- a/lib/app/utils/services/error_service.dart +++ b/lib/app/utils/services/error_service.dart @@ -17,7 +17,7 @@ class ErrorService extends GetxService { ApiException toApiException(Response response) { final int statusCode = response.statusCode ?? 500; final body = response.body; - String message = + final String message = _extractMessage(body) ?? response.bodyString ?? response.statusText ?? diff --git a/lib/app/utils/services/token_service.dart b/lib/app/utils/services/token_service.dart index ff1a076..7026477 100644 --- a/lib/app/utils/services/token_service.dart +++ b/lib/app/utils/services/token_service.dart @@ -9,23 +9,31 @@ import '../../data/services/storage_service.dart'; import '../logger/app_logger.dart'; class TokenInfo { - final String accessToken; - final String? refreshToken; - final DateTime? expiresAt; - final DateTime createdAt; - const TokenInfo({ required this.accessToken, this.refreshToken, this.expiresAt, - required this.createdAt, }); + factory TokenInfo.fromJson(Map json) { + return TokenInfo( + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] as String?, + expiresAt: json['expiresAt'] != null + ? DateTime.parse(json['expiresAt'] as String) + : null, + ); + } + + final String accessToken; + final String? refreshToken; + final DateTime? expiresAt; + bool get isExpired { final expAt = expiresAt; if (expAt == null) return false; final now = DateTime.now(); - final bufferTime = Duration(minutes: 5); + const bufferTime = Duration(minutes: 5); return now.isAfter(expAt.subtract(bufferTime)); } @@ -36,23 +44,17 @@ class TokenInfo { 'accessToken': accessToken, 'refreshToken': refreshToken, 'expiresAt': expiresAt?.toIso8601String(), - 'createdAt': createdAt.toIso8601String(), }; } - - factory TokenInfo.fromJson(Map json) { - return TokenInfo( - accessToken: json['accessToken'] as String, - refreshToken: json['refreshToken'] as String?, - expiresAt: json['expiresAt'] != null - ? DateTime.parse(json['expiresAt'] as String) - : null, - createdAt: DateTime.parse(json['createdAt'] as String), - ); - } } class TokenService extends GetxService { + TokenService({ + StorageService? storageService, + IAuthRepository? authRepository, + }) : _storageService = storageService, + _authRepository = authRepository; + static TokenService get I => Get.find(); StorageService? _storageService; @@ -64,19 +66,26 @@ class TokenService extends GetxService { final Completer _ready = Completer(); bool _isRefreshing = false; bool _supabaseSessionAvailable = false; + bool _initializationComplete = false; + Object? _initializationError; + + /// Returns a Future that completes when initialization is done. + /// Throws if initialization failed. + Future get ready async { + await _ready.future; + if (_initializationError != null) { + throw _initializationError!; + } + } - Future get ready => _ready.future; - - TokenService({ - StorageService? storageService, - IAuthRepository? authRepository, - }) : _storageService = storageService, - _authRepository = authRepository; + /// Returns true if initialization has completed successfully. + /// Use this for synchronous checks when you need to know if the service is ready. + bool get isReady => _initializationComplete && _initializationError == null; @override void onInit() { super.onInit(); - _initialize(); + unawaited(_initialize()); } @override @@ -102,7 +111,6 @@ class TokenService extends GetxService { accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt, - createdAt: DateTime.now(), ); await _saveTokens(); @@ -117,6 +125,18 @@ class TokenService extends GetxService { } String? get accessToken { + // Safe access even before initialization + if (!_initializationComplete) { + // Try Supabase session as fallback before init completes + try { + final supabaseToken = + Supabase.instance.client.auth.currentSession?.accessToken; + if (supabaseToken != null) return supabaseToken; + } catch (_) { + // Supabase not ready yet + } + return null; + } if (_supabaseSessionAvailable) { final supabaseToken = Supabase.instance.client.auth.currentSession?.accessToken; @@ -128,6 +148,19 @@ class TokenService extends GetxService { String? get refreshToken => _currentToken?.refreshToken; bool get hasValidToken { + // Safe access even before initialization - check Supabase first + try { + final session = Supabase.instance.client.auth.currentSession; + if (session != null && session.isExpired == false) { + return true; + } + } catch (_) { + // Supabase not ready yet + } + // If not initialized yet, don't trust in-memory token state + if (!_initializationComplete) { + return false; + } if (_supabaseSessionAvailable) { final session = Supabase.instance.client.auth.currentSession; if (session != null && session.isExpired == false) { @@ -138,6 +171,18 @@ class TokenService extends GetxService { } bool get needsRefresh { + // If not initialized, can't determine refresh need from in-memory state + if (!_initializationComplete) { + try { + final session = Supabase.instance.client.auth.currentSession; + if (session != null && session.isExpired == true) { + return true; + } + } catch (_) { + // Supabase not ready yet + } + return false; + } if (_supabaseSessionAvailable) { final session = Supabase.instance.client.auth.currentSession; if (session != null && session.isExpired == true) { @@ -246,6 +291,12 @@ class TokenService extends GetxService { if (isAuthenticated.value) { _startRefreshTimer(); } + + _initializationComplete = true; + } catch (e, s) { + AppLogger.error('TokenService initialization failed', e, s); + _initializationError = e; + _initializationComplete = false; } finally { if (!_ready.isCompleted) { _ready.complete(); @@ -288,7 +339,6 @@ class TokenService extends GetxService { expiresAt: expiresAtStr != null ? DateTime.tryParse(expiresAtStr) : null, - createdAt: DateTime.now(), ); if (validateTokenFormat(_currentToken!.accessToken) && diff --git a/lib/app/utils/services/validation_service.dart b/lib/app/utils/services/validation_service.dart index b2813bc..0a926e4 100644 --- a/lib/app/utils/services/validation_service.dart +++ b/lib/app/utils/services/validation_service.dart @@ -8,10 +8,10 @@ abstract class ValidationRule { /// Required field validation rule class RequiredRule extends ValidationRule { - final String? customMessage; - const RequiredRule({this.customMessage}); + final String? customMessage; + @override String? validate(String? value) { if (value == null || value.trim().isEmpty) { @@ -23,10 +23,10 @@ class RequiredRule extends ValidationRule { /// Email validation rule class EmailRule extends ValidationRule { - final String? customMessage; - const EmailRule({this.customMessage}); + final String? customMessage; + @override String? validate(String? value) { if (value == null || value.trim().isEmpty) return null; @@ -44,10 +44,10 @@ class EmailRule extends ValidationRule { /// Phone validation rule class PhoneRule extends ValidationRule { - final String? customMessage; - const PhoneRule({this.customMessage}); + final String? customMessage; + @override String? validate(String? value) { if (value == null || value.trim().isEmpty) return null; @@ -65,18 +65,18 @@ class PhoneRule extends ValidationRule { /// Email or phone validation rule class EmailOrPhoneRule extends ValidationRule { - final String? customMessage; - const EmailOrPhoneRule({this.customMessage}); + final String? customMessage; + @override String? validate(String? value) { if (value == null || value.trim().isEmpty) { return customMessage ?? 'Email or phone number is required'; } - final emailRule = EmailRule(); - final phoneRule = PhoneRule(); + const emailRule = EmailRule(); + const phoneRule = PhoneRule(); final emailError = emailRule.validate(value); final phoneError = phoneRule.validate(value); @@ -91,13 +91,6 @@ class EmailOrPhoneRule extends ValidationRule { /// Password validation rule class PasswordRule extends ValidationRule { - final int minLength; - final bool requireUppercase; - final bool requireLowercase; - final bool requireNumbers; - final bool requireSpecialChars; - final String? customMessage; - const PasswordRule({ this.minLength = 6, this.requireUppercase = false, @@ -107,6 +100,13 @@ class PasswordRule extends ValidationRule { this.customMessage, }); + final int minLength; + final bool requireUppercase; + final bool requireLowercase; + final bool requireNumbers; + final bool requireSpecialChars; + final String? customMessage; + @override String? validate(String? value) { if (value == null || value.isEmpty) { @@ -140,11 +140,11 @@ class PasswordRule extends ValidationRule { /// Password confirmation validation rule class PasswordConfirmationRule extends ValidationRule { + const PasswordConfirmationRule(this.getPassword, {this.customMessage}); + final String Function() getPassword; final String? customMessage; - const PasswordConfirmationRule(this.getPassword, {this.customMessage}); - @override String? validate(String? value) { if (value == null || value.isEmpty) { @@ -160,11 +160,11 @@ class PasswordConfirmationRule extends ValidationRule { /// Field validator for form validation class FieldValidator { + FieldValidator(this.rules); + final List rules; final RxString error = ''.obs; - FieldValidator(this.rules); - /// Validate the field with the given value bool validate(String? value) { for (final rule in rules) { @@ -186,15 +186,15 @@ class FieldValidator { /// Validation result for form submissions class ValidationResult { - final bool isValid; - final Map errors; - const ValidationResult({required this.isValid, required this.errors}); factory ValidationResult.success() => const ValidationResult(isValid: true, errors: {}); factory ValidationResult.failure(Map errors) => ValidationResult(isValid: false, errors: errors); + + final bool isValid; + final Map errors; } /// Centralized validation service for all forms @@ -274,7 +274,7 @@ class ValidationService extends GetxService { ]; static List get passwordRequired => [ - const PasswordRule(minLength: 6), + const PasswordRule(), ]; static List get passwordStrong => [ diff --git a/lib/app/utils/services/widget_optimizer.dart b/lib/app/utils/services/widget_optimizer.dart index 0e6c65e..5b0581a 100644 --- a/lib/app/utils/services/widget_optimizer.dart +++ b/lib/app/utils/services/widget_optimizer.dart @@ -160,10 +160,10 @@ class WidgetOptimizer { /// Memoized widget that rebuilds only when necessary class _MemoizedWidget extends StatefulWidget { - final Widget Function() builder; - const _MemoizedWidget({required this.builder}); + final Widget Function() builder; + @override State<_MemoizedWidget> createState() => _MemoizedWidgetState(); } @@ -262,11 +262,11 @@ class WidgetPerformanceAnalyzer { } class _PerformanceWrapper extends StatefulWidget { + const _PerformanceWrapper({required this.widgetName, required this.child}); + final String widgetName; final Widget child; - const _PerformanceWrapper({required this.widgetName, required this.child}); - @override State<_PerformanceWrapper> createState() => _PerformanceWrapperState(); } diff --git a/lib/features/auth/bindings/auth_binding.dart b/lib/features/auth/bindings/auth_binding.dart index fe9364c..084629c 100644 --- a/lib/features/auth/bindings/auth_binding.dart +++ b/lib/features/auth/bindings/auth_binding.dart @@ -3,11 +3,12 @@ import 'package:get/get.dart'; import 'package:stays_app/features/auth/controllers/auth_controller.dart'; import 'package:stays_app/features/auth/controllers/otp_controller.dart'; import 'package:stays_app/features/auth/controllers/form_validation_controller.dart'; -import 'package:stays_app/features/auth/controllers/session_controller.dart'; import 'package:stays_app/features/auth/controllers/user_profile_controller.dart'; import 'package:stays_app/app/data/repositories/auth_repository.dart'; import 'package:stays_app/app/data/providers/auth/i_auth_provider.dart'; import 'package:stays_app/app/data/providers/supabase_auth_provider.dart'; +import 'package:stays_app/app/data/providers/users_provider.dart'; +import 'package:stays_app/app/data/repositories/profile_repository.dart'; import 'package:stays_app/app/utils/services/token_service.dart'; class AuthBinding extends Bindings { @@ -24,31 +25,40 @@ class AuthBinding extends Bindings { () => AuthRepository(provider: Get.find()), ); - // Form validation controller (now managed properly) - if (!Get.isRegistered()) { - Get.put(FormValidationController()); + if (!Get.isRegistered()) { + Get.lazyPut(() => UsersProvider(), fenix: true); } - // User profile controller for profile management - if (!Get.isRegistered()) { - Get.lazyPut(() => UserProfileController()); + if (!Get.isRegistered()) { + Get.lazyPut( + () => ProfileRepository(provider: Get.find()), + fenix: true, + ); } - // Session controller for token/session management - if (!Get.isRegistered()) { - Get.lazyPut( - () => SessionController(tokenService: Get.find()), - ); + // Form validation controller (now managed properly) + if (!Get.isRegistered()) { + Get.put(FormValidationController()); } // Auth controller with proper dependency injection Get.lazyPut( () => AuthController( authRepository: Get.find(), - sessionController: Get.find(), + tokenService: Get.find(), + profileRepository: Get.find(), ), ); Get.lazyPut(() => OTPController()); + + // User profile controller for profile management + if (!Get.isRegistered()) { + Get.lazyPut( + () => UserProfileController( + profileRepository: Get.find(), + ), + ); + } } } diff --git a/lib/features/auth/controllers/auth_controller.dart b/lib/features/auth/controllers/auth_controller.dart index def8045..67483b5 100644 --- a/lib/features/auth/controllers/auth_controller.dart +++ b/lib/features/auth/controllers/auth_controller.dart @@ -1,80 +1,103 @@ import 'dart:async'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:stays_app/app/data/repositories/auth_repository.dart'; import 'package:stays_app/app/data/models/user_model.dart'; import 'package:stays_app/app/routes/app_routes.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/app/utils/exceptions/app_exceptions.dart'; +import 'package:stays_app/app/data/repositories/profile_repository.dart'; +import 'package:stays_app/app/data/services/storage_service.dart'; +import 'package:stays_app/app/utils/services/token_service.dart'; import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/app/controllers/base/base_controller.dart'; +import 'package:stays_app/app/data/services/analytics_service.dart'; import 'form_validation_controller.dart'; -import 'session_controller.dart'; -import 'user_profile_controller.dart'; -/// Controller responsible for authentication operations. -/// Handles login, logout, and registration flows. -/// Delegates session management to SessionController and profile to UserProfileController. class AuthController extends BaseController { + // Storage keys dedicated to the remember-me preference and cached tokens. + static const String _rememberMeBox = 'auth_preferences'; + static const String _rememberMeFlagKey = 'remember_me'; + // Legacy keys from older builds (plaintext tokens). Kept for one-time cleanup. + static const String _rememberedAccessTokenKey = 'remembered_access_token'; + static const String _rememberedRefreshTokenKey = 'remembered_refresh_token'; + + final AuthRepository _authRepository; + final TokenService _tokenService; + final ProfileRepository _profileRepository; + late final FormValidationController _validation; + AuthController({ required AuthRepository authRepository, - required SessionController sessionController, + required TokenService tokenService, + required ProfileRepository profileRepository, }) : _authRepository = authRepository, - _sessionController = sessionController { - // Resolve the shared FormValidationController via GetX + _tokenService = tokenService, + _profileRepository = profileRepository { + // Resolve the shared FormValidationController via GetX so lifecycle hooks run _validation = Get.isRegistered() ? Get.find() : Get.put(FormValidationController()); - - // Resolve or create UserProfileController - _userProfileController = Get.isRegistered() - ? Get.find() - : Get.put(UserProfileController()); } - final AuthRepository _authRepository; - final SessionController _sessionController; - late final FormValidationController _validation; - late final UserProfileController _userProfileController; - - // Expose session state for backwards compatibility - RxBool get isAuthenticated => _sessionController.isAuthenticated; - RxBool get rememberMe => _sessionController.rememberMe; - - // Expose current user from profile controller - Rx get currentUser => _userProfileController.currentUser; - + final Rx currentUser = Rx(null); + final RxBool isAuthenticated = false.obs; final RxBool isPasswordVisible = false.obs; + final RxBool rememberMe = false.obs; + + // Local storage for remember-me preference + late final GetStorage _authPrefs; + Future? _rememberMeReady; // Backwards-compat alias used by phone-based views RxString get phoneError => emailOrPhoneError; - RxString get emailOrPhoneError => _validation.emailOrPhoneError; - RxString get passwordError => _validation.passwordError; - RxString get confirmPasswordError => _validation.confirmPasswordError; + + StreamSubscription? _authSubscription; @override void onInit() { super.onInit(); + + // Initialize storage asynchronously (GetStorage requires init before use) + unawaited(_ensureRememberMePreferenceReady()); + + // Defer initial auth status check until TokenService has loaded tokens unawaited(_initAuthStatus()); + _bindAuthStateListener(); } @override void onReady() { super.onReady(); unawaited(_loadSavedUser()); + // Attempt to refresh profile details once ready if (isAuthenticated.value) { - unawaited(_userProfileController.fetchAndCacheProfile()); + // fire-and-forget; UI will react when currentUser updates + unawaited(fetchAndCacheProfile()); } } + @override + void onClose() { + _authSubscription?.cancel(); + super.onClose(); + } + void togglePasswordVisibility() { isPasswordVisible.value = !isPasswordVisible.value; } + RxString get emailOrPhoneError => _validation.emailOrPhoneError; + RxString get passwordError => _validation.passwordError; + RxString get confirmPasswordError => _validation.confirmPasswordError; + Future _initAuthStatus() async { try { - await _sessionController.ready; + // Wait for TokenService readiness so returning users aren't treated as logged out + await _tokenService.ready; } catch (_) { // If readiness throws, proceed with best-effort check } @@ -83,15 +106,18 @@ class AuthController extends BaseController { Future _checkAuthStatus() async { try { - var tokenAuth = _sessionController.isAuthenticated.value; + // Use TokenService for authentication status + var tokenAuth = _tokenService.isAuthenticated.value; final repoAuth = await _authRepository.isAuthenticated(); + // If repository reports a valid session but TokenService hasn't caught up yet, + // refresh TokenService from the active session to prevent false negatives. if (repoAuth && !tokenAuth) { - await _sessionController.updateTokenServiceFromCurrentSession(); - tokenAuth = _sessionController.isAuthenticated.value; + await _updateTokenServiceFromCurrentSession(); + tokenAuth = _tokenService.isAuthenticated.value; } - _sessionController.setAuthenticated(value: tokenAuth && repoAuth); + isAuthenticated.value = tokenAuth && repoAuth; AppLogger.info( isAuthenticated.value ? 'User is authenticated' @@ -99,11 +125,12 @@ class AuthController extends BaseController { ); if (isAuthenticated.value) { - unawaited(_userProfileController.fetchAndCacheProfile()); + // Proactively refresh profile so dependent screens can prefill + unawaited(fetchAndCacheProfile()); } } catch (e) { AppLogger.error('Auth check failed', e); - _sessionController.setAuthenticated(value: false); + isAuthenticated.value = false; } } @@ -111,7 +138,7 @@ class AuthController extends BaseController { try { final user = await _authRepository.getCurrentUser(); if (user != null) { - _userProfileController.setUser(user); + currentUser.value = user; AppLogger.info('Loaded saved user: ${user.email ?? user.phone}'); } } catch (e) { @@ -119,10 +146,139 @@ class AuthController extends BaseController { } } - /// Login with email or phone + // Prepare the remember-me toggle with any value persisted from a previous run. + Future _initializeRememberMePreference() async { + await GetStorage.init(_rememberMeBox); + _authPrefs = GetStorage(_rememberMeBox); + final storedPreference = _authPrefs.read(_rememberMeFlagKey) ?? false; + rememberMe.value = storedPreference; + await _migrateLegacyRememberedTokens(); + } + + Future _ensureRememberMePreferenceReady() async { + _rememberMeReady ??= _initializeRememberMePreference(); + await _rememberMeReady; + } + + void _bindAuthStateListener() { + _authSubscription?.cancel(); + _authSubscription = trackSubscription( + Supabase.instance.client.auth.onAuthStateChange.listen( + (data) async { + final event = data.event; + final session = data.session; + if (event == AuthChangeEvent.signedOut) { + // Keep TokenService in sync on sign-out events + await _tokenService.clearTokens(); + await _clearRememberedSession(); + return; + } + if (session == null) { + return; + } + if (event == AuthChangeEvent.signedIn || + event == AuthChangeEvent.tokenRefreshed) { + // Update TokenService with latest tokens so late-bound controllers see auth state + await _updateTokenServiceFromSession(session); + await _ensureRememberMePreferenceReady(); + // Only persist to local remember-me storage if opted in + if (rememberMe.value) { + await _persistRememberedSession(session: session); + } + } + }, + onError: (Object error) { + AppLogger.warning('Auth state listener error: $error'); + }, + ), + ); + } + + // Update the remember-me flag and synchronise it to disk for future launches. + Future setRememberMe(bool value) async { + await _ensureRememberMePreferenceReady(); + rememberMe.value = value; + await _authPrefs.write(_rememberMeFlagKey, value); + if (!value) { + await _clearRememberedSession(); + } + } + + // Persist the latest Supabase session details when the user opts in. + Future _persistRememberedSession({Session? session}) async { + await _ensureRememberMePreferenceReady(); + // Tokens are already stored securely via TokenService/StorageService. + // We only keep a boolean flag in GetStorage to control auto-login. + await _authPrefs.write(_rememberMeFlagKey, true); + await _clearLegacyRememberedSession(); + } + + // Drop any cached credentials when the user opts out or signs out. + Future _clearRememberedSession() async { + await _ensureRememberMePreferenceReady(); + await _clearLegacyRememberedSession(); + } + + Future _clearLegacyRememberedSession() async { + await _authPrefs.remove(_rememberedAccessTokenKey); + await _authPrefs.remove(_rememberedRefreshTokenKey); + } + + Future _migrateLegacyRememberedTokens() async { + // If legacy plaintext tokens exist, migrate them to secure storage once. + try { + final legacyAccess = _authPrefs.read(_rememberedAccessTokenKey); + final legacyRefresh = _authPrefs.read(_rememberedRefreshTokenKey); + + if ((legacyAccess == null || legacyAccess.isEmpty) && + (legacyRefresh == null || legacyRefresh.isEmpty)) { + return; + } + + if (rememberMe.value && legacyAccess != null && legacyAccess.isNotEmpty) { + try { + await _tokenService.ready; + await _tokenService.storeTokens( + accessToken: legacyAccess, + refreshToken: legacyRefresh, + ); + AppLogger.info( + 'Migrated legacy remember-me tokens to secure storage', + ); + } catch (e) { + // Fallback to StorageService if TokenService not ready yet + if (Get.isRegistered()) { + final storage = Get.find(); + await storage.saveTokens( + accessToken: legacyAccess, + refreshToken: legacyRefresh, + ); + } + } + } + } catch (e) { + AppLogger.warning('Failed to migrate legacy remember-me tokens: $e'); + } finally { + await _clearLegacyRememberedSession(); + } + } + + // Centralised helper that applies the user's remember-me choice post-login. + Future _syncRememberMeStateAfterLogin() async { + await _ensureRememberMePreferenceReady(); + if (rememberMe.value) { + await _persistRememberedSession(); + return; + } + await _authPrefs.write(_rememberMeFlagKey, false); + await _clearRememberedSession(); + } + + // Login with email or phone Future login({required String email, required String password}) async { try { isLoading.value = true; + _validation.clearErrors(); final emailValidation = _validation.validateEmailOrPhone(email); @@ -137,20 +293,29 @@ class AuthController extends BaseController { } UserModel user; + // Check if input is email or phone if (GetUtils.isEmail(email)) { user = await _authRepository.loginWithEmail( email: email, password: password, ); + if (Get.isRegistered()) { + Get.find().logLogin('email'); + } } else { user = await _authRepository.loginWithPhone( phone: email, password: password, ); + if (Get.isRegistered()) { + Get.find().logLogin('phone'); + } } - _userProfileController.setUser(user); - _sessionController.setAuthenticated(value: true); + currentUser.value = user; + isAuthenticated.value = true; + + // Tokens already persisted via TokenService in repository final displayName = user.name ?? user.firstName ?? user.email ?? user.phone ?? 'User'; @@ -159,8 +324,12 @@ class AuthController extends BaseController { message: 'Hello $displayName', ); - await _sessionController.syncRememberMeStateAfterLogin(); - unawaited(_userProfileController.fetchAndCacheProfile()); + await _syncRememberMeStateAfterLogin(); + + // Refresh full profile and cache for later prefilling + unawaited(fetchAndCacheProfile()); + + // Navigate to home await Get.offAllNamed(Routes.home); } on ApiException catch (e) { AppLogger.error('Login failed: ${e.message}', e); @@ -176,15 +345,33 @@ class AuthController extends BaseController { } } - /// Phone-based login (delegates to unified login path) + // Phone-based login (if backend supports phone on same endpoint) Future loginWithPhone({ required String phone, required String password, }) async { + // Delegate to the unified login path return login(email: phone, password: password); } - /// Phone signup via Supabase: sends OTP for first-time validation + // Fetch latest profile from API, update observable and cache for fast prefill + Future fetchAndCacheProfile() async { + try { + final profile = await _profileRepository.getProfile(); + currentUser.value = profile; + if (Get.isRegistered()) { + final storage = Get.find(); + await storage.saveUserData(profile.toMap()); + } + AppLogger.info('Profile refreshed for ${profile.email ?? profile.phone}'); + return profile; + } catch (e) { + AppLogger.warning('Failed to refresh user profile: $e'); + return null; + } + } + + // Phone signup via Supabase: sends OTP for first-time validation Future registerWithPhone({ required String phone, required String password, @@ -227,7 +414,8 @@ class AuthController extends BaseController { } } - /// Send forgot password OTP (stub until backend supports it) + // Backwards-compat: the current UI triggers an OTP flow for forgot password. + // Provide a graceful error until a backend endpoint is available. Future sendForgotPasswordOTP(String phone) async { final validation = _validation.validateEmailOrPhone(phone); if (validation != null) { @@ -241,7 +429,7 @@ class AuthController extends BaseController { return false; } - /// Reset password (stub until backend supports it) + // Backwards-compat stub, replace with real backend call when available Future resetPassword({ required String newPassword, required String confirmPassword, @@ -269,10 +457,13 @@ class AuthController extends BaseController { try { isLoading.value = true; await _authRepository.logout(); - await _sessionController.clearSession(); - await _userProfileController.clearUser(); + // Clear tokens from TokenService as well + await _tokenService.clearTokens(); + await setRememberMe(false); + currentUser.value = null; + isAuthenticated.value = false; - // Reset form/UI state + // Reset form/UI state before navigating so login screen starts enabled emailOrPhoneError.value = ''; passwordError.value = ''; confirmPasswordError.value = ''; @@ -283,7 +474,11 @@ class AuthController extends BaseController { title: 'Logged Out', message: 'You have been successfully logged out.', ); + if (Get.isRegistered()) { + Get.find().logLogout(); + } + // Navigate to login after local state is reset await Get.offAllNamed(Routes.login); } catch (e) { AppLogger.error('Logout failed', e); @@ -292,20 +487,22 @@ class AuthController extends BaseController { message: 'Failed to logout properly.', ); } finally { + // Ensure loading is not stuck true in any race condition isLoading.value = false; } } Future register({ - required String email, - required String password, - String? confirmPassword, String? name, String? firstName, String? lastName, + required String email, + required String password, + String? confirmPassword, }) async { try { isLoading.value = true; + _validation.clearErrors(); final emailValidation = _validation.validateEmailOrPhone(email); @@ -331,10 +528,11 @@ class AuthController extends BaseController { final computedName = () { final n = name; if (n != null && n.trim().isNotEmpty) return n.trim(); - final parts = [ - (firstName ?? '').trim(), - (lastName ?? '').trim(), - ]..removeWhere((value) => value.isEmpty); + final parts = []; + final fn = firstName; + final ln = lastName; + if ((fn ?? '').trim().isNotEmpty) parts.add(fn!.trim()); + if ((ln ?? '').trim().isNotEmpty) parts.add(ln!.trim()); if (parts.isNotEmpty) return parts.join(' '); // Fallback: use email username final at = email.indexOf('@'); @@ -346,8 +544,13 @@ class AuthController extends BaseController { email: email, password: password, ); - _userProfileController.setUser(user); - _sessionController.setAuthenticated(value: true); + currentUser.value = user; + isAuthenticated.value = true; + if (Get.isRegistered()) { + Get.find().logSignup('email'); + } + + // Tokens already persisted via TokenService in repository _showSuccessSnackbar( title: 'Welcome!', @@ -367,17 +570,28 @@ class AuthController extends BaseController { } } - /// Update the remember-me preference - Future setRememberMe({required bool value}) async { - await _sessionController.setRememberMe(value: value); + // Sync TokenService state from a provided Supabase session + Future _updateTokenServiceFromSession(Session session) async { + try { + final access = session.accessToken; + final refresh = session.refreshToken; + await _tokenService.storeTokens( + accessToken: access, + refreshToken: refresh, + ); + } catch (e) { + AppLogger.warning('Failed to sync TokenService from session: $e'); + } } - /// Fetch and cache user profile (delegates to UserProfileController) - Future fetchAndCacheProfile() async { - return _userProfileController.fetchAndCacheProfile(); + // Sync TokenService using the current Supabase session if available + Future _updateTokenServiceFromCurrentSession() async { + final session = Supabase.instance.client.auth.currentSession; + if (session != null) { + await _updateTokenServiceFromSession(session); + } } - /// Update user profile data (delegates to UserProfileController) Future updateUserProfileData({ String? firstName, String? lastName, @@ -388,36 +602,55 @@ class AuthController extends BaseController { String? avatarUrl, String? agentId, }) async { - return _userProfileController.updateProfile( - firstName: firstName, - lastName: lastName, - fullName: fullName, - bio: bio, - phone: phone, - dateOfBirth: dateOfBirth, - avatarUrl: avatarUrl, - agentId: agentId, - ); + try { + final updated = await _profileRepository.updateProfile( + firstName: firstName, + lastName: lastName, + fullName: fullName, + bio: bio, + phone: phone, + dateOfBirth: dateOfBirth, + avatarUrl: avatarUrl, + agentId: agentId, + ); + currentUser.value = updated; + return updated; + } catch (e, stack) { + AppLogger.error('Failed to update user profile', e, stack); + rethrow; + } } - /// Update user preferences (delegates to UserProfileController) Future updateUserPreferences( Map preferences, ) async { - return _userProfileController.updatePreferences(preferences); + try { + final updated = await _profileRepository.updatePreferences(preferences); + currentUser.value = updated; + return updated; + } catch (e, stack) { + AppLogger.error('Failed to update user preferences', e, stack); + rethrow; + } } - /// Update user location (delegates to UserProfileController) Future updateUserLocation({ required double latitude, required double longitude, bool shareLocation = true, }) async { - return _userProfileController.updateLocation( - latitude: latitude, - longitude: longitude, - shareLocation: shareLocation, - ); + try { + final updated = await _profileRepository.updateLocation( + latitude: latitude, + longitude: longitude, + shareLocation: shareLocation, + ); + currentUser.value = updated; + return updated; + } catch (e, stack) { + AppLogger.error('Failed to update user location', e, stack); + rethrow; + } } void _showSuccessSnackbar({required String title, required String message}) { diff --git a/lib/features/auth/controllers/form_validation_controller.dart b/lib/features/auth/controllers/form_validation_controller.dart index 19656d8..3647217 100644 --- a/lib/features/auth/controllers/form_validation_controller.dart +++ b/lib/features/auth/controllers/form_validation_controller.dart @@ -1,15 +1,15 @@ import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/utils/services/validation_service.dart'; /// Enhanced form validation controller using centralized ValidationService. /// Provides backward compatibility while leveraging improved validation. -class FormValidationController extends BaseController { +class FormValidationController extends GetxController { late final ValidationService _validationService; // Field keys for validation static const String emailOrPhoneKey = 'emailOrPhone'; + static const String loginPasswordKey = 'loginPassword'; static const String passwordKey = 'password'; static const String confirmPasswordKey = 'confirmPassword'; @@ -37,9 +37,13 @@ class FormValidationController extends BaseController { ValidationService.emailOrPhoneRequired, ); _validationService.registerValidator( - passwordKey, + loginPasswordKey, ValidationService.passwordRequired, ); + _validationService.registerValidator( + passwordKey, + ValidationService.passwordStrong, + ); _validatorsRegistered = true; } @@ -89,7 +93,9 @@ class FormValidationController extends BaseController { /// Validate complete login form bool validateLoginForm(String? emailOrPhone, String? password) { final emailValid = validateEmailOrPhone(emailOrPhone) == null; - final passwordValid = validatePassword(password) == null; + final passwordValid = + _validationService.validateField(loginPasswordKey, password); + passwordError.value = _validationService.getFieldError(loginPasswordKey); return emailValid && passwordValid; } @@ -121,6 +127,7 @@ class FormValidationController extends BaseController { case emailOrPhoneKey: emailOrPhoneError.value = ''; break; + case loginPasswordKey: case passwordKey: passwordError.value = ''; break; diff --git a/lib/features/auth/controllers/otp_controller.dart b/lib/features/auth/controllers/otp_controller.dart index 223ef49..a01ab0b 100644 --- a/lib/features/auth/controllers/otp_controller.dart +++ b/lib/features/auth/controllers/otp_controller.dart @@ -2,16 +2,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/routes/app_routes.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'auth_controller.dart'; enum OTPType { signup, forgotPassword } -class OTPController extends BaseController { +class OTPController extends GetxController { final AuthController _authController = Get.find(); - // OTP-specific error (separate from base errorMessage) + final RxBool isLoading = false.obs; final RxString otpError = ''.obs; final RxInt countdown = 30.obs; final RxBool canResend = false.obs; @@ -223,56 +223,10 @@ class OTPController extends BaseController { } void _showSuccessSnackbar(String message) { - Get.snackbar( - '', - '', - titleText: const Text( - 'Success', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - messageText: Text( - message, - style: const TextStyle(color: Colors.white70, fontSize: 14), - ), - backgroundColor: const Color(0xFF4CAF50).withValues(alpha: 0.9), - borderRadius: 12, - margin: const EdgeInsets.all(16), - duration: const Duration(seconds: 2), - snackPosition: SnackPosition.TOP, - icon: const Icon( - Icons.check_circle_outline, - color: Colors.white, - size: 20, - ), - ); + AppSnackbar.success(title: 'Success', message: message); } void _showErrorSnackbar(String message) { - Get.snackbar( - '', - '', - titleText: const Text( - 'Error', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - messageText: Text( - message, - style: const TextStyle(color: Colors.white70, fontSize: 14), - ), - backgroundColor: const Color(0xFFE91E63).withValues(alpha: 0.9), - borderRadius: 12, - margin: const EdgeInsets.all(16), - duration: const Duration(seconds: 2), - snackPosition: SnackPosition.TOP, - icon: const Icon(Icons.error_outline, color: Colors.white, size: 20), - ); + AppSnackbar.error(title: 'Error', message: message); } } diff --git a/lib/features/auth/controllers/user_profile_controller.dart b/lib/features/auth/controllers/user_profile_controller.dart index 5c9d324..81ed819 100644 --- a/lib/features/auth/controllers/user_profile_controller.dart +++ b/lib/features/auth/controllers/user_profile_controller.dart @@ -1,7 +1,6 @@ import 'package:get/get.dart'; import 'package:stays_app/app/data/models/user_model.dart'; -import 'package:stays_app/app/data/providers/users_provider.dart'; import 'package:stays_app/app/data/repositories/profile_repository.dart'; import 'package:stays_app/app/data/services/storage_service.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; @@ -10,11 +9,11 @@ import 'package:stays_app/app/controllers/base/base_controller.dart'; /// Controller responsible for user profile management. /// Handles profile fetching, updating, and caching. class UserProfileController extends BaseController { - final Rx currentUser = Rx(null); - - ProfileRepository? _profileRepository; + UserProfileController({required ProfileRepository profileRepository}) + : _profileRepository = profileRepository; - UserProfileController(); + final Rx currentUser = Rx(null); + final ProfileRepository _profileRepository; /// Get the current user's display name String get displayName => currentUser.value?.displayName ?? 'Guest'; @@ -31,8 +30,7 @@ class UserProfileController extends BaseController { /// Fetch latest profile from API, update observable and cache for fast prefill Future fetchAndCacheProfile() async { try { - final repo = _ensureProfileRepository(); - final profile = await repo.getProfile(); + final profile = await _profileRepository.getProfile(); currentUser.value = profile; await _cacheUserData(profile); AppLogger.info('Profile refreshed for ${profile.email ?? profile.phone}'); @@ -74,8 +72,7 @@ class UserProfileController extends BaseController { }) async { try { isLoading.value = true; - final repo = _ensureProfileRepository(); - final updated = await repo.updateProfile( + final updated = await _profileRepository.updateProfile( firstName: firstName, lastName: lastName, fullName: fullName, @@ -101,8 +98,7 @@ class UserProfileController extends BaseController { Future updatePreferences(Map preferences) async { try { isLoading.value = true; - final repo = _ensureProfileRepository(); - final updated = await repo.updatePreferences(preferences); + final updated = await _profileRepository.updatePreferences(preferences); currentUser.value = updated; await _cacheUserData(updated); return updated; @@ -123,8 +119,7 @@ class UserProfileController extends BaseController { }) async { try { isLoading.value = true; - final repo = _ensureProfileRepository(); - final updated = await repo.updateLocation( + final updated = await _profileRepository.updateLocation( latitude: latitude, longitude: longitude, shareLocation: shareLocation, @@ -158,22 +153,6 @@ class UserProfileController extends BaseController { currentUser.value = user; } - ProfileRepository _ensureProfileRepository() { - if (_profileRepository != null) { - return _profileRepository!; - } - if (Get.isRegistered()) { - _profileRepository = Get.find(); - return _profileRepository!; - } - if (!Get.isRegistered()) { - Get.put(UsersProvider()); - } - _profileRepository = ProfileRepository(provider: Get.find()); - Get.put(_profileRepository!); - return _profileRepository!; - } - Future _cacheUserData(UserModel user) async { try { if (Get.isRegistered()) { diff --git a/lib/features/auth/views/phone_login_view.dart b/lib/features/auth/views/phone_login_view.dart index bb08f04..dfc554a 100644 --- a/lib/features/auth/views/phone_login_view.dart +++ b/lib/features/auth/views/phone_login_view.dart @@ -81,21 +81,14 @@ class _PhoneLoginViewState extends State { Obx(() { final rememberSelection = controller.rememberMe.value; return InkWell( - onTap: () async { - await controller.setRememberMe( - value: !rememberSelection, - ); - }, + onTap: () => controller.setRememberMe(!rememberSelection), borderRadius: BorderRadius.circular(8), child: Row( children: [ Checkbox( value: rememberSelection, - onChanged: (value) async { - await controller.setRememberMe( - value: value ?? false, - ); - }, + onChanged: (value) => + controller.setRememberMe(value ?? false), activeColor: colors.primary, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -236,9 +229,9 @@ class _PhoneLoginViewState extends State { ), ), const SizedBox(height: 8), - DecoratedBox( + Container( decoration: BoxDecoration( - color: context.elevatedSurface(), + color: context.elevatedSurface(0.08), borderRadius: BorderRadius.circular(12), border: Border.all(color: colors.outlineVariant), ), @@ -340,9 +333,9 @@ class _PhoneLoginViewState extends State { final hasError = controller.passwordError.value.isNotEmpty; return Column( children: [ - DecoratedBox( + Container( decoration: BoxDecoration( - color: context.elevatedSurface(), + color: context.elevatedSurface(0.08), borderRadius: BorderRadius.circular(12), border: Border.all( color: hasError ? colors.error : colors.outlineVariant, @@ -403,8 +396,8 @@ class _PhoneLoginViewState extends State { ); } - Future _handleLogin() async { - await controller.loginWithPhone( + void _handleLogin() { + controller.loginWithPhone( phone: _phoneController.text.trim(), password: _passwordController.text, ); diff --git a/lib/features/explore/bindings/explore_binding.dart b/lib/features/explore/bindings/explore_binding.dart index d502b0f..dcd642c 100644 --- a/lib/features/explore/bindings/explore_binding.dart +++ b/lib/features/explore/bindings/explore_binding.dart @@ -11,24 +11,46 @@ import 'package:stays_app/app/controllers/favorites_controller.dart'; class ExploreBinding extends Bindings { @override void dependencies() { + // Register LocationService with fenix to persist across navigation if (!Get.isRegistered()) { Get.lazyPut(() => LocationService(), fenix: true); } - Get.lazyPut(() => PropertiesProvider()); - Get.lazyPut( - () => PropertiesRepository(provider: Get.find()), - ); - Get.lazyPut(() => SwipesProvider()); - Get.lazyPut( - () => WishlistRepository(provider: Get.find()), - ); + + // Register PropertiesProvider + if (!Get.isRegistered()) { + Get.lazyPut(() => PropertiesProvider()); + } + + // Register PropertiesRepository + if (!Get.isRegistered()) { + Get.lazyPut( + () => PropertiesRepository(provider: Get.find()), + ); + } + + // Register SwipesProvider + if (!Get.isRegistered()) { + Get.lazyPut(() => SwipesProvider()); + } + + // Register WishlistRepository + if (!Get.isRegistered()) { + Get.lazyPut( + () => WishlistRepository(provider: Get.find()), + ); + } + + // Register FilterController as permanent singleton if (!Get.isRegistered()) { Get.put(FilterController(), permanent: true); } + + // Register FavoritesController as permanent singleton BEFORE ExploreController if (!Get.isRegistered()) { Get.put(FavoritesController(), permanent: true); } + // Register ExploreController AFTER all dependencies are registered Get.lazyPut( () => ExploreController( locationService: Get.find(), diff --git a/lib/features/explore/controllers/explore_controller.dart b/lib/features/explore/controllers/explore_controller.dart index 8d043ec..7f3db82 100644 --- a/lib/features/explore/controllers/explore_controller.dart +++ b/lib/features/explore/controllers/explore_controller.dart @@ -9,15 +9,15 @@ import 'package:stays_app/app/data/repositories/wishlist_repository.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/app/utils/constants/app_constants.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/app/controllers/filter_controller.dart'; import 'package:stays_app/app/controllers/favorites_controller.dart'; import 'package:stays_app/app/controllers/base/base_controller.dart'; -import 'package:stays_app/app/utils/services/connectivity_service.dart'; +import 'package:stays_app/app/utils/helpers/haptic_helper.dart'; import 'package:stays_app/app/data/services/image_prefetch_service.dart'; -import 'package:stays_app/app/utils/mixins/favorite_toggle_mixin.dart'; +import 'package:stays_app/app/data/services/analytics_service.dart'; -class ExploreController extends BaseController - with ImagePrefetchMixin, FavoriteToggleMixin { +class ExploreController extends BaseController with ImagePrefetchMixin { final LocationService _locationService; final PropertiesRepository _propertiesRepository; final WishlistRepository _wishlistRepository; @@ -28,8 +28,6 @@ class ExploreController extends BaseController final RxBool isShowingCachedData = false.obs; UnifiedFilterModel _activeFilters = UnifiedFilterModel.empty; - Worker? _filterWorker; - Worker? _locationWorker; ExploreController({ required LocationService locationService, @@ -43,13 +41,6 @@ class ExploreController extends BaseController _filterController = filterController, _favoritesController = favoritesController; - // FavoriteToggleMixin requirements - @override - WishlistRepository? get wishlistRepository => _wishlistRepository; - - @override - FavoritesController get favoritesController => _favoritesController; - final RxList popularHomes = [].obs; final RxList nearbyHotels = [].obs; // This can be fetched by location @@ -59,6 +50,51 @@ class ExploreController extends BaseController : _locationService.locationName; String get nearbyCity => _selectedCityNormalized(); List get recommendedHotels => nearbyHotels.toList(); + + /// Returns the property with the minimum distance from the current location. + /// Considers both in-city and nearby properties. Returns null if no properties have distance data. + Property? get nearestProperty { + final allProperties = allExploreProperties; + if (allProperties.isEmpty) return null; + + Property? nearest; + double minDistance = double.infinity; + + for (final property in allProperties) { + final distance = property.distanceKm; + if (distance != null && distance < minDistance) { + minDistance = distance; + nearest = property; + } + } + + return nearest; + } + + /// Returns a time-based greeting message. + static String getTimeBasedGreeting() { + final hour = DateTime.now().hour; + if (hour < 12) { + return 'Good morning'; + } else if (hour < 17) { + return 'Good afternoon'; + } else { + return 'Good evening'; + } + } + + /// Returns popular properties in the selected city, excluding the featured (nearest) property. + List get popularInCity { + final nearest = nearestProperty; + return popularHomes.where((p) => p.id != nearest?.id).toList(); + } + + /// Returns nearby properties (from nearby cities), excluding the featured (nearest) property. + List get nearbyStays { + final nearest = nearestProperty; + return nearbyHotels.where((p) => p.id != nearest?.id).toList(); + } + List get allExploreProperties { final seen = {}; final combined = []; @@ -91,18 +127,16 @@ class ExploreController extends BaseController _locationService.clearSelectedLocation(); await _locationService.updateLocation(ensurePrecise: true); await loadProperties(); - Get.snackbar( - 'Location Updated', - 'Using your current location for nearby stays', - snackPosition: SnackPosition.TOP, + AppSnackbar.success( + title: 'Location Updated', + message: 'Using your current location for nearby stays', duration: const Duration(seconds: 2), ); } catch (e) { AppLogger.error('Failed to update location', e); - Get.snackbar( - 'Location', - 'Unable to get your location. Check permissions.', - snackPosition: SnackPosition.TOP, + AppSnackbar.warning( + title: 'Location', + message: 'Unable to get your location. Check permissions.', ); } finally { isLoading.value = false; @@ -114,9 +148,10 @@ class ExploreController extends BaseController // Set initial loading state isLoading.value = true; super.onInit(); + _logScreenView(); // _filterController is now injected via constructor _activeFilters = _filterController.filterFor(FilterScope.explore); - _filterWorker = trackWorker( + trackWorker( debounce( _filterController.rxFor(FilterScope.explore), (filters) async { @@ -129,17 +164,21 @@ class ExploreController extends BaseController ); _fetchInitialData(); // Reload properties when user selects a new location - _locationWorker = trackWorker( + trackWorker( ever(_locationService.locationNameRx, (_) { _reloadWithFilters(); }), ); } + void _logScreenView() { + if (Get.isRegistered()) { + Get.find().logScreenView('Explore'); + } + } + @override void onClose() { - _filterWorker?.dispose(); - _locationWorker?.dispose(); super.onClose(); } @@ -169,26 +208,98 @@ class ExploreController extends BaseController isLoading.value = true; errorMessage.value = ''; try { + AppLogger.info('ExploreController: Starting initial data fetch'); // Wait for location service to initialize before loading properties await _waitForLocationInitialization(); + AppLogger.info( + 'ExploreController: Location initialized - ' + 'lat: ${_locationService.latitude}, lng: ${_locationService.longitude}', + ); // Use Future.wait to run fetches in parallel for better performance await loadProperties(); - } catch (e) { - errorMessage.value = 'Failed to load data. Please pull to refresh.'; - AppLogger.error('Error fetching initial data', e); + AppLogger.info( + 'ExploreController: Properties loaded - ' + 'popular: ${popularHomes.length}, nearby: ${nearbyHotels.length}', + ); + } catch (e, s) { + AppLogger.error('ExploreController: Error fetching initial data', e, s); + errorMessage.value = _getUserFriendlyErrorMessage(e); } finally { isLoading.value = false; } } + /// Converts exceptions to user-friendly error messages + String _getUserFriendlyErrorMessage(dynamic error) { + final errorStr = error.toString().toLowerCase(); + + // Network connectivity issues + if (errorStr.contains('connection') || + errorStr.contains('socket') || + errorStr.contains('network')) { + return 'No internet connection. Please check your network and try again.'; + } + if (errorStr.contains('timeout') || errorStr.contains('deadline')) { + return 'Request timed out. Please try again.'; + } + if (errorStr.contains('host') || + errorStr.contains('lookup') || + errorStr.contains('unreachable')) { + return 'Cannot reach the server. Please check your connection.'; + } + + // Authentication issues + if (errorStr.contains('401') || errorStr.contains('unauthorized') || errorStr.contains('token')) { + return 'Session expired. Please log in again.'; + } + if (errorStr.contains('403') || errorStr.contains('forbidden')) { + return 'Access denied. Please log in again.'; + } + + // Server errors + if (errorStr.contains('500') || errorStr.contains('502') || errorStr.contains('503') || errorStr.contains('504')) { + return 'Server is temporarily unavailable. Please try again later.'; + } + + // Not found + if (errorStr.contains('404') || errorStr.contains('not found')) { + return 'Requested resource not found.'; + } + + // Log the actual error for debugging + AppLogger.warning('Unhandled error type: ${error.runtimeType} - $error'); + + // Generic error message + return 'Unable to load properties. Pull down to refresh.'; + } + Future loadProperties() async { - // Check connectivity first - final connectivityService = _getConnectivityService(); - final isOnline = connectivityService?.isOnline.value ?? true; + // Clear offline flags before attempting to load + isOffline.value = false; + isShowingCachedData.value = false; + + try { + // Fetch within a broader radius so nearby cities are included + final double radiusKm = _activeFilters.radiusKm ?? 100.0; + AppLogger.info( + 'ExploreController: Fetching properties - radius: $radiusKm, ' + 'lat: ${_locationService.latitude}, lng: ${_locationService.longitude}', + ); + + final resp = await _propertiesRepository.explore( + limit: 30, + radiusKm: radiusKm, + filters: _activeFilters.toQueryParameters(), + ); + + final props = resp.properties; + AppLogger.info('ExploreController: Received ${props.length} properties from API'); + _updatePropertiesFromResponse(props); + } catch (e, s) { + AppLogger.error('ExploreController: Error loading properties', e, s); + final friendlyError = _getUserFriendlyErrorMessage(e); - if (!isOnline) { - isOffline.value = true; - // Try to load cached data when offline + // Try to load cached data on error (could be network issue) final cached = _propertiesRepository.getOfflineExploreResults( lat: _locationService.latitude, lng: _locationService.longitude, @@ -197,36 +308,17 @@ class ExploreController extends BaseController isShowingCachedData.value = true; _updatePropertiesFromResponse(cached.properties); AppLogger.info( - 'Loaded ${cached.properties.length} cached properties (offline mode)', + 'Loaded ${cached.properties.length} cached properties due to error', ); + // Show cached data but also indicate there was an error + errorMessage.value = '$friendlyError Showing cached data.'; return; } - errorMessage.value = 'You are offline. Please check your connection.'; - AppLogger.warning('No cached data available for offline mode'); - return; - } - - // Online - clear offline flags - isOffline.value = false; - isShowingCachedData.value = false; - - // Fetch within a broader radius so nearby cities are included - final double radiusKm = _activeFilters.radiusKm ?? 100.0; - final resp = await _propertiesRepository.explore( - limit: 30, - radiusKm: radiusKm, - filters: _activeFilters.toQueryParameters(), - ); - final props = resp.properties; - _updatePropertiesFromResponse(props); - } - - ConnectivityService? _getConnectivityService() { - try { - return Get.find(); - } catch (_) { - return null; + // No cached data available, show the error + isOffline.value = friendlyError.contains('internet') || friendlyError.contains('connection'); + errorMessage.value = friendlyError; + return; } } @@ -271,10 +363,11 @@ class ExploreController extends BaseController isLoading.value = true; errorMessage.value = ''; try { + AppLogger.info('ExploreController: Reloading with filters'); await loadProperties(); - } catch (e) { - errorMessage.value = 'Unable to apply filters. Please pull to refresh.'; - AppLogger.error('Error applying explore filters', e); + } catch (e, s) { + AppLogger.error('ExploreController: Error applying explore filters', e, s); + errorMessage.value = _getUserFriendlyErrorMessage(e); } finally { isLoading.value = false; } @@ -326,39 +419,61 @@ class ExploreController extends BaseController prefetchDetailImages(property); } - Future toggleFavoriteProperty(Property property) async { + Future toggleFavorite(Property property) async { final propertyId = property.id; - final wasCurrentlyFavorite = isPropertyFavorite(propertyId); + final isCurrentlyFavorite = _favoritesController.isFavorite(propertyId); + unawaited(HapticHelper.favoriteToggle()); - final result = await toggleFavorite( - property, - onSuccess: () { - _updatePropertyFavoriteStatusInLists(propertyId, !wasCurrentlyFavorite); - }, - ); - - if (!result.success) { - AppLogger.error('Failed to toggle favorite: ${result.errorMessage}'); + try { + if (isCurrentlyFavorite) { + await _wishlistRepository.remove(propertyId); + _favoritesController.removeFavorite(propertyId); + if (Get.isRegistered()) { + Get.find().logWishlistRemoved('$propertyId'); + } + } else { + await _wishlistRepository.add(propertyId); + _favoritesController.addFavorite(propertyId); + if (Get.isRegistered()) { + Get.find().logWishlistAdded('$propertyId'); + } + } + _updatePropertyFavoriteStatusInLists(propertyId, !isCurrentlyFavorite); + AppSnackbar.success( + title: isCurrentlyFavorite ? 'Removed from Wishlist' : 'Added to Wishlist', + message: '${property.name} updated.', + ); + } catch (e) { + AppLogger.error('Error toggling favorite', e); + AppSnackbar.error( + title: 'Error', + message: 'Could not update wishlist. Please try again.', + ); } } void _updatePropertyFavoriteStatusInLists(int propertyId, bool isFavorite) { // This correctly updates the UI by creating a new instance of the Property - final index = popularHomes.indexWhere((p) => p.id == propertyId); + int index = popularHomes.indexWhere((p) => p.id == propertyId); if (index != -1) { popularHomes[index] = popularHomes[index].copyWith( isFavorite: isFavorite, ); } - // Update nearby hotels too - final hotelIndex = nearbyHotels.indexWhere((p) => p.id == propertyId); - if (hotelIndex != -1) { - nearbyHotels[hotelIndex] = nearbyHotels[hotelIndex].copyWith( + + // Update nearbyHotels list + index = nearbyHotels.indexWhere((p) => p.id == propertyId); + if (index != -1) { + nearbyHotels[index] = nearbyHotels[index].copyWith( isFavorite: isFavorite, ); } } + bool isPropertyFavorite(int propertyId) { + return _favoritesController.isFavorite(propertyId); + } + void navigateToAllProperties(String categoryType) { final lat = _locationService.latitude; final lng = _locationService.longitude; diff --git a/lib/features/explore/views/explore_view.dart b/lib/features/explore/views/explore_view.dart index bda3bfe..3357a74 100644 --- a/lib/features/explore/views/explore_view.dart +++ b/lib/features/explore/views/explore_view.dart @@ -2,14 +2,14 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:stays_app/features/explore/controllers/explore_controller.dart'; import 'package:stays_app/app/controllers/filter_controller.dart'; -import 'package:stays_app/app/data/models/property_model.dart'; -import 'package:stays_app/app/ui/widgets/cards/property_card.dart'; -import 'package:stays_app/app/ui/widgets/common/banner_carousel.dart'; +import 'package:stays_app/app/ui/theme/app_dimensions.dart'; +import 'package:stays_app/app/ui/theme/theme_extensions.dart'; +import 'package:stays_app/app/ui/widgets/common/explore_hero_header.dart'; import 'package:stays_app/app/ui/widgets/common/filter_button.dart'; +import 'package:stays_app/app/ui/widgets/common/property_horizontal_section.dart'; import 'package:stays_app/app/ui/widgets/common/search_bar_widget.dart'; import 'package:stays_app/app/ui/widgets/common/section_header.dart'; - -import 'package:stays_app/app/ui/theme/theme_extensions.dart'; +import 'package:stays_app/app/ui/widgets/cards/featured_property_card.dart'; class ExploreView extends GetView { const ExploreView({super.key}); @@ -30,9 +30,11 @@ class ExploreView extends GetView { _buildSliverAppBar(context), _buildActiveFilters(context), _buildOfflineBanner(context), - _buildBannerSection(), - _buildPropertiesSection(context), - const SliverToBoxAdapter(child: SizedBox(height: 100)), + _buildHeroGreeting(context), + _buildFeaturedSection(context), + _buildPopularInSection(context), + _buildNearbySection(context), + const SliverToBoxAdapter(child: SizedBox(height: 56)), ], ), ), @@ -149,22 +151,6 @@ class ExploreView extends GetView { ); } - Widget _buildBannerSection() { - const bannerUrls = [ - 'https://images.unsplash.com/photo-1505691723518-36a5ac3be353?auto=format&fit=crop&w=1600&q=80', - 'https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?auto=format&fit=crop&w=1600&q=80', - 'https://images.unsplash.com/photo-1542314831-068cd1dbfeeb?auto=format&fit=crop&w=1600&q=80', - 'https://images.unsplash.com/photo-1512453979798-5ea266f8880c?auto=format&fit=crop&w=1600&q=80', - ]; - - return const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB(16, 12, 16, 8), - child: BannerCarousel(imageUrls: bannerUrls, aspectRatio: 16 / 6), - ), - ); - } - Widget _buildOfflineBanner(BuildContext context) { return SliverToBoxAdapter( child: Obx(() { @@ -214,66 +200,48 @@ class ExploreView extends GetView { ); } - Widget _buildPropertiesSection(BuildContext context) { + Widget _buildHeroGreeting(BuildContext context) { + return const SliverToBoxAdapter( + child: ExploreHeroHeader(), + ); + } + + Widget _buildFeaturedSection(BuildContext context) { return SliverToBoxAdapter( child: Obx(() { - final city = controller.locationName; final isLoading = controller.isLoading.value; - final properties = controller.allExploreProperties; - final textStyles = context.textStyles; - final colors = context.colors; + final nearest = controller.nearestProperty; final errorMsg = controller.errorMessage.value; + final colors = context.colors; - // Show error state for offline with no data - if (errorMsg.isNotEmpty && properties.isEmpty && !isLoading) { - return Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.wifi_off, size: 64, color: colors.outline), - const SizedBox(height: 16), - Text( - errorMsg, - style: textStyles.bodyLarge?.copyWith( - color: colors.onSurface.withValues(alpha: 0.7), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - TextButton.icon( - onPressed: controller.refreshData, - icon: const Icon(Icons.refresh), - label: const Text('Try Again'), - ), - ], - ), - ); + // Show error state when no properties and error exists + if (errorMsg.isNotEmpty && nearest == null && !isLoading) { + return _buildErrorSection(context, errorMsg, colors); } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 250), + return Padding( + padding: const EdgeInsets.only(top: AppDimensions.exploreSectionSpacing), child: Column( - key: ValueKey('all-$city-${properties.length}'), crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 24), SectionHeader( - title: 'explore.popular_stays'.trParams({'city': city}), - onViewAll: () => controller.navigateToAllProperties(city), - titleStyle: textStyles.titleMedium?.copyWith( - fontSize: 16, - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - const SizedBox(height: 16), - SizedBox( - height: 200, - child: isLoading - ? _buildShimmerList() - : _buildHotelsList(properties, 'all'), + title: 'Featured near you', + subtitle: 'Closest stay based on your location', + leadingIcon: Icons.near_me_rounded, ), + const SizedBox(height: 12), + if (isLoading) + const FeaturedPropertyStripShimmer() + else if (nearest != null) + FeaturedPropertyStrip( + property: nearest, + heroPrefix: 'featured_strip', + isFavorite: controller.isPropertyFavorite(nearest.id), + onTap: () => controller.navigateToPropertyDetail(nearest), + onFavoriteToggle: () => controller.toggleFavorite(nearest), + ) + else if (errorMsg.isEmpty) + _buildEmptyState(context, 'No featured stays found nearby', Icons.near_me_rounded, colors), ], ), ); @@ -281,55 +249,169 @@ class ExploreView extends GetView { ); } - Widget _buildHotelsList(List hotels, String heroPrefix) { - if (hotels.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.hotel_outlined, size: 48, color: Colors.grey[400]), - const SizedBox(height: 16), - Text( - 'explore.no_results'.tr, - style: TextStyle(color: Colors.grey[600], fontSize: 16), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } + Widget _buildPopularInSection(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.only( + top: AppDimensions.exploreSectionSpacing, + ), + sliver: SliverToBoxAdapter( + child: Obx(() { + final city = controller.locationName; + final isLoading = controller.isLoading.value; + final properties = controller.popularInCity; + final colors = context.colors; + final textStyles = context.textStyles; + final titleStyle = textStyles.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ); + final subtitleStyle = textStyles.labelMedium?.copyWith( + color: colors.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ); - return ListView.builder( - key: ValueKey('${heroPrefix}_list_${hotels.length}'), - padding: const EdgeInsets.symmetric(horizontal: 20), - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - itemCount: hotels.length, - itemBuilder: (context, index) { - final property = hotels[index]; - return RepaintBoundary( - child: PropertyCard( - property: property, - heroPrefix: '${heroPrefix}_$index', - isFavorite: controller.isPropertyFavorite(property.id), - onTap: () => controller.navigateToPropertyDetail(property), - onFavoriteToggle: () => controller.toggleFavoriteProperty(property), + final locationLabel = city.isEmpty ? 'this area' : city; + return PropertyHorizontalSection( + title: 'Popular stay in $locationLabel', + leadingIcon: Icons.local_fire_department_rounded, + titleStyle: titleStyle, + subtitleStyle: subtitleStyle, + properties: properties, + isLoading: isLoading && properties.isEmpty, + sectionPrefix: 'popular', + cardHeight: 230, + cardWidth: 220, + onViewAll: () => controller.navigateToAllProperties(city), + onPropertyTap: (property) => controller.navigateToPropertyDetail(property), + onFavoriteToggle: (property) => controller.toggleFavorite(property), + isPropertyFavorite: (id) => controller.isPropertyFavorite(id), + emptyMessage: 'No popular stays found in $city', + ); + }), + ), + ); + } + + Widget _buildNearbySection(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.only( + top: AppDimensions.exploreSectionSpacing, + ), + sliver: SliverToBoxAdapter( + child: Obx(() { + final isLoading = controller.isLoading.value; + final properties = controller.nearbyStays; + final city = controller.locationName; + final colors = context.colors; + final textStyles = context.textStyles; + final titleStyle = textStyles.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ); + final subtitleStyle = textStyles.labelMedium?.copyWith( + color: colors.onSurface.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ); + + // Don't show this section if there are no nearby properties + if (properties.isEmpty && !isLoading) { + return const SizedBox.shrink(); + } + + final locationLabel = city.isEmpty ? 'this area' : city; + return PropertyHorizontalSection( + title: 'Nearby stay in $locationLabel', + leadingIcon: Icons.place_outlined, + titleStyle: titleStyle, + subtitleStyle: subtitleStyle, + properties: properties, + isLoading: isLoading, + sectionPrefix: 'nearby', + cardHeight: 230, + cardWidth: 220, + onPropertyTap: (property) => controller.navigateToPropertyDetail(property), + onFavoriteToggle: (property) => controller.toggleFavorite(property), + isPropertyFavorite: (id) => controller.isPropertyFavorite(id), + emptyMessage: 'No nearby stays found', + ); + }), + ), + ); + } + + /// Builds an error section with retry button + Widget _buildErrorSection(BuildContext context, String errorMsg, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.errorContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colors.error.withValues(alpha: 0.3), ), - ); - }, + ), + child: Column( + children: [ + Icon( + Icons.error_outline_rounded, + size: 48, + color: colors.error, + ), + const SizedBox(height: 12), + Text( + 'Unable to load properties', + style: context.textStyles.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + errorMsg, + style: context.textStyles.bodyMedium?.copyWith( + color: colors.onSurface.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: controller.refreshData, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + style: FilledButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + ), + ), + ], + ), + ), ); } - Widget _buildShimmerList() { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 20), - scrollDirection: Axis.horizontal, - physics: const NeverScrollableScrollPhysics(), - itemCount: 3, - itemBuilder: (context, index) => const PropertyCardShimmer(), + /// Builds an empty state when no properties are found + Widget _buildEmptyState(BuildContext context, String message, IconData icon, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + Icon( + icon, + size: 56, + color: colors.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 12), + Text( + message, + style: context.textStyles.bodyMedium?.copyWith( + color: colors.onSurface.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + ), ); } } diff --git a/lib/features/home/bindings/home_binding.dart b/lib/features/home/bindings/home_binding.dart index 0a28cc9..90362d7 100644 --- a/lib/features/home/bindings/home_binding.dart +++ b/lib/features/home/bindings/home_binding.dart @@ -1,29 +1,100 @@ import 'package:get/get.dart'; -import 'package:stays_app/features/auth/bindings/auth_binding.dart'; -import 'package:stays_app/features/explore/bindings/explore_binding.dart'; import 'package:stays_app/features/messaging/bindings/message_binding.dart'; import 'package:stays_app/features/trips/bindings/trips_binding.dart'; import 'package:stays_app/features/wishlist/bindings/wishlist_binding.dart'; import 'package:stays_app/features/profile/bindings/profile_binding.dart' as profile_binding; +import 'package:stays_app/features/auth/controllers/auth_controller.dart'; +import 'package:stays_app/features/explore/controllers/explore_controller.dart'; +import 'package:stays_app/app/controllers/filter_controller.dart'; +import 'package:stays_app/app/controllers/favorites_controller.dart'; import 'package:stays_app/features/listing/controllers/location_search_controller.dart'; import 'package:stays_app/features/listing/controllers/listing_controller.dart'; import 'package:stays_app/features/home/controllers/navigation_controller.dart'; +import 'package:stays_app/app/data/providers/properties_provider.dart'; +import 'package:stays_app/app/data/providers/swipes_provider.dart'; +import 'package:stays_app/app/data/providers/users_provider.dart'; +import 'package:stays_app/app/data/repositories/auth_repository.dart'; +import 'package:stays_app/app/data/providers/auth/i_auth_provider.dart'; +import 'package:stays_app/app/data/providers/supabase_auth_provider.dart'; +import 'package:stays_app/app/data/repositories/profile_repository.dart'; import 'package:stays_app/app/data/repositories/properties_repository.dart'; +import 'package:stays_app/app/data/repositories/wishlist_repository.dart'; +import 'package:stays_app/app/data/services/location_service.dart'; +import 'package:stays_app/app/utils/services/token_service.dart'; /// HomeBinding registers all dependencies needed for the home shell view. -/// It delegates to specialized bindings for each feature domain. class HomeBinding extends Bindings { @override void dependencies() { - // Delegate to specialized bindings for auth and core dependencies - AuthBinding().dependencies(); + if (!Get.isRegistered()) { + Get.put(SupabaseAuthProvider(), permanent: true); + } + if (!Get.isRegistered()) { + Get.put( + AuthRepository(provider: Get.find()), + permanent: true, + ); + } - // Delegate to explore binding for explore-related dependencies - ExploreBinding().dependencies(); + // ============================================ + // PROVIDERS (must come before repositories) + // ============================================ + if (!Get.isRegistered()) { + Get.lazyPut(() => PropertiesProvider(), fenix: true); + } + + if (!Get.isRegistered()) { + Get.lazyPut(() => SwipesProvider(), fenix: true); + } + + if (!Get.isRegistered()) { + Get.lazyPut(() => UsersProvider(), fenix: true); + } + + // ============================================ + // REPOSITORIES (must come before controllers) + // ============================================ + if (!Get.isRegistered()) { + Get.lazyPut( + () => PropertiesRepository(provider: Get.find()), + fenix: true, + ); + } + + if (!Get.isRegistered()) { + Get.lazyPut( + () => WishlistRepository(provider: Get.find()), + fenix: true, + ); + } + + if (!Get.isRegistered()) { + Get.lazyPut( + () => ProfileRepository(provider: Get.find()), + fenix: true, + ); + } + + if (!Get.isRegistered()) { + Get.put( + AuthController( + authRepository: Get.find(), + tokenService: Get.find(), + profileRepository: Get.find(), + ), + permanent: true, + ); + } + + // ============================================ + // SERVICES & CONTROLLERS + // ============================================ + if (!Get.isRegistered()) { + Get.lazyPut(() => LocationService(), fenix: true); + } - // Navigation controller for bottom navigation if (!Get.isRegistered()) { Get.lazyPut( () => NavigationController(), @@ -31,7 +102,27 @@ class HomeBinding extends Bindings { ); } - // Listing controllers + if (!Get.isRegistered()) { + Get.put(FilterController(), permanent: true); + } + + if (!Get.isRegistered()) { + Get.put(FavoritesController(), permanent: true); + } + + if (!Get.isRegistered()) { + Get.lazyPut( + () => ExploreController( + locationService: Get.find(), + propertiesRepository: Get.find(), + wishlistRepository: Get.find(), + filterController: Get.find(), + favoritesController: Get.find(), + ), + fenix: true, + ); + } + if (!Get.isRegistered()) { Get.lazyPut( () => ListingController(repository: Get.find()), @@ -46,7 +137,6 @@ class HomeBinding extends Bindings { ); } - // Delegate to feature bindings WishlistBinding().dependencies(); TripsBinding().dependencies(); MessageBinding().dependencies(); diff --git a/lib/features/home/controllers/navigation_controller.dart b/lib/features/home/controllers/navigation_controller.dart index eda6607..cec0e34 100644 --- a/lib/features/home/controllers/navigation_controller.dart +++ b/lib/features/home/controllers/navigation_controller.dart @@ -3,11 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/routes/app_routes.dart'; import 'package:stays_app/app/utils/helpers/haptic_helper.dart'; -class NavigationController extends BaseController { +class NavigationController extends GetxController { // Default to the Home/Explore tab (index 0) final RxInt currentIndex = 0.obs; final PageController pageController = PageController(initialPage: 0); diff --git a/lib/features/home/views/simple_home_view.dart b/lib/features/home/views/simple_home_view.dart index 4f2de70..b6306e4 100644 --- a/lib/features/home/views/simple_home_view.dart +++ b/lib/features/home/views/simple_home_view.dart @@ -1,6 +1,8 @@ +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:stays_app/app/ui/theme/app_animations.dart'; import 'package:stays_app/features/messaging/controllers/hotels_map_controller.dart'; import 'package:stays_app/features/home/controllers/navigation_controller.dart'; import 'package:stays_app/features/messaging/views/locate_view.dart'; @@ -29,11 +31,11 @@ class _SimpleHomeViewState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final shadowColor = theme.brightness == Brightness.dark - ? Colors.black.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.05); + final isDark = theme.brightness == Brightness.dark; + return Scaffold( backgroundColor: colorScheme.surface, + extendBody: true, body: PageView.builder( controller: controller.pageController, itemCount: 5, @@ -54,37 +56,95 @@ class _SimpleHomeViewState extends State { }; }, ), - bottomNavigationBar: Container( - decoration: BoxDecoration( - color: colorScheme.surface, - boxShadow: [ - BoxShadow( - color: shadowColor, - blurRadius: 10, - offset: const Offset(0, -2), - ), + bottomNavigationBar: _PremiumBottomNav( + controller: controller, + isDark: isDark, + ), + ); + } +} + +/// Premium bottom navigation bar with glassmorphism and fluid animations. +class _PremiumBottomNav extends StatelessWidget { + final NavigationController controller; + final bool isDark; + + const _PremiumBottomNav({ + required this.controller, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + height: 72, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.surface.withValues(alpha: 0.0), + if (isDark) + colorScheme.surface.withValues(alpha: 0.9) + else + colorScheme.surface.withValues(alpha: 0.95), + colorScheme.surface, ], + stops: const [0.0, 0.3, 1.0], ), - child: SafeArea( - child: Container( - height: 60, - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Obx( - () => Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: controller.tabs.asMap().entries.map((entry) { - final index = entry.key; - final tab = entry.value; - final isActive = controller.currentIndex.value == index; - return Expanded( - child: _NavItem( - icon: tab.icon, - labelKey: tab.labelKey, - isActive: isActive, - onTap: () => controller.changeTab(index), - ), - ); - }).toList(), + ), + child: SafeArea( + top: false, + child: Container( + height: 56, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: isDark ? 0.92 : 0.96), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: isDark ? 0.35 : 0.5), + width: 1.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.08), + blurRadius: 20, + offset: const Offset(0, 10), + spreadRadius: -5, + ), + if (!isDark) + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.05), + blurRadius: 20, + offset: const Offset(0, -5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Obx( + () => Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: controller.tabs.asMap().entries.map((entry) { + final index = entry.key; + final tab = entry.value; + final isActive = controller.currentIndex.value == index; + return Expanded( + child: _PremiumNavItem( + icon: tab.icon, + labelKey: tab.labelKey, + isActive: isActive, + onTap: () => controller.changeTab(index), + isFirst: index == 0, + isLast: index == controller.tabs.length - 1, + ), + ); + }).toList(), + ), ), ), ), @@ -94,46 +154,215 @@ class _SimpleHomeViewState extends State { } } -class _NavItem extends StatelessWidget { - const _NavItem({ +class _PremiumNavItem extends StatefulWidget { + const _PremiumNavItem({ required this.icon, required this.labelKey, required this.isActive, required this.onTap, + this.isFirst = false, + this.isLast = false, }); final IconData icon; final String labelKey; final bool isActive; final VoidCallback onTap; + final bool isFirst; + final bool isLast; + + @override + State<_PremiumNavItem> createState() => _PremiumNavItemState(); +} + +class _PremiumNavItemState extends State<_PremiumNavItem> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _iconScaleAnimation; + late Animation _textFadeAnimation; + late Animation _indicatorAnimation; + late Animation _glowAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: AppAnimations.normal, + vsync: this, + ); + + // Premium icon bounce with overshoot + _iconScaleAnimation = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.85), + weight: 25, + ), + TweenSequenceItem( + tween: Tween(begin: 0.85, end: 1.08), + weight: 35, + ), + TweenSequenceItem( + tween: Tween(begin: 1.08, end: 1.0), + weight: 40, + ), + ]).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + + // Text fade in with delay + _textFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.3, 1.0, curve: Curves.easeOut), + ), + ); + + // Active indicator expansion + _indicatorAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.6, curve: Curves.easeOutCubic), + ), + ); + + // Glow pulse for active state + _glowAnimation = Tween(begin: 0.5, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.4, 1.0, curve: Curves.easeOut), + ), + ); + + if (widget.isActive) { + _controller.value = 1.0; + } + } + + @override + void didUpdateWidget(_PremiumNavItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isActive != oldWidget.isActive) { + if (widget.isActive) { + _controller.forward(from: 0); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final activeColor = colorScheme.primary; - final inactiveColor = colorScheme.onSurface.withValues(alpha: 0.6); - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: isActive ? activeColor : inactiveColor, size: 22), - const SizedBox(height: 2), - Text( - labelKey.tr, - style: TextStyle( - fontSize: 10, - fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, - color: isActive ? activeColor : inactiveColor, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ], + final inactiveColor = colorScheme.onSurface.withValues(alpha: 0.55); + const indicatorWidth = 46.0; + const indicatorHeight = 28.0; + const labelSpacing = 4.0; + const iconSize = 22.0; + + return GestureDetector( + onTap: widget.onTap, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final iconColor = Color.lerp( + inactiveColor, + activeColor, + _controller.value, + ) ?? + activeColor; + final labelColor = Color.lerp( + inactiveColor, + activeColor, + _textFadeAnimation.value, + ) ?? + activeColor; + final labelOpacity = + lerpDouble(0.65, 1.0, _textFadeAnimation.value) ?? 1.0; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: indicatorHeight, + child: Stack( + alignment: Alignment.center, + children: [ + // Active indicator background with glow + if (widget.isActive) + Transform.scale( + scale: _indicatorAnimation.value, + child: Container( + width: indicatorWidth, + height: indicatorHeight, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + activeColor + .withValues(alpha: 0.15 * _glowAnimation.value), + activeColor + .withValues(alpha: 0.08 * _glowAnimation.value), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: activeColor + .withValues(alpha: 0.1 * _glowAnimation.value), + width: 1, + ), + ), + ), + ), + + // Icon centered within the indicator + Transform.scale( + scale: _iconScaleAnimation.value, + child: Icon( + widget.icon, + color: iconColor, + size: iconSize, + ), + ), + ], + ), + ), + const SizedBox(height: labelSpacing), + Opacity( + opacity: labelOpacity, + child: Transform.translate( + offset: Offset(0, 2 * (1 - _textFadeAnimation.value)), + child: Text( + widget.labelKey.tr, + style: TextStyle( + fontSize: 10, + fontWeight: + widget.isActive ? FontWeight.w600 : FontWeight.w500, + color: labelColor, + letterSpacing: 0.3, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ], + ); + }, + ), ), ), ); diff --git a/lib/features/inquiry/bindings/inquiry_binding.dart b/lib/features/inquiry/bindings/inquiry_binding.dart index f5eadb8..f0186b9 100644 --- a/lib/features/inquiry/bindings/inquiry_binding.dart +++ b/lib/features/inquiry/bindings/inquiry_binding.dart @@ -7,11 +7,12 @@ import 'package:stays_app/features/trips/controllers/trips_controller.dart'; import 'package:stays_app/features/trips/bindings/trips_binding.dart'; import 'package:stays_app/app/data/providers/bookings_provider.dart'; import 'package:stays_app/features/auth/controllers/auth_controller.dart'; -import 'package:stays_app/features/auth/controllers/session_controller.dart'; import 'package:stays_app/app/data/repositories/auth_repository.dart'; import 'package:stays_app/app/data/providers/auth/i_auth_provider.dart'; import 'package:stays_app/app/data/providers/supabase_auth_provider.dart'; import 'package:stays_app/app/utils/services/token_service.dart'; +import 'package:stays_app/app/data/repositories/profile_repository.dart'; +import 'package:stays_app/app/data/providers/users_provider.dart'; class InquiryBinding extends Bindings { @override @@ -26,20 +27,21 @@ class InquiryBinding extends Bindings { permanent: true, ); } - if (!Get.isRegistered()) { - Get.put(TokenService(), permanent: true); + if (!Get.isRegistered()) { + Get.lazyPut(() => UsersProvider(), fenix: true); } - if (!Get.isRegistered()) { - Get.put( - SessionController(tokenService: Get.find()), - permanent: true, + if (!Get.isRegistered()) { + Get.lazyPut( + () => ProfileRepository(provider: Get.find()), + fenix: true, ); } if (!Get.isRegistered()) { Get.put( AuthController( authRepository: Get.find(), - sessionController: Get.find(), + tokenService: Get.find(), + profileRepository: Get.find(), ), permanent: true, ); diff --git a/lib/features/inquiry/controllers/inquiry_confirmation_controller.dart b/lib/features/inquiry/controllers/inquiry_confirmation_controller.dart index 00366be..fd6f8df 100644 --- a/lib/features/inquiry/controllers/inquiry_confirmation_controller.dart +++ b/lib/features/inquiry/controllers/inquiry_confirmation_controller.dart @@ -2,11 +2,11 @@ import 'dart:math' as math; import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/data/models/property_model.dart'; import 'package:stays_app/app/routes/app_routes.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; -class InquiryConfirmationController extends BaseController { +class InquiryConfirmationController extends GetxController { InquiryConfirmationController(); final Rxn property = Rxn(); @@ -36,10 +36,9 @@ class InquiryConfirmationController extends BaseController { if (property.value == null) { Future.microtask(() { - Get.snackbar( - 'Inquiry unavailable', - 'We could not load the property details. Please try again.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Inquiry unavailable', + message: 'We could not load the property details. Please try again.', ); }); } @@ -64,10 +63,9 @@ class InquiryConfirmationController extends BaseController { Future submitInquiry() async { final selectedProperty = property.value; if (selectedProperty == null) { - Get.snackbar( - 'Inquiry unavailable', - 'No property was provided for this inquiry.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Inquiry unavailable', + message: 'No property was provided for this inquiry.', ); return; } diff --git a/lib/features/inquiry/controllers/inquiry_controller.dart b/lib/features/inquiry/controllers/inquiry_controller.dart index 70bdaef..241121d 100644 --- a/lib/features/inquiry/controllers/inquiry_controller.dart +++ b/lib/features/inquiry/controllers/inquiry_controller.dart @@ -1,21 +1,19 @@ import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/data/models/booking_model.dart'; import 'package:stays_app/app/data/models/booking_pricing_model.dart'; import 'package:stays_app/app/data/repositories/booking_repository.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/app/routes/app_routes.dart'; -class InquiryController extends BaseController { +class InquiryController extends GetxController { + final BookingRepository _repository; InquiryController({required BookingRepository repository}) : _repository = repository; - final BookingRepository _repository; - final RxBool isSubmitting = false.obs; final RxString statusMessage = ''.obs; - // Note: errorMessage is inherited from BaseController + final RxString errorMessage = ''.obs; final Rxn latestBooking = Rxn(); Future createBooking(Map payload) async { @@ -90,14 +88,14 @@ class InquiryController extends BaseController { }); } - double? sanitizeAmount(double? value) { + double? _sanitizeAmount(double? value) { if (value == null) return null; if (value.isNaN || value.isInfinite) return null; return value; } - double resolveRequiredAmount(String key, double? primary) { - final sanitized = sanitizeAmount(primary); + double _resolveRequiredAmount(String key, double? primary) { + final sanitized = _sanitizeAmount(primary); if (sanitized != null) return sanitized; final fallbackValue = fallbackPricing?[key]; if (fallbackValue != null) { @@ -114,8 +112,8 @@ class InquiryController extends BaseController { return 0.0; } - double? resolveOptionalAmount(String key, double? primary) { - final sanitized = sanitizeAmount(primary); + double? _resolveOptionalAmount(String key, double? primary) { + final sanitized = _sanitizeAmount(primary); if (sanitized != null) return sanitized; final hasFallback = fallbackPricing?.containsKey(key) ?? false; if (hasFallback) { @@ -129,23 +127,23 @@ class InquiryController extends BaseController { return null; } - final baseAmount = resolveRequiredAmount( + final baseAmount = _resolveRequiredAmount( 'base_amount', pricingModel?.baseAmount, ); - final taxesAmount = resolveRequiredAmount( + final taxesAmount = _resolveRequiredAmount( 'taxes_amount', pricingModel?.taxesAmount, ); - final serviceCharges = resolveRequiredAmount( + final serviceCharges = _resolveRequiredAmount( 'service_charges', pricingModel?.serviceCharges, ); - final totalAmount = resolveRequiredAmount( + final totalAmount = _resolveRequiredAmount( 'total_amount', pricingModel?.totalAmount, ); - final discountAmount = resolveOptionalAmount( + final discountAmount = _resolveOptionalAmount( 'discount_amount', pricingModel?.discountAmount, ); diff --git a/lib/features/inquiry/views/inquiry_confirmation_view.dart b/lib/features/inquiry/views/inquiry_confirmation_view.dart index 8979610..a5ea827 100644 --- a/lib/features/inquiry/views/inquiry_confirmation_view.dart +++ b/lib/features/inquiry/views/inquiry_confirmation_view.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import 'package:stays_app/features/inquiry/controllers/inquiry_confirmation_controller.dart'; import 'package:stays_app/app/data/models/property_model.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/app/utils/helpers/currency_helper.dart'; class InquiryConfirmationView extends GetView { @@ -477,10 +478,9 @@ class InquiryConfirmationView extends GetView { : earliestCheckout; final lastDate = controller.maxSelectableDate; if (firstDate.isAfter(lastDate)) { - Get.snackbar( - 'Unavailable', - 'Please choose an earlier check-in date to extend your stay.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Unavailable', + message: 'Please choose an earlier check-in date to extend your stay.', ); return; } diff --git a/lib/features/inquiry/views/inquiry_view.dart b/lib/features/inquiry/views/inquiry_view.dart index 615432f..835e5e0 100644 --- a/lib/features/inquiry/views/inquiry_view.dart +++ b/lib/features/inquiry/views/inquiry_view.dart @@ -12,6 +12,7 @@ import 'package:stays_app/app/data/models/user_model.dart'; import 'package:stays_app/app/routes/app_routes.dart'; import 'package:stays_app/app/utils/helpers/currency_helper.dart'; import 'package:stays_app/app/data/services/storage_service.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; class InquiryView extends StatefulWidget { const InquiryView({super.key}); @@ -259,42 +260,42 @@ class _InquiryViewState extends State { return; } if (property == null) { - Get.snackbar('Missing property', 'Unable to identify this listing.'); + AppSnackbar.warning(title: 'Missing property', message: 'Unable to identify this listing.'); return; } if (checkInDate == null || checkOutDate == null || nights <= 0) { - Get.snackbar( - 'Select dates', - 'Please choose valid check-in and check-out dates.', + AppSnackbar.warning( + title: 'Select dates', + message: 'Please choose valid check-in and check-out dates.', ); return; } if (guests <= 0) { - Get.snackbar('Guests', 'Please select at least one guest.'); + AppSnackbar.warning(title: 'Guests', message: 'Please select at least one guest.'); return; } if (nameController.text.trim().isEmpty) { - Get.snackbar('Guest name', 'Please provide the primary guest name.'); + AppSnackbar.warning(title: 'Guest name', message: 'Please provide the primary guest name.'); return; } final trimmedEmail = emailController.text.trim(); if (trimmedEmail.isEmpty) { - Get.snackbar('Email', 'Please provide a valid email address.'); + AppSnackbar.warning(title: 'Email', message: 'Please provide a valid email address.'); return; } if (!GetUtils.isEmail(trimmedEmail)) { - Get.snackbar('Email', 'Please enter a valid email address.'); + AppSnackbar.warning(title: 'Email', message: 'Please enter a valid email address.'); return; } final trimmedPhone = phoneController.text.trim(); if (trimmedPhone.isEmpty) { - Get.snackbar('Phone', 'Please provide a valid phone number.'); + AppSnackbar.warning(title: 'Phone', message: 'Please provide a valid phone number.'); return; } final digits = trimmedPhone.replaceAll(RegExp(r'[^0-9+]'), ''); if (digits.replaceAll('+', '').length != 10) { - Get.snackbar('Phone', 'Please enter a valid phone number.'); + AppSnackbar.warning(title: 'Phone', message: 'Please enter a valid phone number.'); return; } final sanitizedPhone = _normalizePhoneForDisplay(trimmedPhone); @@ -342,12 +343,9 @@ class _InquiryViewState extends State { if (tripsController != null) { await tripsController!.loadPastBookings(forceRefresh: true); } - Get.snackbar( - 'Inquiry Sent Successfully', - 'We have recorded your inquiry for ${property!.name}.', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.green[100], - colorText: Colors.green[800], + AppSnackbar.success( + title: 'Inquiry Sent Successfully', + message: 'We have recorded your inquiry for ${property!.name}.', ); Get.offAllNamed(Routes.home, arguments: 0); } else { @@ -358,12 +356,9 @@ class _InquiryViewState extends State { final truncatedError = errorMessage.length > 100 ? '${errorMessage.substring(0, 97)}...' : errorMessage; - Get.snackbar( - 'Inquiry failed', - truncatedError, - snackPosition: SnackPosition.BOTTOM, - margin: const EdgeInsets.all(8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + AppSnackbar.error( + title: 'Inquiry failed', + message: truncatedError, ); } } diff --git a/lib/features/listing/controllers/listing_controller.dart b/lib/features/listing/controllers/listing_controller.dart index d7e469c..6f5c1aa 100644 --- a/lib/features/listing/controllers/listing_controller.dart +++ b/lib/features/listing/controllers/listing_controller.dart @@ -18,7 +18,6 @@ class ListingController extends BaseController { final ScrollController scrollController = ScrollController(); final RxList listings = [].obs; - // Note: isLoading and errorMessage are inherited from BaseController final RxBool isRefreshing = false.obs; final RxInt currentPage = 1.obs; final RxInt totalPages = 1.obs; @@ -31,7 +30,6 @@ class ListingController extends BaseController { Map? _filtersFromArgs; UnifiedFilterModel _activeFilters = UnifiedFilterModel.empty; FilterController? _filterController; - Worker? _filterWorker; @override void onInit() { @@ -44,13 +42,13 @@ class ListingController extends BaseController { @override void onClose() { scrollController.dispose(); - _filterWorker?.dispose(); + // Worker is automatically disposed by BaseController via trackWorker super.onClose(); } void _initQueryFromArgsOrService() { - final args = Get.arguments as Map?; - if (args != null) { + final args = Get.arguments; + if (args is Map) { _queryLat = (args['lat'] as num?)?.toDouble() ?? _locationService.latitude; _queryLng = @@ -85,7 +83,7 @@ class ListingController extends BaseController { } else { _activeFilters = _filterController!.filterFor(FilterScope.explore); } - _filterWorker = debounce( + trackWorker(debounce( _filterController!.rxFor(FilterScope.locate), (filters) async { if (_activeFilters == filters) return; @@ -93,7 +91,7 @@ class ListingController extends BaseController { await fetch(pageOverride: 1, jumpToTop: true); }, time: const Duration(milliseconds: 150), - ); + )); } Map? _buildFilters() { diff --git a/lib/features/listing/controllers/listing_detail_controller.dart b/lib/features/listing/controllers/listing_detail_controller.dart index 925e987..617e6dc 100644 --- a/lib/features/listing/controllers/listing_detail_controller.dart +++ b/lib/features/listing/controllers/listing_detail_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:stays_app/app/data/models/property_model.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/app/data/repositories/properties_repository.dart'; import 'package:stays_app/app/data/repositories/wishlist_repository.dart'; import 'package:stays_app/app/routes/app_routes.dart'; @@ -10,6 +11,7 @@ import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/controllers/favorites_controller.dart'; import 'package:stays_app/features/wishlist/controllers/wishlist_controller.dart'; import 'package:stays_app/app/data/services/image_prefetch_service.dart'; +import 'package:stays_app/app/data/services/analytics_service.dart'; class ListingDetailController extends BaseController { final PropertiesRepository _repository; @@ -88,6 +90,7 @@ class ListingDetailController extends BaseController { } listing.value = property.copyWith(isFavorite: isFavorite); currentImageIndex.value = 0; + _logPropertyView(property); if (galleryController.hasClients) { galleryController.jumpToPage(0); } @@ -138,9 +141,9 @@ class ListingDetailController extends BaseController { if (_wishlistRepository == null) { AppLogger.error('WishlistRepository not available'); - Get.snackbar( - 'Error', - 'Wishlist service not available. Please try again.', + AppSnackbar.error( + title: 'Error', + message: 'Wishlist service not available. Please try again.', ); return; } @@ -149,9 +152,15 @@ class ListingDetailController extends BaseController { if (isCurrentlyFavorite) { await _wishlistRepository!.remove(propertyId); _favoritesController.removeFavorite(propertyId); + if (Get.isRegistered()) { + Get.find().logWishlistRemoved('$propertyId'); + } } else { await _wishlistRepository!.add(propertyId); _favoritesController.addFavorite(propertyId); + if (Get.isRegistered()) { + Get.find().logWishlistAdded('$propertyId'); + } } listing.value = listing.value?.copyWith(isFavorite: !isCurrentlyFavorite); @@ -168,14 +177,16 @@ class ListingDetailController extends BaseController { ); } - Get.snackbar( - isCurrentlyFavorite ? 'Removed from Wishlist' : 'Added to Wishlist', - '${property.name} updated.', - snackPosition: SnackPosition.TOP, + AppSnackbar.success( + title: isCurrentlyFavorite ? 'Removed from Wishlist' : 'Added to Wishlist', + message: '${property.name} updated.', ); } catch (e) { AppLogger.error('Error toggling favorite', e); - Get.snackbar('Error', 'Could not update wishlist. Please try again.'); + AppSnackbar.error( + title: 'Error', + message: 'Could not update wishlist. Please try again.', + ); } } @@ -184,9 +195,24 @@ class ListingDetailController extends BaseController { } void navigateToInquiryConfirmation(Property property) { + if (Get.isRegistered()) { + Get.find().logBookingStarted( + '${property.id}', + property.pricePerNight, + ); + } Get.toNamed(Routes.inquiryConfirmation, arguments: property); } + void _logPropertyView(Property property) { + if (Get.isRegistered()) { + Get.find().logPropertyView( + '${property.id}', + property.name, + ); + } + } + @override void onClose() { galleryController.dispose(); diff --git a/lib/features/listing/controllers/location_search_controller.dart b/lib/features/listing/controllers/location_search_controller.dart index 5318b88..24c54d3 100644 --- a/lib/features/listing/controllers/location_search_controller.dart +++ b/lib/features/listing/controllers/location_search_controller.dart @@ -1,16 +1,15 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/data/services/location_service.dart'; import 'package:stays_app/app/data/services/places_service.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; -class LocationSearchController extends BaseController { +class LocationSearchController extends GetxController { late final PlacesService _placesService; late final LocationService _locationService; final RxString query = ''.obs; - // Note: isLoading is inherited from BaseController + final RxBool isLoading = false.obs; final RxList predictions = [].obs; final TextEditingController textController = TextEditingController(); Worker? _searchWorker; diff --git a/lib/features/listing/views/listing_detail_view.dart b/lib/features/listing/views/listing_detail_view.dart index b0ef2d0..c45e7ef 100644 --- a/lib/features/listing/views/listing_detail_view.dart +++ b/lib/features/listing/views/listing_detail_view.dart @@ -5,6 +5,8 @@ import 'package:get/get.dart'; import 'package:latlong2/latlong.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:stays_app/app/ui/theme/app_dimensions.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/features/listing/controllers/listing_detail_controller.dart'; import 'package:stays_app/app/data/models/property_model.dart'; import 'package:stays_app/app/utils/helpers/currency_helper.dart'; @@ -22,7 +24,6 @@ class ListingDetailView extends GetView { extendBody: true, extendBodyBehindAppBar: false, body: Obx(() { - // Only rebuild this when loading/error states or listing null-check changes if (controller.isLoading.value && controller.listing.value == null) { return const Center(child: CircularProgressIndicator()); } @@ -34,8 +35,68 @@ class ListingDetailView extends GetView { if (listing == null) { return const Center(child: Text('Listing not found')); } - // Pass listing to a separate widget to avoid rebuilding - return _ListingContent(listing: listing, controller: controller); + + final amenities = (listing.amenities ?? []) + .where((a) => a.trim().isNotEmpty) + .toList(); + + final features = (listing.features ?? []) + .where((f) => f.trim().isNotEmpty) + .toList(); + + return CustomScrollView( + physics: const BouncingScrollPhysics(), + + slivers: [ + _buildHeroSliver(context, listing), + + SliverPadding( + padding: EdgeInsets.fromLTRB( + AppDimensions.xl + 4, + AppDimensions.xxl + 8, + AppDimensions.xl + 4, + AppDimensions.xxl + 8, + ), + + sliver: SliverList( + delegate: SliverChildListDelegate([ + _buildPrimaryDetails(context, listing), + + if (amenities.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: AppDimensions.sectionSpacingMd), + child: _buildAmenitiesSection(context, amenities), + ), + + if (listing.hasVirtualTour) + Padding( + padding: const EdgeInsets.only(top: AppDimensions.sectionSpacingMd), + child: _buildVirtualTourSection(context, listing), + ), + + if (features.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: AppDimensions.sectionSpacingMd), + child: _buildFeaturesSection(context, features), + ), + + if (listing.ownerName?.isNotEmpty == true) + Padding( + padding: const EdgeInsets.only(top: AppDimensions.sectionSpacingMd), + child: _buildHostSection(context, listing), + ), + + Padding( + padding: const EdgeInsets.only(top: AppDimensions.sectionSpacingMd), + child: _buildLocationSection(context, listing), + ), + + SizedBox(height: MediaQuery.of(context).padding.bottom + 120), + ]), + ), + ), + ], + ); }), bottomNavigationBar: Obx(() { @@ -55,17 +116,11 @@ class ListingDetailView extends GetView { return SliverAppBar( backgroundColor: colors.surface, - - expandedHeight: 360, - + expandedHeight: context.responsiveHeroHeight, pinned: true, - stretch: true, - automaticallyImplyLeading: false, - toolbarHeight: 64, - titleSpacing: 0, flexibleSpace: LayoutBuilder( @@ -614,7 +669,7 @@ class ListingDetailView extends GetView { if (lat != null && lng != null) ...[ const SizedBox(height: 16), Container( - height: 150, + height: AppDimensions.mapHeight, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: [ @@ -887,7 +942,10 @@ class ListingDetailView extends GetView { final lng = listing.longitude; if (lat == null || lng == null) { - Get.snackbar('Error', 'Location coordinates not available'); + AppSnackbar.error( + title: 'Error', + message: 'Location coordinates not available', + ); return; } @@ -903,11 +961,17 @@ class ListingDetailView extends GetView { if (await canLaunchUrl(Uri.parse(webUrl))) { await launchUrl(Uri.parse(webUrl)); } else { - Get.snackbar('Error', 'Could not open maps application'); + AppSnackbar.error( + title: 'Error', + message: 'Could not open maps application', + ); } } } catch (e) { - Get.snackbar('Error', 'Could not open maps application'); + AppSnackbar.error( + title: 'Error', + message: 'Could not open maps application', + ); } } @@ -1090,57 +1154,3 @@ class _AmenityTile extends StatelessWidget { ); } } - -/// Extracted content widget to avoid rebuilding entire content on every Rx change. -/// Only rebuilds when the listing data itself changes, not on loading/error state changes. -class _ListingContent extends StatelessWidget { - const _ListingContent({ - required this.listing, - required this.controller, - }); - - final Property listing; - final ListingDetailController controller; - - @override - Widget build(BuildContext context) { - const parentView = ListingDetailView(); - final amenities = listing.amenities ?? const []; - final features = listing.features ?? const []; - final hasVirtualTour = listing.virtualTourUrl?.isNotEmpty == true; - final hasHost = listing.ownerName?.isNotEmpty == true; - - return CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - parentView._buildHeroSliver(context, listing), - SliverPadding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), - sliver: SliverList( - delegate: SliverChildListDelegate([ - parentView._buildPrimaryDetails(context, listing), - if (amenities.isNotEmpty) ...[ - const SizedBox(height: 24), - parentView._buildAmenitiesSection(context, amenities), - ], - if (hasVirtualTour) ...[ - const SizedBox(height: 24), - parentView._buildVirtualTourSection(context, listing), - ], - if (features.isNotEmpty) ...[ - const SizedBox(height: 24), - parentView._buildFeaturesSection(context, features), - ], - if (hasHost) ...[ - const SizedBox(height: 24), - parentView._buildHostSection(context, listing), - ], - const SizedBox(height: 24), - parentView._buildLocationSection(context, listing), - ]), - ), - ), - ], - ); - } -} diff --git a/lib/features/listing/views/search_results_view.dart b/lib/features/listing/views/search_results_view.dart index b61dc5c..3e63789 100644 --- a/lib/features/listing/views/search_results_view.dart +++ b/lib/features/listing/views/search_results_view.dart @@ -5,6 +5,7 @@ import 'package:get/get.dart'; import 'package:stays_app/app/controllers/filter_controller.dart'; import 'package:stays_app/app/data/models/unified_filter_model.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/features/listing/controllers/listing_controller.dart'; import 'package:stays_app/app/ui/widgets/cards/property_grid_card.dart'; import 'package:stays_app/app/ui/widgets/common/location_filter_app_bar.dart'; @@ -19,7 +20,6 @@ class SearchResultsView extends GetView { final filterController = Get.find(); final filtersRx = filterController.rxFor(FilterScope.locate); final colors = Theme.of(context).colorScheme; - final textStyles = Theme.of(context).textTheme; return Scaffold( backgroundColor: colors.surface, appBar: LocationFilterAppBar( @@ -30,14 +30,20 @@ class SearchResultsView extends GetView { tooltip: 'Sort', icon: Icon(Icons.sort_rounded, color: colors.onSurface), onPressed: () { - Get.snackbar('Sort', 'Sorting options coming soon'); + AppSnackbar.info( + title: 'Sort', + message: 'Sorting options coming soon', + ); }, ), IconButton( tooltip: 'Map', icon: Icon(Icons.map_outlined, color: colors.onSurface), onPressed: () { - Get.snackbar('Map', 'Map view coming soon'); + AppSnackbar.info( + title: 'Map', + message: 'Map view coming soon', + ); }, ), ], diff --git a/lib/features/messaging/bindings/message_binding.dart b/lib/features/messaging/bindings/message_binding.dart index 646d02a..9770aa6 100644 --- a/lib/features/messaging/bindings/message_binding.dart +++ b/lib/features/messaging/bindings/message_binding.dart @@ -1,16 +1,12 @@ import 'package:get/get.dart'; import 'package:stays_app/app/controllers/filter_controller.dart'; -import 'package:stays_app/app/data/providers/message_provider.dart'; import 'package:stays_app/features/messaging/controllers/chat_controller.dart'; import 'package:stays_app/features/messaging/controllers/hotels_map_controller.dart'; class MessageBinding extends Bindings { @override void dependencies() { - if (!Get.isRegistered()) { - Get.put(MessageProvider(), permanent: true); - } if (!Get.isRegistered()) { Get.lazyPut( () => HotelsMapController(), @@ -18,10 +14,7 @@ class MessageBinding extends Bindings { ); } if (!Get.isRegistered()) { - Get.lazyPut( - () => ChatController(messageProvider: Get.find()), - fenix: true, - ); + Get.lazyPut(() => ChatController(), fenix: true); } if (!Get.isRegistered()) { Get.put(FilterController(), permanent: true); diff --git a/lib/features/messaging/controllers/chat_controller.dart b/lib/features/messaging/controllers/chat_controller.dart index 4e1baa7..d260fc8 100644 --- a/lib/features/messaging/controllers/chat_controller.dart +++ b/lib/features/messaging/controllers/chat_controller.dart @@ -1,129 +1,9 @@ import 'package:get/get.dart'; - -import '../../../app/controllers/base/base_controller.dart'; -import '../../../app/data/models/message_model.dart'; -import '../../../app/data/providers/message_provider.dart'; -import '../../../app/utils/logger/app_logger.dart'; +import 'package:stays_app/app/controllers/base/base_controller.dart'; +import 'package:stays_app/app/data/models/message_model.dart'; class ChatController extends BaseController { - ChatController({ - required MessageProvider messageProvider, - }) : _messageProvider = messageProvider; - - final MessageProvider _messageProvider; - - /// Current conversation ID - final conversationId = ''.obs; - - /// List of messages in the current conversation final RxList messages = [].obs; - /// Flag indicating if more messages are being loaded (pagination) - final isLoadingMore = false.obs; - - /// Flag indicating if a message is being sent - final isSending = false.obs; - - /// Flag indicating if there are more messages to load - final hasMoreMessages = true.obs; - - /// Current page for pagination - int _currentPage = 1; - static const int _pageSize = 20; - - @override - void onInit() { - super.onInit(); - // Get conversation ID from route parameters - final id = Get.parameters['conversationId']; - if (id != null && id.isNotEmpty) { - conversationId.value = id; - loadMessages(); - } - } - - /// Load messages for the current conversation - Future loadMessages() async { - if (conversationId.value.isEmpty) { - errorMessage.value = 'No conversation selected'; - return; - } - - _currentPage = 1; - hasMoreMessages.value = true; - - await executeWithErrorHandling(() async { - final result = await _messageProvider.getMessages( - conversationId.value, - page: _currentPage, - limit: _pageSize, - ); - messages.assignAll(result); - hasMoreMessages.value = result.length >= _pageSize; - }); - } - - /// Load more messages (pagination) - Future loadMoreMessages() async { - if (isLoadingMore.value || !hasMoreMessages.value) return; - - isLoadingMore.value = true; - try { - _currentPage++; - final result = await _messageProvider.getMessages( - conversationId.value, - page: _currentPage, - limit: _pageSize, - ); - messages.addAll(result); - hasMoreMessages.value = result.length >= _pageSize; - } catch (e, s) { - _currentPage--; // Revert page on failure - AppLogger.error('Failed to load more messages', e, s); - } finally { - isLoadingMore.value = false; - } - } - - /// Send a new message - Future sendMessage(String content) async { - if (content.trim().isEmpty || isSending.value) return; - - isSending.value = true; - try { - final message = await _messageProvider.sendMessage( - conversationId.value, - content.trim(), - ); - messages.add(message); - } catch (e, s) { - AppLogger.error('Failed to send message', e, s); - errorMessage.value = 'Failed to send message. Please try again.'; - } finally { - isSending.value = false; - } - } - - /// Mark conversation as read - Future markAsRead() async { - if (conversationId.value.isEmpty) return; - - try { - await _messageProvider.markAsRead(conversationId.value); - } catch (e, s) { - AppLogger.error('Failed to mark conversation as read', e, s); - // Non-critical error, don't show to user - } - } - - /// Refresh messages - Future refreshMessages() async { - await loadMessages(); - } - - /// Clear error message - @override - void clearError() { - errorMessage.value = ''; - } + // TODO: Implement message loading, sending, and pagination logic } diff --git a/lib/features/messaging/controllers/hotels_map_controller.dart b/lib/features/messaging/controllers/hotels_map_controller.dart index 5019143..6edb5fe 100644 --- a/lib/features/messaging/controllers/hotels_map_controller.dart +++ b/lib/features/messaging/controllers/hotels_map_controller.dart @@ -5,7 +5,6 @@ import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/data/repositories/properties_repository.dart'; import 'package:stays_app/app/data/models/property_model.dart'; import 'package:stays_app/app/data/services/places_service.dart'; @@ -13,6 +12,7 @@ import 'package:stays_app/app/data/services/location_service.dart'; import 'package:stays_app/app/data/models/unified_filter_model.dart'; import 'package:stays_app/app/controllers/filter_controller.dart'; import 'package:stays_app/app/utils/helpers/currency_helper.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; class HotelModel { final Property property; @@ -36,7 +36,7 @@ class HotelModel { String get propertyType => property.propertyType.toLowerCase(); } -class HotelsMapController extends BaseController { +class HotelsMapController extends GetxController { late flutter_map.MapController mapController; late final PageController cardsController; final RxList markers = [].obs; @@ -276,7 +276,10 @@ class HotelsMapController extends BaseController { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { - Get.snackbar('Location Error', 'Location services are disabled'); + AppSnackbar.error( + title: 'Location Error', + message: 'Location services are disabled', + ); _loadSampleHotels(); return; } @@ -285,9 +288,9 @@ class HotelsMapController extends BaseController { if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { - Get.snackbar( - 'Permission Denied', - 'Location permission is required to show nearby hotels', + AppSnackbar.warning( + title: 'Permission Denied', + message: 'Location permission is required to show nearby hotels', ); _loadSampleHotels(); return; @@ -306,7 +309,10 @@ class HotelsMapController extends BaseController { await _loadHotelsNearLocation(currentLocation.value); } catch (e) { - Get.snackbar('Error', 'Failed to get current location: $e'); + AppSnackbar.error( + title: 'Error', + message: 'Failed to get current location: $e', + ); _loadSampleHotels(); } finally { isLoadingLocation.value = false; diff --git a/lib/features/messaging/views/chat_view.dart b/lib/features/messaging/views/chat_view.dart index d373097..78c1056 100644 --- a/lib/features/messaging/views/chat_view.dart +++ b/lib/features/messaging/views/chat_view.dart @@ -1,11 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../../../app/data/models/message_model.dart'; -import '../../../app/ui/widgets/common/empty_state_widget.dart'; -import '../../../app/ui/widgets/common/error_widget.dart'; -import '../../../app/ui/widgets/common/loading_widget.dart'; -import '../controllers/chat_controller.dart'; +import 'package:stays_app/features/messaging/controllers/chat_controller.dart'; +import 'package:stays_app/app/data/models/message_model.dart'; class ChatView extends StatefulWidget { const ChatView({super.key}); @@ -17,252 +14,98 @@ class ChatView extends StatefulWidget { class _ChatViewState extends State { late final ChatController _controller; late final TextEditingController _input; - late final ScrollController _scrollController; @override void initState() { super.initState(); _controller = Get.find(); _input = TextEditingController(); - _scrollController = ScrollController(); - - // Add scroll listener for pagination - _scrollController.addListener(_onScroll); - - // Mark conversation as read when opened - WidgetsBinding.instance.addPostFrameCallback((_) { - _controller.markAsRead(); - }); - } - - void _onScroll() { - // Load more messages when scrolling near the top (older messages) - if (_scrollController.position.pixels <= - _scrollController.position.minScrollExtent + 100) { - _controller.loadMoreMessages(); - } } @override void dispose() { _input.dispose(); - _scrollController.removeListener(_onScroll); - _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final conversationId = Get.parameters['conversationId'] ?? 'Chat'; final colors = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar( - title: Obx(() { - final conversationId = _controller.conversationId.value; - return Text( - conversationId.isEmpty ? 'Chat' : 'Chat · $conversationId', - ); - }), - ), + appBar: AppBar(title: Text('Chat · $conversationId')), body: Column( children: [ Expanded( - child: _buildMessageList(colors), - ), - _buildInputArea(colors), - ], - ), - ); - } - - Widget _buildMessageList(ColorScheme colors) { - return Obx(() { - // Show loading state - if (_controller.isLoading.value && _controller.messages.isEmpty) { - return const LoadingWidget(message: 'Loading messages...'); - } - - // Show error state - if (_controller.errorMessage.value.isNotEmpty && - _controller.messages.isEmpty) { - return ErrorDisplay( - message: _controller.errorMessage.value, - onRetry: _controller.refreshMessages, - ); - } - - // Show empty state - if (_controller.messages.isEmpty) { - return const EmptyStateWidget( - title: 'No messages yet', - message: 'Start the conversation by sending a message', - type: EmptyStateType.messages, - ); - } - - // Show messages list - return RefreshIndicator( - onRefresh: _controller.refreshMessages, - child: Column( - children: [ - // Show loading indicator when loading more messages - if (_controller.isLoadingMore.value) - const Padding( - padding: EdgeInsets.all(8.0), - child: SmallLoadingWidget(), - ), - Expanded( - child: ListView.builder( - controller: _scrollController, + child: Obx( + () => ListView.builder( padding: const EdgeInsets.all(12), - reverse: true, // Most recent messages at the bottom itemCount: _controller.messages.length, - itemBuilder: (context, index) { - // Reverse index since we're using reverse: true - final reversedIndex = - _controller.messages.length - 1 - index; - return _buildMessageBubble( - context, - _controller.messages[reversedIndex], - reversedIndex, - colors, - ); - }, - ), - ), - ], - ), - ); - }); - } - - Widget _buildMessageBubble( - BuildContext context, - MessageModel message, - int index, - ColorScheme colors, - ) { - // Determine if this is a sent message (even index for demo, - // in production check against current user ID) - final isSentByMe = index.isEven; - - return Align( - alignment: isSentByMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, - ), - decoration: BoxDecoration( - color: isSentByMe ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(12), - topRight: const Radius.circular(12), - bottomLeft: Radius.circular(isSentByMe ? 12 : 4), - bottomRight: Radius.circular(isSentByMe ? 4 : 12), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - message.content, - style: TextStyle( - color: isSentByMe ? colors.onPrimary : colors.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - _formatTime(message.createdAt), - style: TextStyle( - fontSize: 10, - color: (isSentByMe ? colors.onPrimary : colors.onSurface) - .withValues(alpha: 0.6), + itemBuilder: (_, i) => Align( + alignment: i.isEven + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: i.isEven + ? colors.primary + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _controller.messages[i].content, + style: TextStyle( + color: i.isEven ? colors.onPrimary : colors.onSurface, + ), + ), + ), + ), ), ), - ], - ), - ), - ); - } - - String _formatTime(DateTime dateTime) { - final now = DateTime.now(); - final difference = now.difference(dateTime); - - if (difference.inDays > 0) { - return '${dateTime.day}/${dateTime.month}'; - } - return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; - } - - Widget _buildInputArea(ColorScheme colors) { - return SafeArea( - top: false, - child: Container( - padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: colors.surface, - border: Border( - top: BorderSide( - color: colors.outlineVariant.withValues(alpha: 0.3), - ), ), - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _input, - decoration: InputDecoration( - hintText: 'Type a message', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _input, + decoration: const InputDecoration( + hintText: 'Type a message', + ), + ), ), - filled: true, - fillColor: colors.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + onPressed: () { + final text = _input.text.trim(); + if (text.isEmpty) return; + _controller.messages.add( + MessageModel( + id: DateTime.now().millisecondsSinceEpoch.toString(), + conversationId: '', + senderId: 'current_user', + content: text, + createdAt: DateTime.now(), + ), + ); + _input.clear(); + }, ), - ), - textInputAction: TextInputAction.send, - onSubmitted: _sendMessage, + ], ), ), - const SizedBox(width: 8), - Obx(() { - final isSending = _controller.isSending.value; - return IconButton.filled( - icon: isSending - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colors.onPrimary, - ), - ) - : const Icon(Icons.send), - onPressed: isSending ? null : () => _sendMessage(_input.text), - ); - }), - ], - ), + ), + ], ), ); } - - void _sendMessage(String text) { - final trimmed = text.trim(); - if (trimmed.isEmpty) return; - - _controller.sendMessage(trimmed); - _input.clear(); - } } diff --git a/lib/features/messaging/views/locate_view.dart b/lib/features/messaging/views/locate_view.dart index 0db0602..252e308 100644 --- a/lib/features/messaging/views/locate_view.dart +++ b/lib/features/messaging/views/locate_view.dart @@ -191,7 +191,7 @@ class LocateView extends GetView { ), ), ), - const SizedBox(width: 8), + SizedBox(width: 8), Text( 'locate.loading_hotels'.tr, style: textStyles.bodyMedium?.copyWith( @@ -341,11 +341,11 @@ class LocatePropertyCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( - flex: 1, + flex: 10, child: _buildImage(context, isSelected: isSelected), ), Expanded( - flex: 1, + flex: 11, child: _buildDetails( context, textStyles, @@ -371,16 +371,16 @@ class LocatePropertyCard extends StatelessWidget { Widget fallback = _buildPlaceholder(colors); Widget infoChip(String text, {IconData? icon}) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ - Icon(icon, color: colors.primary, size: 14), + Icon(icon, color: colors.primary, size: 12), const SizedBox(width: 4), ], Text( @@ -390,7 +390,7 @@ class LocatePropertyCard extends StatelessWidget { fontWeight: FontWeight.w600, fontSize: _shrinkFont( theme.textTheme.labelSmall?.fontSize, - 0.5, + 1.5, ), ), ), @@ -433,10 +433,10 @@ class LocatePropertyCard extends StatelessWidget { top: 12, left: 12, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: colorScheme.primaryContainer.withOpacity(0.92), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), child: Text( hotel.property.propertyTypeDisplay.toUpperCase(), @@ -444,7 +444,7 @@ class LocatePropertyCard extends StatelessWidget { color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w700, letterSpacing: 0.3, - fontSize: _shrinkFont(theme.textTheme.labelSmall?.fontSize), + fontSize: _shrinkFont(theme.textTheme.labelSmall?.fontSize, 1.5), ), ), ), @@ -480,7 +480,7 @@ class LocatePropertyCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (hasBedrooms) - infoChip('${bedrooms!} BHK', icon: Icons.king_bed_rounded), + infoChip('${bedrooms} BHK', icon: Icons.king_bed_rounded), if (hasBedrooms && distanceKm > 0) const SizedBox(height: 6), if (distanceKm > 0) infoChip('${distanceKm.toStringAsFixed(1)} km'), @@ -506,10 +506,10 @@ class LocatePropertyCard extends StatelessWidget { final address = property.fullAddress.isNotEmpty ? property.fullAddress : property.city; - final baseTitleStyle = textTheme.titleSmall ?? textTheme.titleMedium; - final titleStyle = baseTitleStyle?.copyWith( + final baseTitleStyle = textTheme.titleSmall ?? textTheme.titleMedium ?? const TextStyle(); + final titleStyle = baseTitleStyle.copyWith( fontWeight: FontWeight.w700, - fontSize: _shrinkFont(baseTitleStyle?.fontSize, 1), + fontSize: _shrinkFont(baseTitleStyle.fontSize, 1), height: 1.05, ); final priceStyle = textTheme.titleSmall?.copyWith( @@ -554,15 +554,16 @@ class LocatePropertyCard extends StatelessWidget { ), ], ), - const SizedBox(height: 2), + // Reduced spacing to prevent overflow + const SizedBox(height: 1), Text( address, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, style: addressStyle, ), if (hotel.distanceKm > 0) ...[ - const SizedBox(height: 4), + const SizedBox(height: 2), Text( '${hotel.distanceKm.toStringAsFixed(1)} km away', style: distanceStyle, diff --git a/lib/features/payment/controllers/payment_controller.dart b/lib/features/payment/controllers/payment_controller.dart index 9c09fb3..5aa5bef 100644 --- a/lib/features/payment/controllers/payment_controller.dart +++ b/lib/features/payment/controllers/payment_controller.dart @@ -1,7 +1,7 @@ import 'package:get/get.dart'; - import 'package:stays_app/app/controllers/base/base_controller.dart'; class PaymentController extends BaseController { + /// Indicates if a payment is being processed (distinct from general loading) final RxBool isProcessing = false.obs; } diff --git a/lib/features/payment/controllers/payment_method_controller.dart b/lib/features/payment/controllers/payment_method_controller.dart index 9a3ebbf..dafc772 100644 --- a/lib/features/payment/controllers/payment_method_controller.dart +++ b/lib/features/payment/controllers/payment_method_controller.dart @@ -1,7 +1,5 @@ import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; - -class PaymentMethodController extends BaseController { +class PaymentMethodController extends GetxController { final RxList methods = [].obs; } diff --git a/lib/features/profile/bindings/profile_binding.dart b/lib/features/profile/bindings/profile_binding.dart index ff13016..a97e83d 100644 --- a/lib/features/profile/bindings/profile_binding.dart +++ b/lib/features/profile/bindings/profile_binding.dart @@ -1,6 +1,5 @@ import 'package:get/get.dart'; import 'package:stays_app/features/auth/controllers/auth_controller.dart'; -import 'package:stays_app/features/auth/controllers/session_controller.dart'; import 'package:stays_app/features/settings/controllers/theme_controller.dart'; import 'package:stays_app/app/data/providers/users_provider.dart'; import 'package:stays_app/app/data/repositories/auth_repository.dart'; @@ -40,22 +39,6 @@ class ProfileBinding extends Bindings { ); } final authRepository = Get.find(); - if (!Get.isRegistered()) { - Get.put( - SessionController(tokenService: Get.find()), - permanent: true, - ); - } - if (!Get.isRegistered()) { - Get.put( - AuthController( - authRepository: Get.find(), - sessionController: Get.find(), - ), - permanent: true, - ); - } - final authController = Get.find(); if (!Get.isRegistered()) { Get.lazyPut(() => UsersProvider(), fenix: true); @@ -69,6 +52,18 @@ class ProfileBinding extends Bindings { } final profileRepository = Get.find(); + if (!Get.isRegistered()) { + Get.put( + AuthController( + authRepository: Get.find(), + tokenService: Get.find(), + profileRepository: Get.find(), + ), + permanent: true, + ); + } + final authController = Get.find(); + if (!Get.isRegistered()) { Get.lazyPut( () => ProfileController( diff --git a/lib/features/profile/controllers/about_controller.dart b/lib/features/profile/controllers/about_controller.dart index 5f3c9b7..2a3d7a5 100644 --- a/lib/features/profile/controllers/about_controller.dart +++ b/lib/features/profile/controllers/about_controller.dart @@ -1,11 +1,10 @@ import 'package:get/get.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/routes/app_routes.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/config/app_config.dart'; -class AboutController extends BaseController { +class AboutController extends GetxController { final RxString version = ''.obs; final RxString buildNumber = ''.obs; final RxString environment = ''.obs; diff --git a/lib/features/profile/controllers/edit_profile_controller.dart b/lib/features/profile/controllers/edit_profile_controller.dart index e59d45b..864ef95 100644 --- a/lib/features/profile/controllers/edit_profile_controller.dart +++ b/lib/features/profile/controllers/edit_profile_controller.dart @@ -8,7 +8,7 @@ import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/features/auth/controllers/auth_controller.dart'; import 'package:stays_app/app/data/models/user_model.dart'; import 'package:stays_app/app/data/repositories/profile_repository.dart'; -import 'package:stays_app/app/utils/logger/app_logger.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/features/profile/controllers/profile_controller.dart'; class EditProfileController extends BaseController { @@ -29,10 +29,6 @@ class EditProfileController extends BaseController { final formKey = GlobalKey(); - // Listeners for profile changes - late final Worker _profileChangeWorker; - late final Worker _authChangeWorker; - late final TextEditingController firstNameController; late final TextEditingController lastNameController; late final TextEditingController emailController; @@ -41,11 +37,13 @@ class EditProfileController extends BaseController { late final TextEditingController dobController; final Rx dateOfBirth = Rx(null); - final RxBool isSaving = false.obs; final RxBool isUploadingImage = false.obs; final Rx selectedImage = Rx(null); final RxString avatarUrl = ''.obs; + /// Alias for isLoading from BaseController for backwards compatibility + RxBool get isSaving => isLoading; + String? get _firstName => firstNameController.text.trim().isEmpty ? null : firstNameController.text.trim(); @@ -61,17 +59,17 @@ class EditProfileController extends BaseController { _initializeFields(); // Listen for profile changes and update form fields accordingly - _profileChangeWorker = ever(_profileController.user, (UserModel? user) { + trackWorker(ever(_profileController.user, (UserModel? user) { if (user != null) { _updateFieldsFromUser(user); } - }); + })); - _authChangeWorker = ever(_authController.currentUser, (UserModel? user) { + trackWorker(ever(_authController.currentUser, (UserModel? user) { if (user != null) { _updateFieldsFromUser(user); } - }); + })); } void _initializeFields() { @@ -103,8 +101,7 @@ class EditProfileController extends BaseController { @override void onClose() { - _profileChangeWorker.dispose(); - _authChangeWorker.dispose(); + // Workers are automatically disposed by BaseController via trackWorker firstNameController.dispose(); lastNameController.dispose(); emailController.dispose(); @@ -143,22 +140,16 @@ class EditProfileController extends BaseController { ); if (picked == null) return; if (kIsWeb) { - Get.snackbar( - 'Unsupported', - 'Image uploads are not supported on web builds yet.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Unsupported', + message: 'Image uploads are not supported on web builds yet.', ); return; } final file = File(picked.path); selectedImage.value = file; - } catch (e, stack) { - AppLogger.error('Image picker failed', e, stack); - Get.snackbar( - 'Image picker', - 'Failed to pick image. Please try again.', - snackPosition: SnackPosition.BOTTOM, - ); + } catch (e) { + handleError(e); } } @@ -197,13 +188,12 @@ class EditProfileController extends BaseController { } Future save() async { - if (isSaving.value) return; + if (isLoading.value) return; if (!(formKey.currentState?.validate() ?? false)) { return; } - try { - isSaving.value = true; + final result = await executeWithErrorHandling(() async { String? uploadedUrl; if (selectedImage.value != null) { @@ -228,22 +218,21 @@ class EditProfileController extends BaseController { _profileController.updateUser(updated); selectedImage.value = null; + return updated; + }); + isUploadingImage.value = false; + + if (result != null) { Get.back(result: true); - Get.snackbar( - 'Profile updated', - 'Your profile changes have been saved.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.success( + title: 'Profile updated', + message: 'Your profile changes have been saved.', ); - } catch (e, stack) { - AppLogger.error('Failed to update profile', e, stack); - Get.snackbar( - 'Update failed', - 'We could not save your changes. Please try again.', - snackPosition: SnackPosition.BOTTOM, + } else { + AppSnackbar.error( + title: 'Update failed', + message: 'We could not save your changes. Please try again.', ); - } finally { - isUploadingImage.value = false; - isSaving.value = false; } } diff --git a/lib/features/profile/controllers/help_controller.dart b/lib/features/profile/controllers/help_controller.dart index 0b317f4..ab3817a 100644 --- a/lib/features/profile/controllers/help_controller.dart +++ b/lib/features/profile/controllers/help_controller.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/routes/app_routes.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/features/profile/models/faq_item.dart'; import 'package:stays_app/features/profile/models/support_channel.dart'; import 'package:url_launcher/url_launcher.dart'; -class HelpController extends BaseController { +class HelpController extends GetxController { final RxBool isSubmittingFeedback = false.obs; final TextEditingController feedbackController = TextEditingController(); @@ -85,17 +85,15 @@ class HelpController extends BaseController { // Placeholder for API integration. await Future.delayed(const Duration(milliseconds: 600)); feedbackController.clear(); - Get.snackbar( - 'Feedback received', - 'Thanks for sharing your experience. Our team will review it shortly.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.success( + title: 'Feedback received', + message: 'Thanks for sharing your experience. Our team will review it shortly.', ); } catch (e, stack) { AppLogger.error('Feedback submission failed', e, stack); - Get.snackbar( - 'Feedback not sent', - 'We could not send your feedback. Please try again later.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.error( + title: 'Feedback not sent', + message: 'We could not send your feedback. Please try again later.', ); } finally { isSubmittingFeedback.value = false; @@ -104,10 +102,9 @@ class HelpController extends BaseController { Future _launchUri(Uri uri) async { if (!await canLaunchUrl(uri)) { - Get.snackbar( - 'Unavailable', - 'Unable to launch ${uri.scheme} contact method on this device.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Unavailable', + message: 'Unable to launch ${uri.scheme} contact method on this device.', ); return; } diff --git a/lib/features/profile/controllers/notifications_controller.dart b/lib/features/profile/controllers/notifications_controller.dart index 668c1ed..9035bad 100644 --- a/lib/features/profile/controllers/notifications_controller.dart +++ b/lib/features/profile/controllers/notifications_controller.dart @@ -3,7 +3,8 @@ import 'package:get/get.dart'; import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/data/models/user_model.dart'; import 'package:stays_app/app/data/repositories/profile_repository.dart'; -import 'package:stays_app/app/utils/logger/app_logger.dart'; +import 'package:stays_app/app/utils/extensions/dynamic_extensions.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/features/profile/controllers/profile_controller.dart'; class NotificationsController extends BaseController { @@ -32,8 +33,8 @@ class NotificationsController extends BaseController { 'community': false, }.obs; - final RxBool isSaving = false.obs; - Worker? _userWorker; + /// Alias for isLoading from BaseController for backwards compatibility + RxBool get isSaving => isLoading; final List supportedFrequencies = const [ 'realtime', @@ -45,20 +46,20 @@ class NotificationsController extends BaseController { void onInit() { super.onInit(); _hydrate(_profileController.user.value); - _userWorker = ever(_profileController.user, _hydrate); + trackWorker(ever(_profileController.user, _hydrate)); } @override void onClose() { - _userWorker?.dispose(); + // Workers are automatically disposed by BaseController via trackWorker super.onClose(); } void _hydrate(UserModel? user) { if (user == null) return; final settings = user.notificationSettings ?? {}; - pushEnabled.value = _asBool(settings['push'], fallback: true); - emailEnabled.value = _asBool(settings['email'], fallback: true); + pushEnabled.value = parseBool(settings['push'], fallback: true); + emailEnabled.value = parseBool(settings['email'], fallback: true); frequency.value = (settings['frequency'] ?? frequency.value).toString(); final quietHours = settings['quietHours']; @@ -71,26 +72,15 @@ class NotificationsController extends BaseController { final dynamic cats = settings['categories']; if (cats is Map) { - final parsed = cats.map( - (key, value) => MapEntry( - key.toString(), - _asBool(value, fallback: categories[key] ?? false), - ), - ); + final parsed = {}; + for (final entry in cats.entries) { + parsed[entry.key.toString()] = + parseBool(entry.value, fallback: categories[entry.key.toString()] ?? false); + } categories.assignAll(parsed); } } - bool _asBool(dynamic value, {required bool fallback}) { - if (value is bool) return value; - if (value is num) return value != 0; - if (value is String) { - final normalized = value.toLowerCase(); - return normalized == 'true' || normalized == '1' || normalized == 'yes'; - } - return fallback; - } - TimeOfDay? _parseTimeOfDay(dynamic value) { if (value is TimeOfDay) return value; if (value is String && value.contains(':')) { @@ -106,38 +96,30 @@ class NotificationsController extends BaseController { '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; Future save() async { - if (isSaving.value) return; - try { - isSaving.value = true; - final payload = { - 'push': pushEnabled.value, - 'email': emailEnabled.value, - 'frequency': frequency.value, - 'quietHours': { - 'start': _timeToString(quietHoursStart.value), - 'end': _timeToString(quietHoursEnd.value), - }, - 'categories': Map.from(categories), - }; + if (isLoading.value) return; + final payload = { + 'push': pushEnabled.value, + 'email': emailEnabled.value, + 'frequency': frequency.value, + 'quietHours': { + 'start': _timeToString(quietHoursStart.value), + 'end': _timeToString(quietHoursEnd.value), + }, + 'categories': Map.from(categories), + }; + final result = await executeWithErrorHandling(() async { final updated = await _profileRepository.updateNotificationSettings( payload, ); _profileController.updateUser(updated); _profileController.updateNotificationSettingsLocal(payload); - Get.snackbar( - 'Notifications', - 'Notification preferences updated', - snackPosition: SnackPosition.BOTTOM, - ); - } catch (e, stack) { - AppLogger.error('Failed to update notifications', e, stack); - Get.snackbar( - 'Update failed', - 'Unable to update notification settings. Please retry.', - snackPosition: SnackPosition.BOTTOM, + return updated; + }); + if (result != null) { + AppSnackbar.success( + title: 'Notifications', + message: 'Notification preferences updated', ); - } finally { - isSaving.value = false; } } diff --git a/lib/features/profile/controllers/preferences_controller.dart b/lib/features/profile/controllers/preferences_controller.dart index 79efe7c..ea4fa1b 100644 --- a/lib/features/profile/controllers/preferences_controller.dart +++ b/lib/features/profile/controllers/preferences_controller.dart @@ -6,7 +6,8 @@ import 'package:stays_app/features/settings/controllers/theme_controller.dart'; import 'package:stays_app/app/data/models/user_model.dart'; import 'package:stays_app/app/data/repositories/profile_repository.dart'; import 'package:stays_app/app/data/services/locale_service.dart'; -import 'package:stays_app/app/utils/logger/app_logger.dart'; +import 'package:stays_app/app/utils/extensions/dynamic_extensions.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/features/profile/controllers/profile_controller.dart'; import 'package:stays_app/l10n/localization_service.dart'; @@ -33,11 +34,10 @@ class PreferencesController extends BaseController { final RxBool travelAlerts = true.obs; final RxString currency = 'INR'.obs; - final RxBool isSaving = false.obs; final RxString feedbackMessage = ''.obs; - Worker? _userWorker; - Worker? _themeWorker; + /// Alias for isLoading from BaseController for backwards compatibility + RxBool get isSaving => isLoading; List get supportedThemes => const ['light', 'dark', 'system']; @@ -53,17 +53,16 @@ class PreferencesController extends BaseController { super.onInit(); _syncFromSystem(); _hydrateFromUser(_profileController.user.value); - _userWorker = ever(_profileController.user, _hydrateFromUser); - _themeWorker = ever( + trackWorker(ever(_profileController.user, _hydrateFromUser)); + trackWorker(ever( _themeController.themeMode, (mode) => themeMode.value = _themeModeToString(mode), - ); + )); } @override void onClose() { - _userWorker?.dispose(); - _themeWorker?.dispose(); + // Workers are automatically disposed by BaseController via trackWorker super.onClose(); } @@ -84,52 +83,39 @@ class PreferencesController extends BaseController { if (prefLanguage != null && prefLanguage.isNotEmpty) { language.value = prefLanguage; } - autoLocation.value = _asBool(prefs['autoLocation'], fallback: false); - marketingEmails.value = _asBool(prefs['marketingEmails'], fallback: false); - travelAlerts.value = _asBool(prefs['travelAlerts'], fallback: true); + autoLocation.value = parseBool(prefs['autoLocation'], fallback: false); + marketingEmails.value = parseBool(prefs['marketingEmails'], fallback: false); + travelAlerts.value = parseBool(prefs['travelAlerts'], fallback: true); currency.value = (prefs['currency'] ?? currency.value).toString(); } - bool _asBool(dynamic value, {required bool fallback}) { - if (value is bool) return value; - if (value is num) return value != 0; - if (value is String) { - final normalized = value.toLowerCase(); - return normalized == 'true' || normalized == '1' || normalized == 'yes'; - } - return fallback; - } - Future save() async { - if (isSaving.value) return; - try { - isSaving.value = true; - final payload = { - 'theme': themeMode.value, - 'language': language.value, - 'autoLocation': autoLocation.value, - 'marketingEmails': marketingEmails.value, - 'travelAlerts': travelAlerts.value, - 'currency': currency.value, - }; + if (isLoading.value) return; + final payload = { + 'theme': themeMode.value, + 'language': language.value, + 'autoLocation': autoLocation.value, + 'marketingEmails': marketingEmails.value, + 'travelAlerts': travelAlerts.value, + 'currency': currency.value, + }; + final result = await executeWithErrorHandling(() async { final updatedUser = await _profileRepository.updatePreferences(payload); _profileController.updateUser(updatedUser); _profileController.updatePreferencesLocal(payload); + return updatedUser; + }, swallowError: true); + if (result != null) { feedbackMessage.value = 'Preferences updated'; - Get.snackbar( - 'Preferences', - feedbackMessage.value, - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.success( + title: 'Preferences', + message: feedbackMessage.value, ); - } catch (e, stack) { - AppLogger.error('Failed to update preferences', e, stack); - Get.snackbar( - 'Update failed', - 'We could not update your preferences. Please try again.', - snackPosition: SnackPosition.BOTTOM, + } else { + AppSnackbar.error( + title: 'Update failed', + message: 'We could not update your preferences. Please try again.', ); - } finally { - isSaving.value = false; } } diff --git a/lib/features/profile/controllers/privacy_controller.dart b/lib/features/profile/controllers/privacy_controller.dart index ac04636..407834e 100644 --- a/lib/features/profile/controllers/privacy_controller.dart +++ b/lib/features/profile/controllers/privacy_controller.dart @@ -6,7 +6,8 @@ import 'package:stays_app/app/data/models/user_model.dart'; import 'package:stays_app/app/data/repositories/auth_repository.dart'; import 'package:stays_app/app/data/repositories/profile_repository.dart'; import 'package:stays_app/app/routes/app_routes.dart'; -import 'package:stays_app/app/utils/logger/app_logger.dart'; +import 'package:stays_app/app/utils/extensions/dynamic_extensions.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/features/profile/controllers/profile_controller.dart'; class PrivacyController extends BaseController { @@ -30,7 +31,9 @@ class PrivacyController extends BaseController { final RxBool locationSharing = false.obs; final RxBool dataExportInFlight = false.obs; final RxBool accountDeletionInFlight = false.obs; - final RxBool isSaving = false.obs; + + /// Alias for isLoading from BaseController for backwards compatibility + RxBool get isSaving => isLoading; final TextEditingController currentPasswordController = TextEditingController(); @@ -38,13 +41,11 @@ class PrivacyController extends BaseController { final TextEditingController confirmPasswordController = TextEditingController(); - Worker? _userWorker; - @override void onInit() { super.onInit(); _hydrate(_profileController.user.value); - _userWorker = ever(_profileController.user, _hydrate); + trackWorker(ever(_profileController.user, _hydrate)); } @override @@ -52,32 +53,16 @@ class PrivacyController extends BaseController { currentPasswordController.dispose(); newPasswordController.dispose(); confirmPasswordController.dispose(); - _userWorker?.dispose(); + // Worker is automatically disposed by BaseController via trackWorker super.onClose(); } void _hydrate(UserModel? user) { if (user == null) return; final settings = user.privacySettings ?? {}; - twoFactorEnabled.value = _asBool( - settings['twoFactorEnabled'], - fallback: false, - ); - profileVisible.value = _asBool(settings['profileVisible'], fallback: true); - locationSharing.value = _asBool( - settings['locationSharing'], - fallback: false, - ); - } - - bool _asBool(dynamic value, {required bool fallback}) { - if (value is bool) return value; - if (value is num) return value != 0; - if (value is String) { - final normalized = value.toLowerCase(); - return normalized == 'true' || normalized == '1' || normalized == 'yes'; - } - return fallback; + twoFactorEnabled.value = parseBool(settings['twoFactorEnabled'], fallback: false); + profileVisible.value = parseBool(settings['profileVisible'], fallback: true); + locationSharing.value = parseBool(settings['locationSharing'], fallback: false); } void setTwoFactorEnabled(bool value) { @@ -93,31 +78,28 @@ class PrivacyController extends BaseController { } Future savePrivacySettings() async { - if (isSaving.value) return; - try { - isSaving.value = true; - final payload = { - 'twoFactorEnabled': twoFactorEnabled.value, - 'profileVisible': profileVisible.value, - 'locationSharing': locationSharing.value, - }; + if (isLoading.value) return; + final payload = { + 'twoFactorEnabled': twoFactorEnabled.value, + 'profileVisible': profileVisible.value, + 'locationSharing': locationSharing.value, + }; + final result = await executeWithErrorHandling(() async { final updated = await _profileRepository.updatePrivacySettings(payload); _profileController.updateUser(updated); _profileController.updatePrivacySettingsLocal(payload); - Get.snackbar( - 'Privacy & Security', - 'Settings updated successfully', - snackPosition: SnackPosition.BOTTOM, + return updated; + }); + if (result != null) { + AppSnackbar.success( + title: 'Privacy & Security', + message: 'Settings updated successfully', ); - } catch (e, stack) { - AppLogger.error('Failed to update privacy settings', e, stack); - Get.snackbar( - 'Update failed', - 'Unable to update privacy settings. Please try again.', - snackPosition: SnackPosition.BOTTOM, + } else { + AppSnackbar.error( + title: 'Update failed', + message: 'Unable to update privacy settings. Please try again.', ); - } finally { - isSaving.value = false; } } @@ -127,24 +109,21 @@ class PrivacyController extends BaseController { final confirm = confirmPasswordController.text.trim(); if (newPassword.length < 8) { - Get.snackbar( - 'Password', - 'Password must be at least 8 characters long.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Password', + message: 'Password must be at least 8 characters long.', ); return; } if (newPassword != confirm) { - Get.snackbar( - 'Password', - 'Passwords do not match.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Password', + message: 'Passwords do not match.', ); return; } - try { - isSaving.value = true; + final result = await executeWithErrorHandling(() async { await _authRepository.updatePassword( newPassword: newPassword, currentPassword: current.isEmpty ? null : current, @@ -152,42 +131,39 @@ class PrivacyController extends BaseController { currentPasswordController.clear(); newPasswordController.clear(); confirmPasswordController.clear(); - Get.snackbar( - 'Password updated', - 'Your password has been changed successfully.', - snackPosition: SnackPosition.BOTTOM, + return true; + }); + if (result == true) { + AppSnackbar.success( + title: 'Password updated', + message: 'Your password has been changed successfully.', ); - } catch (e, stack) { - AppLogger.error('Password update failed', e, stack); - Get.snackbar( - 'Password update failed', - 'We were unable to change your password. Please verify and retry.', - snackPosition: SnackPosition.BOTTOM, + } else { + AppSnackbar.error( + title: 'Password update failed', + message: 'We were unable to change your password. Please verify and retry.', ); - } finally { - isSaving.value = false; } } Future requestDataExport() async { if (dataExportInFlight.value) return; - try { - dataExportInFlight.value = true; + dataExportInFlight.value = true; + final result = await executeWithErrorHandling(() async { await _profileRepository.requestDataExport(); - Get.snackbar( - 'Data export requested', - 'We will email you when your data export is ready.', - snackPosition: SnackPosition.BOTTOM, + return true; + }, showLoading: false); + dataExportInFlight.value = false; + if (result == true) { + AppSnackbar.success( + title: 'Data export requested', + message: 'We will email you when your data export is ready.', ); - } catch (e, stack) { - AppLogger.error('Data export request failed', e, stack); - Get.snackbar( - 'Request failed', - 'Unable to request data export. Please try again later.', - snackPosition: SnackPosition.BOTTOM, + } else { + AppSnackbar.error( + title: 'Request failed', + message: 'Unable to request data export. Please try again later.', ); - } finally { - dataExportInFlight.value = false; } } @@ -216,25 +192,24 @@ class PrivacyController extends BaseController { false; if (!confirmDeletion) return; - try { - accountDeletionInFlight.value = true; + accountDeletionInFlight.value = true; + final result = await executeWithErrorHandling(() async { await _profileRepository.deleteAccount(); await _authController.logout(); + return true; + }, showLoading: false); + accountDeletionInFlight.value = false; + if (result == true) { Get.offAllNamed(Routes.login); - Get.snackbar( - 'Account deleted', - 'Your account has been removed successfully.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.success( + title: 'Account deleted', + message: 'Your account has been removed successfully.', ); - } catch (e, stack) { - AppLogger.error('Account deletion failed', e, stack); - Get.snackbar( - 'Deletion failed', - 'We could not delete your account. Please contact support.', - snackPosition: SnackPosition.BOTTOM, + } else { + AppSnackbar.error( + title: 'Deletion failed', + message: 'We could not delete your account. Please contact support.', ); - } finally { - accountDeletionInFlight.value = false; } } } diff --git a/lib/features/profile/controllers/profile_controller.dart b/lib/features/profile/controllers/profile_controller.dart index 0270344..c9dd5a4 100644 --- a/lib/features/profile/controllers/profile_controller.dart +++ b/lib/features/profile/controllers/profile_controller.dart @@ -9,6 +9,8 @@ import 'package:stays_app/app/data/models/trip_model.dart'; import 'package:stays_app/app/data/models/user_model.dart'; import 'package:stays_app/app/data/repositories/profile_repository.dart'; import 'package:stays_app/app/routes/app_routes.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; +import 'package:stays_app/app/utils/helpers/booking_helpers.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; class ProfileController extends BaseController { @@ -30,7 +32,6 @@ class ProfileController extends BaseController { final RxnString avatarUrl = RxnString(); final Rx memberSince = Rx(null); - // Note: isLoading and errorMessage are inherited from BaseController final RxBool isRefreshing = false.obs; final RxBool isActionInProgress = false.obs; @@ -61,27 +62,21 @@ class ProfileController extends BaseController { Future loadProfile({bool forceRefresh = false}) async { if (isLoading.value && !forceRefresh) return; - try { - if (forceRefresh) { - isRefreshing.value = true; - } else { - isLoading.value = true; - } + if (forceRefresh) { + isRefreshing.value = true; + } + await executeWithErrorHandling(() async { final profile = await _profileRepository.getProfile(); _authController.currentUser.value = profile; _applyUser(profile); await _loadPastTrips(); - } catch (e, stack) { - AppLogger.error('Failed to load profile', e, stack); - errorMessage.value = 'Failed to load your profile. Please try again.'; - Get.snackbar( - 'Profile', - errorMessage.value, - snackPosition: SnackPosition.BOTTOM, + }); + isRefreshing.value = false; + if (errorMessage.isNotEmpty) { + AppSnackbar.error( + title: 'Profile', + message: 'Failed to load your profile. Please try again.', ); - } finally { - isLoading.value = false; - isRefreshing.value = false; } } @@ -186,7 +181,7 @@ class ProfileController extends BaseController { for (final trip in pastTrips) { final diff = trip.checkOut.difference(trip.checkIn).inDays; nights += max(diff, 1); - if (_shouldIncludeInSpend(trip.status)) { + if (shouldCountBookingStatus(trip.status)) { spend += trip.totalCost ?? 0; } final key = trip.propertyName; @@ -199,26 +194,6 @@ class ProfileController extends BaseController { .key; } - bool _shouldIncludeInSpend(String? status) { - if (status == null) return false; - final normalized = status.trim().toLowerCase(); - if (normalized.isEmpty) return false; - - const negativeKeywords = [ - 'cancel', - 'refund', - 'fail', - 'decline', - 'reject', - 'void', - 'expired', - ]; - if (negativeKeywords.any((keyword) => normalized.contains(keyword))) { - return false; - } - return true; - } - void updateUser(UserModel updated) { _authController.currentUser.value = updated; _applyUser(updated); @@ -311,17 +286,15 @@ class ProfileController extends BaseController { await _authController.logout(); user.value = null; Get.offAllNamed(Routes.login); - Get.snackbar( - 'Signed out', - 'You have been logged out safely.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.success( + title: 'Signed out', + message: 'You have been logged out safely.', ); } catch (e, stack) { AppLogger.error('Logout failed', e, stack); - Get.snackbar( - 'Logout failed', - 'Please try again in a moment.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.error( + title: 'Logout failed', + message: 'Please try again in a moment.', ); } finally { isActionInProgress.value = false; diff --git a/lib/features/profile/views/preferences_view.dart b/lib/features/profile/views/preferences_view.dart index a7461ed..7748e12 100644 --- a/lib/features/profile/views/preferences_view.dart +++ b/lib/features/profile/views/preferences_view.dart @@ -30,7 +30,7 @@ class PreferencesView extends GetView { () => ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), children: [ - const _SectionHeader( + _SectionHeader( title: 'Appearance', subtitle: 'Choose how the app looks on your device.', ), @@ -48,7 +48,7 @@ class PreferencesView extends GetView { .toList(), ), const SizedBox(height: 24), - const _SectionHeader( + _SectionHeader( title: 'Language', subtitle: 'Switch the language used throughout the app.', ), @@ -67,7 +67,7 @@ class PreferencesView extends GetView { .toList(), ), const SizedBox(height: 24), - const _SectionHeader( + _SectionHeader( title: 'Location', subtitle: 'Enable automatic location to personalise stay suggestions.', @@ -82,7 +82,7 @@ class PreferencesView extends GetView { ), ), const SizedBox(height: 24), - const _SectionHeader( + _SectionHeader( title: 'Notifications', subtitle: 'Decide what kind of emails you would like to receive.', @@ -105,7 +105,7 @@ class PreferencesView extends GetView { ), ), const SizedBox(height: 24), - const _SectionHeader( + _SectionHeader( title: 'Currency', subtitle: 'Select your preferred currency for inquiries.', ), diff --git a/lib/features/profile/views/profile_view.dart b/lib/features/profile/views/profile_view.dart index 284e8dd..b743531 100644 --- a/lib/features/profile/views/profile_view.dart +++ b/lib/features/profile/views/profile_view.dart @@ -179,16 +179,18 @@ class ProfileView extends GetView { Expanded( child: Text( 'Profile completion', - style: textTheme.titleMedium?.copyWith( + style: textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, + fontSize: 16, ), ), ), Text( '${(controller.completion.value * 100).toInt()}%', - style: textTheme.titleMedium?.copyWith( + style: textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, color: colorScheme.primary, + fontSize: 16, ), ), ], @@ -210,6 +212,7 @@ class ProfileView extends GetView { : 'Complete your profile for faster inquiries and better recommendations.', style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, + fontSize: 12, ), ), ], @@ -256,8 +259,9 @@ class ProfileView extends GetView { children: [ Text( stat.value, - style: textTheme.titleMedium?.copyWith( + style: textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, + fontSize: 15, ), ), const SizedBox(height: 2), @@ -265,6 +269,7 @@ class ProfileView extends GetView { stat.label, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, + fontSize: 11, ), ), ], @@ -287,7 +292,10 @@ class ProfileView extends GetView { children: [ Text( title, - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ), ), const SizedBox(height: 12), Material( @@ -353,17 +361,18 @@ class ProfileView extends GetView { children: [ Text( 'Sign out', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.error, - ), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.error, + fontSize: 16, + ), ), const SizedBox(height: 4), Text( 'Securely logout from this device', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.error.withValues(alpha: 0.7), + fontSize: 12, ), ), ], @@ -412,7 +421,7 @@ class _MenuTile extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(18), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Container( @@ -431,8 +440,9 @@ class _MenuTile extends StatelessWidget { children: [ Text( title, - style: textTheme.titleMedium?.copyWith( + style: textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, + fontSize: 15, ), ), const SizedBox(height: 4), @@ -440,6 +450,7 @@ class _MenuTile extends StatelessWidget { subtitle, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, + fontSize: 12, ), ), ], diff --git a/lib/features/settings/controllers/settings_controller.dart b/lib/features/settings/controllers/settings_controller.dart index 18eb945..e8b1a6f 100644 --- a/lib/features/settings/controllers/settings_controller.dart +++ b/lib/features/settings/controllers/settings_controller.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/data/services/locale_service.dart'; import 'package:stays_app/l10n/localization_service.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; @@ -25,7 +24,7 @@ class ThemeOption { final IconData icon; } -class SettingsController extends BaseController { +class SettingsController extends GetxController { SettingsController({required ThemeController themeController}) : _themeController = themeController; diff --git a/lib/features/settings/controllers/theme_controller.dart b/lib/features/settings/controllers/theme_controller.dart index 7dd29ab..ba58eb6 100644 --- a/lib/features/settings/controllers/theme_controller.dart +++ b/lib/features/settings/controllers/theme_controller.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/data/services/theme_service.dart'; -class ThemeController extends BaseController { +class ThemeController extends GetxController { ThemeController({required ThemeService themeService}) : _themeService = themeService; diff --git a/lib/features/splash/controllers/splash_controller.dart b/lib/features/splash/controllers/splash_controller.dart index c13ee3f..9bdd0e3 100644 --- a/lib/features/splash/controllers/splash_controller.dart +++ b/lib/features/splash/controllers/splash_controller.dart @@ -1,13 +1,11 @@ import 'dart:async'; -import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; -import 'package:stays_app/app/data/services/app_update_service.dart'; import 'package:stays_app/app/data/services/push_notification_service.dart'; import 'package:stays_app/app/data/services/storage_service.dart'; -import 'package:stays_app/app/ui/widgets/dialogs/update_dialog.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:stays_app/app/routes/app_routes.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/app/utils/services/token_service.dart'; import 'package:stays_app/app/controllers/base/base_controller.dart'; @@ -15,6 +13,7 @@ import 'package:stays_app/app/controllers/base/base_controller.dart'; class SplashController extends BaseController { static const String _rememberMeBox = 'auth_preferences'; static const String _rememberMeFlagKey = 'remember_me'; + // Legacy keys from older builds (plaintext tokens). Kept for one-time cleanup. static const String _rememberedAccessTokenKey = 'remembered_access_token'; static const String _rememberedRefreshTokenKey = 'remembered_refresh_token'; @@ -80,27 +79,15 @@ class SplashController extends BaseController { } AppLogger.info( - 'Core initialization finished. Proceeding to update check...', + 'Core initialization finished. Proceeding to auth check...', ); - - // 3) Check for app updates - final shouldContinue = await _checkForAppUpdate(); - if (!shouldContinue) { - // Force update required - navigation already handled - return; - } - - AppLogger.info('Proceeding to auth check...'); unawaited(_navigateToNextScreen()); } catch (e, stackTrace) { AppLogger.error('CRITICAL STARTUP ERROR: $e', e, stackTrace); // If a critical service fails (like Storage), show error and fallback - Get.snackbar( - 'Initialization Failed', - 'Could not start the app. Please check your connection and restart.', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, + AppSnackbar.error( + title: 'Initialization Failed', + message: 'Could not start the app. Please check your connection and restart.', ); await Future.delayed(const Duration(seconds: 2)); unawaited(_navigateToNextScreen(forceLogin: true)); @@ -123,63 +110,70 @@ class SplashController extends BaseController { final prefs = GetStorage(_rememberMeBox); final bool rememberMeEnabled = prefs.read(_rememberMeFlagKey) ?? false; - final String? rememberedAccessToken = prefs.read( - _rememberedAccessTokenKey, - ); - final String? rememberedRefreshToken = prefs.read( - _rememberedRefreshTokenKey, - ); - final bool hasStoredToken = - rememberedAccessToken != null && - rememberedAccessToken.isNotEmpty && - rememberedRefreshToken != null && - rememberedRefreshToken.isNotEmpty; + + final storage = Get.find(); + final tokenService = Get.isRegistered() + ? Get.find() + : null; + if (tokenService != null) { + await tokenService.ready; + } + + final legacyRefresh = prefs.read(_rememberedRefreshTokenKey); var session = Supabase.instance.client.auth.currentSession; bool hasActiveSession = session != null && session.accessToken.isNotEmpty; - if (rememberMeEnabled && hasStoredToken && !hasActiveSession) { - session = await _restoreRememberedSession( - prefs: prefs, - refreshToken: rememberedRefreshToken, - ); - hasActiveSession = session != null && session.accessToken.isNotEmpty; - } - if (!rememberMeEnabled) { - if (hasActiveSession) { - AppLogger.info( - 'Remember-me disabled. Clearing existing Supabase session.', - ); - await Supabase.instance.client.auth.signOut(); - } - await prefs.remove(_rememberedAccessTokenKey); - await prefs.remove(_rememberedRefreshTokenKey); - AppLogger.info('Remember-me disabled. Navigating to login.'); + AppLogger.info('Remember-me disabled. Clearing session/tokens.'); + await _clearLegacyRememberedSession(prefs); + await _signOutAndClear(storage, tokenService); _navigated = true; _watchdog?.cancel(); unawaited(Get.offAllNamed(Routes.login)); return; } - if (rememberMeEnabled && hasStoredToken && hasActiveSession) { - AppLogger.info('Remember-me token found. Navigating to home.'); + // Try to restore Supabase session if none exists + if (!hasActiveSession) { + if (legacyRefresh != null && legacyRefresh.isNotEmpty) { + session = await _restoreSessionFromRefreshToken(legacyRefresh); + hasActiveSession = + session != null && session.accessToken.isNotEmpty; + } + if (!hasActiveSession) { + final secureRefresh = await storage.getRefreshToken(); + if (secureRefresh != null && secureRefresh.isNotEmpty) { + session = await _restoreSessionFromRefreshToken(secureRefresh); + hasActiveSession = + session != null && session.accessToken.isNotEmpty; + } + } + } + + if (hasActiveSession) { + await _syncTokenServiceFromSession(session!, storage, tokenService); + await _clearLegacyRememberedSession(prefs); + AppLogger.info('Active session detected. Navigating to home.'); _navigated = true; _watchdog?.cancel(); unawaited(Get.offAllNamed(Routes.home)); return; } - if (hasStoredToken && !hasActiveSession) { - AppLogger.warning( - 'Remember-me token present but Supabase session missing. Clearing cached token.', - ); - await prefs.remove(_rememberedAccessTokenKey); - await prefs.remove(_rememberedRefreshTokenKey); + // Fallback: if token service already has valid tokens, allow navigation. + if (tokenService?.hasValidToken == true) { + await _clearLegacyRememberedSession(prefs); + AppLogger.info('Valid tokens detected. Navigating to home.'); + _navigated = true; + _watchdog?.cancel(); + unawaited(Get.offAllNamed(Routes.home)); + return; } + await _clearLegacyRememberedSession(prefs); AppLogger.info( - 'Remember-me enabled but no valid session/token combination. Going to login.', + 'Remember-me enabled but no valid session/tokens. Going to login.', ); _navigated = true; _watchdog?.cancel(); @@ -195,13 +189,10 @@ class SplashController extends BaseController { } } - Future _restoreRememberedSession({ - required GetStorage prefs, - required String refreshToken, - }) async { + Future _restoreSessionFromRefreshToken(String refreshToken) async { try { AppLogger.info( - 'Attempting to restore Supabase session from stored refresh token.', + 'Attempting to restore Supabase session from refresh token.', ); final response = await Supabase.instance.client.auth.setSession( refreshToken, @@ -209,113 +200,67 @@ class SplashController extends BaseController { final restoredSession = response.session; if (restoredSession == null) { AppLogger.warning( - 'Supabase returned no session when restoring remember-me tokens.', + 'Supabase returned no session when restoring refresh token.', ); return null; } - // Keep TokenService in sync so downstream auth checks see tokens immediately - try { - final tokenService = Get.find(); - await tokenService.storeTokens( - accessToken: restoredSession.accessToken, - refreshToken: restoredSession.refreshToken, - ); - } catch (_) { - if (Get.isRegistered()) { - final storage = Get.find(); - await storage.saveTokens( - accessToken: restoredSession.accessToken, - refreshToken: restoredSession.refreshToken, - ); - } - } - await prefs.write(_rememberedAccessTokenKey, restoredSession.accessToken); - final newRefreshToken = restoredSession.refreshToken; - if (newRefreshToken != null && newRefreshToken.isNotEmpty) { - await prefs.write(_rememberedRefreshTokenKey, newRefreshToken); - } return restoredSession; } catch (e) { AppLogger.warning( - 'Failed to restore Supabase session from remember-me tokens: $e', + 'Failed to restore Supabase session from refresh token: $e', ); return null; } } - /// Check for app updates. - /// - /// Returns `true` if the app should continue to the next screen, - /// `false` if a force update is required (navigation already handled). - Future _checkForAppUpdate() async { + Future _syncTokenServiceFromSession( + Session session, + StorageService storageService, + TokenService? tokenService, + ) async { try { - // Initialize AppUpdateService if not already registered - if (!Get.isRegistered()) { - await Get.putAsync( - () => AppUpdateService().init(), - permanent: true, - ).timeout(const Duration(seconds: 10)); - AppLogger.info('AppUpdateService initialized.'); - } - - final updateService = Get.find(); - await updateService.checkForUpdate(); - - // Check if force update is required - if (updateService.isForceUpdate.value) { - AppLogger.info('Force update required. Navigating to update screen.'); - _navigated = true; - _watchdog?.cancel(); - unawaited(Get.offAllNamed(Routes.forceUpdate)); - return false; - } - - // Check if optional update is available and should be shown - if (updateService.isUpdateAvailable.value && - updateService.shouldShowUpdatePrompt()) { - AppLogger.info('Optional update available. Will show dialog after navigation.'); - // Schedule dialog to show after navigation completes - _scheduleOptionalUpdateDialog(updateService); + if (tokenService != null) { + await tokenService.storeTokens( + accessToken: session.accessToken, + refreshToken: session.refreshToken, + ); + return; } - - return true; - } catch (e) { - AppLogger.warning('Update check failed: $e. Continuing with app.'); - return true; + } catch (e, stackTrace) { + AppLogger.warning( + 'TokenService.storeTokens failed, falling back to StorageService: $e | $stackTrace', + ); } + await storageService.saveTokens( + accessToken: session.accessToken, + refreshToken: session.refreshToken, + ); } - /// Schedule the optional update dialog to show after navigation completes. - void _scheduleOptionalUpdateDialog(AppUpdateService updateService) { - // Use a post-frame callback to show the dialog after the next screen renders - WidgetsBinding.instance.addPostFrameCallback((_) { - // Wait a bit for the navigation to settle - Future.delayed(const Duration(milliseconds: 500), () { - _showOptionalUpdateDialog(updateService); - }); - }); + Future _clearLegacyRememberedSession(GetStorage prefs) async { + await prefs.remove(_rememberedAccessTokenKey); + await prefs.remove(_rememberedRefreshTokenKey); } - /// Show the optional update dialog. - void _showOptionalUpdateDialog(AppUpdateService updateService) { - final context = Get.context; - if (context == null) { - AppLogger.warning('No context available for update dialog.'); - return; + Future _signOutAndClear( + StorageService storageService, + TokenService? tokenService, + ) async { + try { + await Supabase.instance.client.auth.signOut(); + } catch (e) { + AppLogger.warning('Supabase signOut failed during cleanup: $e'); + } + try { + await tokenService?.clearTokens(); + } catch (e) { + AppLogger.warning('TokenService.clearTokens failed during cleanup: $e'); + } + try { + await storageService.clearTokens(); + await storageService.clearUserData(); + } catch (e) { + AppLogger.warning('StorageService cleanup failed: $e'); } - - showUpdateDialog( - context, - currentVersion: updateService.currentVersion, - newVersion: updateService.storeVersion.value, - releaseNotes: updateService.releaseNotes.value.isNotEmpty - ? updateService.releaseNotes.value - : null, - onUpdate: () => updateService.openStore(), - ).then((result) { - if (result == UpdateDialogResult.later) { - updateService.recordDismissal(); - } - }); } } diff --git a/lib/features/tour/controllers/tour_controller.dart b/lib/features/tour/controllers/tour_controller.dart index 2823b5d..8336ade 100644 --- a/lib/features/tour/controllers/tour_controller.dart +++ b/lib/features/tour/controllers/tour_controller.dart @@ -1,13 +1,12 @@ import 'package:get/get.dart'; import 'package:webview_flutter/webview_flutter.dart'; -import 'package:stays_app/app/controllers/base/base_controller.dart'; import 'package:stays_app/app/data/models/property_model.dart'; import 'package:stays_app/app/utils/helpers/webview_helper.dart'; -class TourController extends BaseController { +class TourController extends GetxController { final RxnString tourUrl = RxnString(); - // Note: isLoading is inherited from BaseController + final RxBool isLoading = true.obs; final RxBool hasError = false.obs; final RxInt progress = 0.obs; diff --git a/lib/features/tour/views/tour_view.dart b/lib/features/tour/views/tour_view.dart index 516c83d..5a32b27 100644 --- a/lib/features/tour/views/tour_view.dart +++ b/lib/features/tour/views/tour_view.dart @@ -6,6 +6,7 @@ import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; import 'package:stays_app/app/utils/helpers/webview_helper.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/features/tour/controllers/tour_controller.dart'; class TourView extends GetView { @@ -43,10 +44,9 @@ class TourView extends GetView { IconButton( tooltip: 'Fullscreen hint', onPressed: () { - Get.snackbar( - 'Fullscreen mode', - 'Rotate your device for the best experience.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.info( + title: 'Fullscreen mode', + message: 'Rotate your device for the best experience.', ); }, icon: const Icon(Icons.fullscreen), @@ -56,16 +56,14 @@ class TourView extends GetView { onPressed: () { final url = controller.tourUrl.value; if (url?.isNotEmpty == true) { - Get.snackbar( - 'Share tour', - 'Tour link copied to clipboard.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.success( + title: 'Share tour', + message: 'Tour link copied to clipboard.', ); } else { - Get.snackbar( - 'Share tour', - 'No tour link available.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Share tour', + message: 'No tour link available.', ); } }, diff --git a/lib/features/trips/controllers/trips_controller.dart b/lib/features/trips/controllers/trips_controller.dart index b3334d2..c56d7bd 100644 --- a/lib/features/trips/controllers/trips_controller.dart +++ b/lib/features/trips/controllers/trips_controller.dart @@ -9,12 +9,14 @@ import 'package:stays_app/app/data/repositories/booking_repository.dart'; import 'package:stays_app/app/data/repositories/properties_repository.dart'; import 'package:stays_app/app/controllers/filter_controller.dart'; import 'package:stays_app/app/utils/exceptions/app_exceptions.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; +import 'package:stays_app/app/utils/helpers/booking_helpers.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; +import 'package:stays_app/features/trips/views/widgets/booking_details_sheet.dart'; class TripsController extends BaseController { final RxList> pastBookings = >[].obs; - // Note: isLoading is inherited from BaseController late final BookingRepository _bookingRepository; PropertiesRepository? _propertiesRepository; @@ -22,7 +24,6 @@ class TripsController extends BaseController { final List> _allBookings = []; UnifiedFilterModel _activeFilters = UnifiedFilterModel.empty; - Worker? _filterWorker; @override void onInit() { @@ -41,7 +42,7 @@ class TripsController extends BaseController { if (!Get.isRegistered()) return; _filterController = Get.find(); _activeFilters = _filterController!.filterFor(FilterScope.booking); - _filterWorker = debounce( + trackWorker(debounce( _filterController!.rxFor(FilterScope.booking), (filters) { if (_activeFilters == filters) return; @@ -49,12 +50,12 @@ class TripsController extends BaseController { _applyFilters(); }, time: const Duration(milliseconds: 120), - ); + )); } @override void onClose() { - _filterWorker?.dispose(); + // Worker is automatically disposed by BaseController via trackWorker super.onClose(); } @@ -80,10 +81,9 @@ class TripsController extends BaseController { AppLogger.error('Failed to load bookings', error, stackTrace); if (forceRefresh || _allBookings.isEmpty) { pastBookings.clear(); - Get.snackbar( - 'Inquiries unavailable', - error.message, - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.error( + title: 'Inquiries unavailable', + message: error.message, ); } } catch (error, stackTrace) { @@ -94,10 +94,9 @@ class TripsController extends BaseController { ); if (forceRefresh || _allBookings.isEmpty) { pastBookings.clear(); - Get.snackbar( - 'Inquiries unavailable', - 'We could not load your inquiries right now. Please try again.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.error( + title: 'Inquiries unavailable', + message: 'We could not load your inquiries right now. Please try again.', ); } } finally { @@ -261,10 +260,9 @@ class TripsController extends BaseController { final parsedId = int.tryParse(bookingId); if (parsedId == null) { - Get.snackbar( - 'Unable to cancel', - 'This inquiry is missing a valid identifier.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.warning( + title: 'Unable to cancel', + message: 'This inquiry is missing a valid identifier.', ); return; } @@ -272,24 +270,21 @@ class TripsController extends BaseController { final snapshot = _setBookingStatusLocally(bookingId, status: 'cancelled'); try { await _bookingRepository.cancelBooking(bookingId: parsedId); - Get.snackbar( - 'Inquiry cancelled', - 'Your inquiry has been cancelled.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.success( + title: 'Inquiry cancelled', + message: 'Your inquiry has been cancelled.', ); } on ApiException catch (error) { _restoreBookingSnapshot(snapshot); - Get.snackbar( - 'Unable to cancel', - error.message, - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.error( + title: 'Unable to cancel', + message: error.message, ); } catch (_) { _restoreBookingSnapshot(snapshot); - Get.snackbar( - 'Unable to cancel', - 'We could not cancel this inquiry. Please try again.', - snackPosition: SnackPosition.BOTTOM, + AppSnackbar.error( + title: 'Unable to cancel', + message: 'We could not cancel this inquiry. Please try again.', ); } } @@ -299,13 +294,9 @@ class TripsController extends BaseController { int get totalHistoryCount => _allBookings.length; void rebookHotel(Map booking) { - Get.snackbar( - 'Rebooking', - 'Redirecting to ${booking['hotelName']} inquiry page', - snackPosition: SnackPosition.TOP, - backgroundColor: Colors.blue[50], - colorText: Colors.blue[800], - duration: const Duration(seconds: 2), + AppSnackbar.info( + title: 'Rebooking', + message: 'Redirecting to ${booking['hotelName']} inquiry page', ); // In real app: Get.toNamed('/inquiry', arguments: booking); } @@ -335,13 +326,9 @@ class TripsController extends BaseController { return IconButton( onPressed: () { Get.back(); - Get.snackbar( - 'Thank You!', - 'Your ${index + 1} star review has been submitted', - snackPosition: SnackPosition.TOP, - backgroundColor: Colors.green[50], - colorText: Colors.green[800], - duration: const Duration(seconds: 2), + AppSnackbar.success( + title: 'Thank You!', + message: 'Your ${index + 1} star review has been submitted', ); }, icon: const Icon( @@ -363,135 +350,19 @@ class TripsController extends BaseController { void viewBookingDetails(Map booking) { Get.bottomSheet( - Container( - padding: const EdgeInsets.all(24), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Handle - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 24), - - // Title - const Text( - 'Inquiry Details', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - - // Details - _buildDetailRow('Inquiry ID', booking['id']), - _buildDetailRow('Hotel', booking['hotelName']), - _buildDetailRow('Location', booking['location']), - _buildDetailRow('Check-in', _formatDate(booking['checkIn'])), - _buildDetailRow('Check-out', _formatDate(booking['checkOut'])), - _buildDetailRow('Guests', '${booking['guests']} guests'), - _buildDetailRow('Rooms', '${booking['rooms']} room(s)'), - _buildDetailRow( - 'Total Amount', - '\$${(booking['totalAmount'] as num?)?.toStringAsFixed(2) ?? '0.00'}', - ), - _buildDetailRow( - 'Status', - booking['status'].toString().toUpperCase(), - ), - - const SizedBox(height: 24), - - // Actions - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () { - Get.back(); - rebookHotel(booking); - }, - child: const Text('Inquire Again'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: () => Get.back(), - child: const Text('Close'), - ), - ), - ], - ), - ], - ), + BookingDetailsSheet( + booking: booking, + onRebook: () => rebookHotel(booking), ), isScrollControlled: true, ); } - Widget _buildDetailRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 100, - child: Text( - label, - style: TextStyle(color: Colors.grey[600], fontSize: 14), - ), - ), - Expanded( - child: Text( - value, - style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14), - ), - ), - ], - ), - ); - } - - String _formatDate(String dateStr) { - try { - final date = DateTime.parse(dateStr); - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return '${date.day} ${months[date.month - 1]}, ${date.year}'; - } catch (e) { - return dateStr; - } - } - int get totalBookings => pastBookings.length; double get totalSpent => pastBookings.fold(0, (sum, booking) { final status = booking['status']?.toString(); - if (!_shouldCountBookingStatus(status)) { + if (!shouldCountBookingStatus(status)) { return sum; } final amount = booking['totalAmount']; @@ -510,25 +381,6 @@ class TripsController extends BaseController { } return locations.entries.reduce((a, b) => a.value > b.value ? a : b).key; } - - bool _shouldCountBookingStatus(String? status) { - if (status == null) return false; - final normalized = status.trim().toLowerCase(); - if (normalized.isEmpty) return false; - const negativeKeywords = [ - 'cancel', - 'refund', - 'fail', - 'decline', - 'reject', - 'void', - 'expired', - ]; - if (negativeKeywords.any((keyword) => normalized.contains(keyword))) { - return false; - } - return true; - } } class _BookingSnapshot { diff --git a/lib/features/trips/views/trips_view.dart b/lib/features/trips/views/trips_view.dart index 03f8f43..7fbc9cc 100644 --- a/lib/features/trips/views/trips_view.dart +++ b/lib/features/trips/views/trips_view.dart @@ -19,7 +19,7 @@ class TripsView extends GetView { final colors = context.colors; return Scaffold( backgroundColor: colors.surface, - appBar: const LocationFilterAppBar(scope: FilterScope.booking), + appBar: LocationFilterAppBar(scope: FilterScope.booking), body: Obx(() { if (controller.isLoading.value && controller.pastBookings.isEmpty) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/features/trips/views/widgets/booking_details_sheet.dart b/lib/features/trips/views/widgets/booking_details_sheet.dart new file mode 100644 index 0000000..90b6c4d --- /dev/null +++ b/lib/features/trips/views/widgets/booking_details_sheet.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +/// A bottom sheet widget displaying detailed booking/inquiry information. +class BookingDetailsSheet extends StatelessWidget { + const BookingDetailsSheet({ + required this.booking, + required this.onRebook, + super.key, + }); + + final Map booking; + final VoidCallback onRebook; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 24), + + // Title + const Text( + 'Inquiry Details', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // Details + _buildDetailRow('Inquiry ID', booking['id']?.toString() ?? ''), + _buildDetailRow('Hotel', booking['hotelName']?.toString() ?? ''), + _buildDetailRow('Location', booking['location']?.toString() ?? ''), + _buildDetailRow('Check-in', _formatDate(booking['checkIn']?.toString() ?? '')), + _buildDetailRow('Check-out', _formatDate(booking['checkOut']?.toString() ?? '')), + _buildDetailRow('Guests', '${booking['guests'] ?? 0} guests'), + _buildDetailRow('Rooms', '${booking['rooms'] ?? 1} room(s)'), + _buildDetailRow( + 'Total Amount', + '\$${(booking['totalAmount'] as num?)?.toStringAsFixed(2) ?? '0.00'}', + ), + _buildDetailRow( + 'Status', + (booking['status']?.toString() ?? '').toUpperCase(), + ), + + const SizedBox(height: 24), + + // Actions + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + Get.back(); + onRebook(); + }, + child: const Text('Inquire Again'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () => Get.back(), + child: const Text('Close'), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: TextStyle(color: Colors.grey[600], fontSize: 14), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14), + ), + ), + ], + ), + ); + } + + String _formatDate(String dateStr) { + if (dateStr.isEmpty) return ''; + try { + final date = DateTime.parse(dateStr); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${date.day} ${months[date.month - 1]}, ${date.year}'; + } catch (e) { + return dateStr; + } + } +} diff --git a/lib/features/wishlist/controllers/wishlist_controller.dart b/lib/features/wishlist/controllers/wishlist_controller.dart index 8b72ebd..b20513b 100644 --- a/lib/features/wishlist/controllers/wishlist_controller.dart +++ b/lib/features/wishlist/controllers/wishlist_controller.dart @@ -5,6 +5,7 @@ import 'package:stays_app/app/data/models/property_model.dart'; import 'package:stays_app/app/data/models/unified_filter_model.dart'; import 'package:stays_app/app/data/models/unified_property_response.dart'; import 'package:stays_app/app/data/repositories/wishlist_repository.dart'; +import 'package:stays_app/app/utils/helpers/app_snackbar.dart'; import 'package:stays_app/app/utils/logger/app_logger.dart'; import 'package:stays_app/app/controllers/filter_controller.dart'; @@ -15,7 +16,6 @@ class WishlistController extends BaseController { FilterController? _filterController; final RxList wishlistItems = [].obs; - // Note: isLoading and errorMessage are inherited from BaseController final RxBool isRefreshing = false.obs; final RxInt currentPage = 1.obs; final RxInt totalPages = 1.obs; @@ -23,7 +23,6 @@ class WishlistController extends BaseController { final RxInt totalCount = 0.obs; UnifiedFilterModel _activeFilters = UnifiedFilterModel.empty; - Worker? _filterWorker; FavoritesController? _favoritesController; @override @@ -61,7 +60,7 @@ class WishlistController extends BaseController { } _filterController = Get.find(); _activeFilters = _filterController!.filterFor(FilterScope.wishlist); - _filterWorker = debounce( + trackWorker(debounce( _filterController!.rxFor(FilterScope.wishlist), (filters) async { if (_activeFilters == filters) return; @@ -69,12 +68,12 @@ class WishlistController extends BaseController { await loadWishlist(pageOverride: 1); }, time: const Duration(milliseconds: 160), - ); + )); } @override void onClose() { - _filterWorker?.dispose(); + // Worker is automatically disposed by BaseController via trackWorker super.onClose(); } @@ -167,11 +166,9 @@ class WishlistController extends BaseController { wishlistItems.insert(0, property); totalCount.value = wishlistItems.length; _favoritesController?.addFavorite(property.id); - Get.snackbar( - 'Added to Wishlist', - '${property.name} has been added to your wishlist', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), + AppSnackbar.success( + title: 'Added to Wishlist', + message: '${property.name} has been added to your wishlist', ); return; } @@ -179,19 +176,15 @@ class WishlistController extends BaseController { await _wishlistRepository!.add(property.id); _favoritesController?.addFavorite(property.id); await loadWishlist(pageOverride: currentPage.value); - Get.snackbar( - 'Added to Wishlist', - '${property.name} has been added to your wishlist', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), + AppSnackbar.success( + title: 'Added to Wishlist', + message: '${property.name} has been added to your wishlist', ); } catch (e) { AppLogger.error('Error adding to wishlist', e); - Get.snackbar( - 'Error', - 'Failed to add to wishlist. Please try again.', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), + AppSnackbar.error( + title: 'Error', + message: 'Failed to add to wishlist. Please try again.', ); } } @@ -203,13 +196,11 @@ class WishlistController extends BaseController { final property = propertyIndex != -1 ? wishlistItems[propertyIndex] : null; void showRemovalSnackbar() { - Get.snackbar( - 'Removed from Wishlist', - property != null + AppSnackbar.success( + title: 'Removed from Wishlist', + message: property != null ? '${property.name} has been removed from your wishlist' : 'Item has been removed from your wishlist', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), ); } @@ -250,11 +241,9 @@ class WishlistController extends BaseController { totalCount.value = totalCount.value + 1; _favoritesController?.addFavorite(propertyId); } - Get.snackbar( - 'Error', - 'Failed to remove from wishlist. Please try again.', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), + AppSnackbar.error( + title: 'Error', + message: 'Failed to remove from wishlist. Please try again.', ); } } @@ -290,30 +279,24 @@ class WishlistController extends BaseController { } _favoritesController?.clear(); await loadWishlist(pageOverride: 1); - Get.snackbar( - 'Wishlist Cleared', - 'All items have been removed from your wishlist', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), + AppSnackbar.success( + title: 'Wishlist Cleared', + message: 'All items have been removed from your wishlist', ); } catch (e) { AppLogger.error('Error clearing wishlist', e); - Get.snackbar( - 'Error', - 'Failed to clear wishlist. Please try again.', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), + AppSnackbar.error( + title: 'Error', + message: 'Failed to clear wishlist. Please try again.', ); } } else { wishlistItems.clear(); _favoritesController?.clear(); totalCount.value = 0; - Get.snackbar( - 'Wishlist Cleared', - 'All items have been removed from your wishlist', - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), + AppSnackbar.success( + title: 'Wishlist Cleared', + message: 'All items have been removed from your wishlist', ); } }, diff --git a/lib/features/wishlist/views/wishlist_view.dart b/lib/features/wishlist/views/wishlist_view.dart index e12b20e..963d74b 100644 --- a/lib/features/wishlist/views/wishlist_view.dart +++ b/lib/features/wishlist/views/wishlist_view.dart @@ -268,7 +268,7 @@ class WishlistView extends GetView { arguments: item, ), child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -319,22 +319,23 @@ class WishlistView extends GetView { children: [ Text( item.name, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, - style: textStyles.titleMedium?.copyWith( + style: textStyles.titleSmall?.copyWith( fontWeight: FontWeight.w700, color: colors.onSurface, - height: 1.2, + height: 1.1, + fontSize: 14, ), ), if (locationLine != null) ...[ - const SizedBox(height: 6), + const SizedBox(height: 4), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon( Icons.place_outlined, - size: 16, + size: 14, color: colors.primary, ), const SizedBox(width: 4), @@ -347,6 +348,7 @@ class WishlistView extends GetView { color: colors.onSurface.withValues( alpha: 0.7, ), + fontSize: 11, ), ), ), @@ -354,28 +356,30 @@ class WishlistView extends GetView { ), ], if (featureSummary.isNotEmpty) ...[ - const SizedBox(height: 10), + const SizedBox(height: 6), Row( children: [ Text( featureSummary, - style: textStyles.bodyMedium?.copyWith( - fontWeight: FontWeight.w700, + style: textStyles.bodySmall?.copyWith( + fontWeight: FontWeight.w600, color: colors.onSurface, + fontSize: 13, ), ), - const SizedBox(width: 10), + const SizedBox(width: 8), Text( '${item.displayPrice}/night', - style: textStyles.titleMedium?.copyWith( + style: textStyles.bodyMedium?.copyWith( fontWeight: FontWeight.w700, color: colors.primary, + fontSize: 14, ), ), ], ), ], - const SizedBox(height: 12), + const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: Wrap( @@ -446,18 +450,19 @@ class WishlistView extends GetView { final textStyles = context.textStyles; final widgets = []; - if (hasRating && ratingLabel != null) { - widgets.addAll([ - Icon(Icons.star_rounded, size: 16, color: Colors.amber), - const SizedBox(width: 4), - Text( - ratingLabel, - style: textStyles.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colors.onSurface.withValues(alpha: 0.8), - ), - ), - ]); + if (hasRating && ratingLabel != null) { + widgets.addAll([ + Icon(Icons.star_rounded, size: 14, color: Colors.amber), + const SizedBox(width: 4), + Text( + ratingLabel, + style: textStyles.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.8), + fontSize: 11, + ), + ), + ]); if (reviewsLabel != null) { widgets.addAll([ @@ -466,6 +471,7 @@ class WishlistView extends GetView { reviewsLabel, style: textStyles.bodySmall?.copyWith( color: colors.onSurface.withValues(alpha: 0.6), + fontSize: 11, ), ), ]); @@ -479,7 +485,7 @@ class WishlistView extends GetView { widgets.addAll([ Icon( Icons.directions_walk_outlined, - size: 16, + size: 14, color: colors.onSurface.withValues(alpha: 0.55), ), const SizedBox(width: 4), @@ -487,6 +493,7 @@ class WishlistView extends GetView { _formatDistance(distanceKm), style: textStyles.bodySmall?.copyWith( color: colors.onSurface.withValues(alpha: 0.6), + fontSize: 11, ), ), ]); diff --git a/lib/main.dart b/lib/main.dart index 9da321b..71ccc6a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,96 +1,111 @@ +import 'package:flutter/material.dart'; import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:get/get.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'config/app_config.dart'; +import 'app/routes/app_pages.dart'; +import 'l10n/localization_service.dart'; import 'app/bindings/initial_binding.dart'; import 'app/data/services/locale_service.dart'; -import 'app/data/services/supabase_service.dart'; -import 'app/data/services/theme_service.dart'; -import 'app/routes/app_pages.dart'; import 'app/ui/theme/app_theme.dart'; -import 'app/utils/performance/performance_monitor.dart'; +import 'app/data/services/theme_service.dart'; +import 'app/data/services/supabase_service.dart'; +import 'app/data/services/crash_reporting_service.dart'; import 'app/utils/security/cert_pinning.dart'; +import 'app/utils/logger/app_logger.dart'; +import 'app/utils/performance/performance_monitor.dart'; import 'app/utils/services/error_service.dart'; -import 'config/app_config.dart'; +import 'app/utils/security/security_service.dart'; import 'features/settings/controllers/theme_controller.dart'; -import 'l10n/localization_service.dart'; Future main() async { - WidgetsFlutterBinding.ensureInitialized(); + await runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); - // Default to dev if launched via lib/main.dart - await dotenv.load(fileName: '.env.dev'); - AppConfig.setConfig(AppConfig.dev()); - if (!Get.isRegistered()) { - Get.put(ErrorService(), permanent: true); - } - if (!Get.isRegistered()) { - Get.put(PerformanceMonitor(), permanent: true); - } + // Default to dev if launched via lib/main.dart + await dotenv.load(fileName: '.env.dev'); + AppConfig.setConfig(AppConfig.dev()); + if (!Get.isRegistered()) { + Get.put(ErrorService(), permanent: true); + } + if (!Get.isRegistered()) { + Get.put(PerformanceMonitor(), permanent: true); + } + SecurityService().validateApiKeys(); - // Optional certificate pinning when API_CERT_SHA256 is provided - final pinsRaw = dotenv.env['API_CERT_SHA256']; - if (pinsRaw != null && pinsRaw.trim().isNotEmpty) { - final host = Uri.parse(AppConfig.I.apiBaseUrl).host; - final pins = pinsRaw - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toSet(); - if (pins.isNotEmpty) { - HttpOverrides.global = PinningHttpOverrides( - allowedPins: pins, - host: host, - ); + // Optional certificate pinning when API_CERT_SHA256 is provided + final pinsRaw = dotenv.env['API_CERT_SHA256']; + if (pinsRaw != null && pinsRaw.trim().isNotEmpty) { + final host = Uri.parse(AppConfig.I.apiBaseUrl).host; + final pins = pinsRaw + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toSet(); + if (pins.isNotEmpty) { + HttpOverrides.global = PinningHttpOverrides( + allowedPins: pins, + host: host, + ); + } } - } - // Initialize Supabase service (required before other services) - final supabaseService = SupabaseService( - url: AppConfig.I.supabaseUrl, - anonKey: AppConfig.I.supabaseAnonKey, - ); + // Initialize Supabase service (required before other services) + final supabaseService = SupabaseService( + url: AppConfig.I.supabaseUrl, + anonKey: AppConfig.I.supabaseAnonKey, + ); - // Parallelize initialization of independent services for faster startup - late ThemeService themeService; - late LocaleService localeService; + // Parallelize initialization of independent services for faster startup + late ThemeService themeService; + late LocaleService localeService; - await Future.wait([ - // Supabase initialization (critical) - supabaseService.initialize(), - // Theme service initialization - ThemeService().init().then((service) => themeService = service), - // Locale service initialization - LocaleService().init().then((service) => localeService = service), - // Orientation lock (lightweight, run in parallel) - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]), - ]); + await Future.wait([ + // Supabase initialization (critical) + supabaseService.initialize(), + // Theme service initialization + ThemeService().init().then((service) => themeService = service), + // Locale service initialization + LocaleService().init().then((service) => localeService = service), + // Orientation lock (lightweight, run in parallel) + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]), + ]); - // Register services with GetX after initialization - if (!Get.isRegistered()) { - Get.put(supabaseService, permanent: true); - } - Get.put(themeService, permanent: true); - Get.put(localeService, permanent: true); + // Register services with GetX after initialization + if (!Get.isRegistered()) { + Get.put(supabaseService, permanent: true); + } + Get.put(themeService, permanent: true); + Get.put(localeService, permanent: true); - // Load translations (depends on localeService) - await LocalizationService.init(localeService); - unawaited(Get.updateLocale(LocalizationService.initialLocale)); + // Load translations (depends on localeService) + await LocalizationService.init(localeService); + unawaited(Get.updateLocale(LocalizationService.initialLocale)); - Get.put( - ThemeController(themeService: themeService), - permanent: true, - ); + Get.put( + ThemeController(themeService: themeService), + permanent: true, + ); - runApp(const MyApp()); + runApp(const MyApp()); + }, (error, stackTrace) async { + AppLogger.error('Uncaught zone error', error, stackTrace); + if (Get.isRegistered()) { + await Get.find().recordError( + error, + stackTrace: stackTrace, + fatal: true, + ); + } + }); } class MyApp extends StatelessWidget { diff --git a/lib/main_dev.dart b/lib/main_dev.dart index 124a833..f723630 100644 --- a/lib/main_dev.dart +++ b/lib/main_dev.dart @@ -1,89 +1,112 @@ +import 'package:flutter/material.dart'; import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:get/get.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'config/app_config.dart'; import 'app/bindings/initial_binding.dart'; -import 'app/data/services/locale_service.dart'; -import 'app/data/services/theme_service.dart'; import 'app/routes/app_pages.dart'; +import 'l10n/localization_service.dart'; +import 'app/data/services/locale_service.dart'; import 'app/ui/theme/app_theme.dart'; +import 'app/data/services/theme_service.dart'; +import 'app/data/services/supabase_service.dart'; +import 'app/data/services/crash_reporting_service.dart'; +import 'features/settings/controllers/theme_controller.dart'; +import 'app/utils/security/cert_pinning.dart'; import 'app/utils/logger/app_logger.dart'; import 'app/utils/performance/performance_monitor.dart'; -import 'app/utils/security/cert_pinning.dart'; import 'app/utils/security/security_service.dart'; import 'app/utils/services/error_service.dart'; -import 'config/app_config.dart'; -import 'features/settings/controllers/theme_controller.dart'; -import 'l10n/localization_service.dart'; Future main() async { - WidgetsFlutterBinding.ensureInitialized(); + await runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: '.env.dev'); - AppConfig.setConfig(AppConfig.dev()); - if (!Get.isRegistered()) { - Get.put(ErrorService(), permanent: true); - } - if (!Get.isRegistered()) { - Get.put(PerformanceMonitor(), permanent: true); - } - // Validate high-level API keys - if (Get.isRegistered()) { - SecurityService.I.validateApiKeys(); - } - final pinsRaw = dotenv.env['API_CERT_SHA256']; - if (pinsRaw != null && pinsRaw.trim().isNotEmpty) { - final host = Uri.parse(AppConfig.I.apiBaseUrl).host; - final pins = pinsRaw - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toSet(); - if (pins.isNotEmpty) { - HttpOverrides.global = PinningHttpOverrides( - allowedPins: pins, - host: host, - ); - AppLogger.info('Certificate pinning enabled for $host'); + await dotenv.load(fileName: '.env.dev'); + AppConfig.setConfig(AppConfig.dev()); + if (!Get.isRegistered()) { + Get.put(ErrorService(), permanent: true); + } + if (!Get.isRegistered()) { + Get.put(PerformanceMonitor(), permanent: true); + } + // Validate high-level API keys + SecurityService().validateApiKeys(); + + // Initialize Supabase service (required before other services) + final supabaseService = SupabaseService( + url: AppConfig.I.supabaseUrl, + anonKey: AppConfig.I.supabaseAnonKey, + ); + final pinsRaw = dotenv.env['API_CERT_SHA256']; + if (pinsRaw != null && pinsRaw.trim().isNotEmpty) { + final host = Uri.parse(AppConfig.I.apiBaseUrl).host; + final pins = pinsRaw + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toSet(); + if (pins.isNotEmpty) { + HttpOverrides.global = PinningHttpOverrides( + allowedPins: pins, + host: host, + ); + AppLogger.info('Certificate pinning enabled for $host'); + } } - } - // Parallelize initialization of independent services for faster startup - late ThemeService themeService; - late LocaleService localeService; + // Parallelize initialization of independent services for faster startup + late ThemeService themeService; + late LocaleService localeService; - await Future.wait([ - // Theme service initialization - ThemeService().init().then((service) => themeService = service), - // Locale service initialization - LocaleService().init().then((service) => localeService = service), - // Orientation lock (lightweight, run in parallel) - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]), - ]); + await Future.wait([ + // Supabase initialization (critical) + supabaseService.initialize(), + // Theme service initialization + ThemeService().init().then((service) => themeService = service), + // Locale service initialization + LocaleService().init().then((service) => localeService = service), + // Orientation lock (lightweight, run in parallel) + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]), + ]); - // Register services with GetX after initialization - Get.put(themeService, permanent: true); - Get.put(localeService, permanent: true); + // Register services with GetX after initialization + if (!Get.isRegistered()) { + Get.put(supabaseService, permanent: true); + } + Get.put(themeService, permanent: true); + Get.put(localeService, permanent: true); - Get.put( - ThemeController(themeService: themeService), - permanent: true, - ); + Get.put( + ThemeController(themeService: themeService), + permanent: true, + ); - await LocalizationService.init(localeService); - unawaited(Get.updateLocale(LocalizationService.initialLocale)); - AppLogger.info( - 'Localization initialized with locale: ${LocalizationService.initialLocale}', - ); + await LocalizationService.init(localeService); + unawaited(Get.updateLocale(LocalizationService.initialLocale)); + AppLogger.info( + 'Localization initialized with locale: ${LocalizationService.initialLocale}', + ); - runApp(const MyApp()); + runApp(const MyApp()); + }, (error, stackTrace) async { + AppLogger.error('Uncaught zone error', error, stackTrace); + if (Get.isRegistered()) { + await Get.find().recordError( + error, + stackTrace: stackTrace, + fatal: true, + ); + } + }); } class MyApp extends StatelessWidget { @@ -92,19 +115,27 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { final themeController = Get.find(); - return GetMaterialApp( - title: '360ghar stays (Dev)', - theme: AppTheme.lightTheme, - darkTheme: AppTheme.darkTheme, - themeMode: themeController.themeMode.value, - translations: LocalizationService(), - locale: LocalizationService.initialLocale, - fallbackLocale: LocalizationService.fallbackLocale, - supportedLocales: LocalizationService.locales, - initialBinding: InitialBinding(), - initialRoute: AppPages.initial, - getPages: AppPages.routes, - debugShowCheckedModeBanner: false, - ); + return Obx(() { + final currentLocale = Get.locale ?? LocalizationService.initialLocale; + return GetMaterialApp( + title: '360ghar stays (Dev)', + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeController.themeMode.value, + translations: LocalizationService(), + locale: currentLocale, + fallbackLocale: LocalizationService.fallbackLocale, + supportedLocales: LocalizationService.locales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + initialBinding: InitialBinding(), + initialRoute: AppPages.initial, + getPages: AppPages.routes, + debugShowCheckedModeBanner: false, + ); + }); } } diff --git a/lib/main_prod.dart b/lib/main_prod.dart index 0bb0a0f..bab7ab7 100644 --- a/lib/main_prod.dart +++ b/lib/main_prod.dart @@ -1,62 +1,48 @@ +import 'package:flutter/material.dart'; import 'dart:async'; import 'dart:io'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:get/get.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'config/app_config.dart'; import 'app/bindings/initial_binding.dart'; -import 'app/data/services/crash_reporting_service.dart'; -import 'app/data/services/locale_service.dart'; -import 'app/data/services/theme_service.dart'; import 'app/routes/app_pages.dart'; +import 'l10n/localization_service.dart'; +import 'app/data/services/locale_service.dart'; import 'app/ui/theme/app_theme.dart'; -import 'app/utils/performance/performance_monitor.dart'; +import 'app/data/services/theme_service.dart'; +import 'app/data/services/supabase_service.dart'; +import 'app/data/services/crash_reporting_service.dart'; +import 'features/settings/controllers/theme_controller.dart'; import 'app/utils/security/cert_pinning.dart'; +import 'app/utils/logger/app_logger.dart'; +import 'app/utils/performance/performance_monitor.dart'; import 'app/utils/security/security_service.dart'; import 'app/utils/services/error_service.dart'; -import 'config/app_config.dart'; -import 'features/settings/controllers/theme_controller.dart'; -import 'l10n/localization_service.dart'; Future main() async { - // Wrap the entire app in a zone to catch all errors - runZonedGuarded>(() async { + runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); - - // Load environment and config await dotenv.load(fileName: '.env.prod'); AppConfig.setConfig(AppConfig.prod()); - - // Initialize Firebase first await Firebase.initializeApp(); - - // Initialize Crashlytics early to catch startup errors - FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; - - // Register core services if (!Get.isRegistered()) { Get.put(ErrorService(), permanent: true); } if (!Get.isRegistered()) { Get.put(PerformanceMonitor(), permanent: true); } + SecurityService().validateApiKeys(); - // Initialize crash reporting service - final crashReportingService = CrashReportingService(); - await crashReportingService.init(); - Get.put(crashReportingService, permanent: true); - - // Validate API keys in production - if (Get.isRegistered()) { - SecurityService.I.validateApiKeys(); - } - - // Set up certificate pinning for production + // Initialize Supabase service (required before other services) + final supabaseService = SupabaseService( + url: AppConfig.I.supabaseUrl, + anonKey: AppConfig.I.supabaseAnonKey, + ); final pinsRaw = dotenv.env['API_CERT_SHA256']; if (pinsRaw != null && pinsRaw.trim().isNotEmpty) { final host = Uri.parse(AppConfig.I.apiBaseUrl).host; @@ -78,6 +64,8 @@ Future main() async { late LocaleService localeService; await Future.wait([ + // Supabase initialization (critical) + supabaseService.initialize(), // Theme service initialization ThemeService().init().then((service) => themeService = service), // Locale service initialization @@ -90,6 +78,9 @@ Future main() async { ]); // Register services with GetX after initialization + if (!Get.isRegistered()) { + Get.put(supabaseService, permanent: true); + } Get.put(themeService, permanent: true); Get.put(localeService, permanent: true); @@ -101,9 +92,18 @@ Future main() async { await LocalizationService.init(localeService); unawaited(Get.updateLocale(LocalizationService.initialLocale)); runApp(const MyApp()); - }, (error, stack) { - // Catch any errors that weren't caught by Flutter's error handling - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + }, + (error, stackTrace) { + AppLogger.error('Uncaught zone error', error, stackTrace); + if (Get.isRegistered()) { + unawaited( + Get.find().recordError( + error, + stackTrace: stackTrace, + fatal: true, + ), + ); + } }); } diff --git a/lib/main_staging.dart b/lib/main_staging.dart index de15a66..178bb89 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -1,82 +1,105 @@ +import 'package:flutter/material.dart'; import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:get/get.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'config/app_config.dart'; import 'app/bindings/initial_binding.dart'; -import 'app/data/services/locale_service.dart'; -import 'app/data/services/theme_service.dart'; import 'app/routes/app_pages.dart'; +import 'l10n/localization_service.dart'; +import 'app/data/services/locale_service.dart'; import 'app/ui/theme/app_theme.dart'; -import 'app/utils/performance/performance_monitor.dart'; +import 'app/data/services/theme_service.dart'; +import 'app/data/services/supabase_service.dart'; +import 'app/data/services/crash_reporting_service.dart'; +import 'features/settings/controllers/theme_controller.dart'; import 'app/utils/security/cert_pinning.dart'; +import 'app/utils/logger/app_logger.dart'; +import 'app/utils/performance/performance_monitor.dart'; import 'app/utils/security/security_service.dart'; import 'app/utils/services/error_service.dart'; -import 'config/app_config.dart'; -import 'features/settings/controllers/theme_controller.dart'; -import 'l10n/localization_service.dart'; Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(fileName: '.env.staging'); - AppConfig.setConfig(AppConfig.staging()); - if (!Get.isRegistered()) { - Get.put(ErrorService(), permanent: true); - } - if (!Get.isRegistered()) { - Get.put(PerformanceMonitor(), permanent: true); - } - if (Get.isRegistered()) { - SecurityService.I.validateApiKeys(); - } - final pinsRaw = dotenv.env['API_CERT_SHA256']; - if (pinsRaw != null && pinsRaw.trim().isNotEmpty) { - final host = Uri.parse(AppConfig.I.apiBaseUrl).host; - final pins = pinsRaw - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toSet(); - if (pins.isNotEmpty) { - HttpOverrides.global = PinningHttpOverrides( - allowedPins: pins, - host: host, - ); + await runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: '.env.staging'); + AppConfig.setConfig(AppConfig.staging()); + if (!Get.isRegistered()) { + Get.put(ErrorService(), permanent: true); + } + if (!Get.isRegistered()) { + Get.put(PerformanceMonitor(), permanent: true); + } + SecurityService().validateApiKeys(); + + // Initialize Supabase service (required before other services) + final supabaseService = SupabaseService( + url: AppConfig.I.supabaseUrl, + anonKey: AppConfig.I.supabaseAnonKey, + ); + final pinsRaw = dotenv.env['API_CERT_SHA256']; + if (pinsRaw != null && pinsRaw.trim().isNotEmpty) { + final host = Uri.parse(AppConfig.I.apiBaseUrl).host; + final pins = pinsRaw + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toSet(); + if (pins.isNotEmpty) { + HttpOverrides.global = PinningHttpOverrides( + allowedPins: pins, + host: host, + ); + } } - } - // Parallelize initialization of independent services for faster startup - late ThemeService themeService; - late LocaleService localeService; + // Parallelize initialization of independent services for faster startup + late ThemeService themeService; + late LocaleService localeService; - await Future.wait([ - // Theme service initialization - ThemeService().init().then((service) => themeService = service), - // Locale service initialization - LocaleService().init().then((service) => localeService = service), - // Orientation lock (lightweight, run in parallel) - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]), - ]); + await Future.wait([ + // Supabase initialization (critical) + supabaseService.initialize(), + // Theme service initialization + ThemeService().init().then((service) => themeService = service), + // Locale service initialization + LocaleService().init().then((service) => localeService = service), + // Orientation lock (lightweight, run in parallel) + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]), + ]); - // Register services with GetX after initialization - Get.put(themeService, permanent: true); - Get.put(localeService, permanent: true); + // Register services with GetX after initialization + if (!Get.isRegistered()) { + Get.put(supabaseService, permanent: true); + } + Get.put(themeService, permanent: true); + Get.put(localeService, permanent: true); - Get.put( - ThemeController(themeService: themeService), - permanent: true, - ); + Get.put( + ThemeController(themeService: themeService), + permanent: true, + ); - await LocalizationService.init(localeService); - unawaited(Get.updateLocale(LocalizationService.initialLocale)); - runApp(const MyApp()); + await LocalizationService.init(localeService); + unawaited(Get.updateLocale(LocalizationService.initialLocale)); + runApp(const MyApp()); + }, (error, stackTrace) async { + AppLogger.error('Uncaught zone error', error, stackTrace); + if (Get.isRegistered()) { + await Get.find().recordError( + error, + stackTrace: stackTrace, + fatal: true, + ); + } + }); } class MyApp extends StatelessWidget { diff --git a/pubspec.lock b/pubspec.lock index 956e518..8e90339 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -173,10 +173,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -487,7 +487,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -1032,26 +1032,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1581,10 +1581,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" timezone: dependency: transitive description: @@ -1858,5 +1858,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 884e8ea..c1e4996 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.1+2 environment: sdk: ^3.9.0 @@ -38,6 +38,7 @@ dependencies: json_annotation: ^4.9.0 logger: ^2.0.2+1 cached_network_image: ^3.3.1 + flutter_cache_manager: ^3.3.1 image_picker: ^1.0.7 package_info_plus: ^8.0.0 shimmer: ^3.0.0 diff --git a/test/unit/controllers/auth/auth_controller_test.dart b/test/unit/controllers/auth/auth_controller_test.dart index 004f327..946844f 100644 --- a/test/unit/controllers/auth/auth_controller_test.dart +++ b/test/unit/controllers/auth/auth_controller_test.dart @@ -5,123 +5,119 @@ import 'package:mockito/mockito.dart'; import 'package:stays_app/features/auth/controllers/auth_controller.dart'; import 'package:stays_app/features/auth/controllers/form_validation_controller.dart'; -import 'package:stays_app/features/auth/controllers/session_controller.dart'; import 'package:stays_app/app/data/repositories/auth_repository.dart'; +import 'package:stays_app/app/data/repositories/profile_repository.dart'; import 'package:stays_app/app/utils/services/token_service.dart'; import 'package:stays_app/app/utils/services/validation_service.dart'; - -@GenerateMocks([AuthRepository, TokenService, SessionController]) -import 'auth_controller_test.mocks.dart'; - -void main() { - setUp(() { - Get.testMode = true; - Get.reset(); - }); - - tearDown(() { - Get.reset(); - }); - + + @GenerateMocks([AuthRepository, TokenService, ProfileRepository]) + import 'auth_controller_test.mocks.dart'; + + void main() { + + setUp(() { + Get.testMode = true; + Get.reset(); + }); + + tearDown(() { + Get.reset(); + }); + group('AuthController initialization', () { late MockAuthRepository mockAuthRepository; - late MockSessionController mockSessionController; + late MockTokenService mockTokenService; + late MockProfileRepository mockProfileRepository; late AuthController authController; setUp(() { mockAuthRepository = MockAuthRepository(); - mockSessionController = MockSessionController(); - - when(mockSessionController.ready).thenAnswer((_) async {}); - when(mockSessionController.isAuthenticated).thenReturn(false.obs); - when(mockSessionController.rememberMe).thenReturn(false.obs); - + mockTokenService = MockTokenService(); + mockProfileRepository = MockProfileRepository(); + + when(mockTokenService.ready).thenAnswer((_) async {}); + when(mockTokenService.isAuthenticated).thenReturn(false.obs); + when(mockTokenService.clearTokens()).thenAnswer((_) async {}); + Get.put(ValidationService()); Get.put(FormValidationController()); - + authController = AuthController( authRepository: mockAuthRepository, - sessionController: mockSessionController, + tokenService: mockTokenService, + profileRepository: mockProfileRepository, ); }); - test('should initialize with unauthenticated state', () { - expect(authController.isAuthenticated.value, false); - expect(authController.currentUser.value, null); - }); - - test('should have password visibility off by default', () { - expect(authController.isPasswordVisible.value, false); - }); - - test('should toggle password visibility', () { - expect(authController.isPasswordVisible.value, false); - - authController.togglePasswordVisibility(); - expect(authController.isPasswordVisible.value, true); - - authController.togglePasswordVisibility(); - expect(authController.isPasswordVisible.value, false); - }); - }); - + test('should initialize with unauthenticated state', () { + expect(authController.isAuthenticated.value, false); + expect(authController.currentUser.value, null); + }); + + test('should have password visibility off by default', () { + expect(authController.isPasswordVisible.value, false); + }); + + test('should toggle password visibility', () { + expect(authController.isPasswordVisible.value, false); + + authController.togglePasswordVisibility(); + expect(authController.isPasswordVisible.value, true); + + authController.togglePasswordVisibility(); + expect(authController.isPasswordVisible.value, false); + }); + }); + group('FormValidationController', () { late FormValidationController validationController; - + setUp(() { Get.put(ValidationService()); - validationController = - Get.put(FormValidationController()); + validationController = Get.put(FormValidationController()); }); - + test('should validate email format correctly', () { - final result = - validationController.validateEmailOrPhone('test@example.com'); + final result = validationController.validateEmailOrPhone('test@example.com'); expect(result, isNull); }); - + test('should reject invalid email format', () { - final result = - validationController.validateEmailOrPhone('invalid-email'); + final result = validationController.validateEmailOrPhone('invalid-email'); expect(result, isNotNull); }); - + test('should validate phone format correctly', () { final result = validationController.validateEmailOrPhone('9876543210'); expect(result, isNull); }); - + test('should validate password length', () { final shortPassword = validationController.validatePassword('123'); expect(shortPassword, isNotNull); - - final validPassword = - validationController.validatePassword('password123'); + + final validPassword = validationController.validatePassword('Password123!'); expect(validPassword, isNull); }); - + test('should validate confirm password matches', () { - final mismatch = - validationController.validateConfirmPassword('pass1', 'pass2'); + final mismatch = validationController.validateConfirmPassword('pass1', 'pass2'); expect(mismatch, isNotNull); - - final match = validationController.validateConfirmPassword( - 'password', - 'password', - ); + + final match = validationController.validateConfirmPassword('password', 'password'); expect(match, isNull); }); - + test('should clear all errors', () { validationController.emailOrPhoneError.value = 'Error'; validationController.passwordError.value = 'Error'; validationController.confirmPasswordError.value = 'Error'; - + validationController.clearErrors(); - + expect(validationController.emailOrPhoneError.value, isEmpty); expect(validationController.passwordError.value, isEmpty); expect(validationController.confirmPasswordError.value, isEmpty); }); - }); -} + }); + } diff --git a/test/unit/controllers/auth/auth_controller_test.mocks.dart b/test/unit/controllers/auth/auth_controller_test.mocks.dart index 9269bfd..fd6424d 100644 --- a/test/unit/controllers/auth/auth_controller_test.mocks.dart +++ b/test/unit/controllers/auth/auth_controller_test.mocks.dart @@ -3,18 +3,16 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; -import 'dart:ui' as _i10; +import 'dart:async' as _i5; +import 'dart:io' as _i8; import 'package:get/get.dart' as _i3; -import 'package:get/get_state_manager/src/simple/list_notifier.dart' as _i9; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i8; +import 'package:mockito/src/dummies.dart' as _i9; import 'package:stays_app/app/data/models/user_model.dart' as _i2; -import 'package:stays_app/app/data/repositories/auth_repository.dart' as _i5; +import 'package:stays_app/app/data/repositories/auth_repository.dart' as _i4; +import 'package:stays_app/app/data/repositories/profile_repository.dart' as _i7; import 'package:stays_app/app/utils/services/token_service.dart' as _i6; -import 'package:stays_app/features/auth/controllers/session_controller.dart' - as _i7; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -29,6 +27,7 @@ import 'package:stays_app/features/auth/controllers/session_controller.dart' // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeUserModel_0 extends _i1.SmartFake implements _i2.UserModel { _FakeUserModel_0(Object parent, Invocation parentInvocation) @@ -46,26 +45,16 @@ class _FakeInternalFinalCallback_2 extends _i1.SmartFake : super(parent, parentInvocation); } -class _FakeRxString_3 extends _i1.SmartFake implements _i3.RxString { - _FakeRxString_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeCompleter_4 extends _i1.SmartFake implements _i4.Completer { - _FakeCompleter_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - /// A class which mocks [AuthRepository]. /// /// See the documentation for Mockito's code generation for more information. -class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { +class MockAuthRepository extends _i1.Mock implements _i4.AuthRepository { MockAuthRepository() { _i1.throwOnMissingStub(this); } @override - _i4.Future<_i2.UserModel> loginWithEmail({ + _i5.Future<_i2.UserModel> loginWithEmail({ required String? email, required String? password, }) => @@ -74,7 +63,7 @@ class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { #email: email, #password: password, }), - returnValue: _i4.Future<_i2.UserModel>.value( + returnValue: _i5.Future<_i2.UserModel>.value( _FakeUserModel_0( this, Invocation.method(#loginWithEmail, [], { @@ -84,10 +73,10 @@ class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { ), ), ) - as _i4.Future<_i2.UserModel>); + as _i5.Future<_i2.UserModel>); @override - _i4.Future<_i2.UserModel> loginWithPhone({ + _i5.Future<_i2.UserModel> loginWithPhone({ required String? phone, required String? password, }) => @@ -96,7 +85,7 @@ class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { #phone: phone, #password: password, }), - returnValue: _i4.Future<_i2.UserModel>.value( + returnValue: _i5.Future<_i2.UserModel>.value( _FakeUserModel_0( this, Invocation.method(#loginWithPhone, [], { @@ -106,10 +95,10 @@ class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { ), ), ) - as _i4.Future<_i2.UserModel>); + as _i5.Future<_i2.UserModel>); @override - _i4.Future<_i2.UserModel> register({ + _i5.Future<_i2.UserModel> register({ required String? name, required String? email, required String? password, @@ -120,7 +109,7 @@ class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { #email: email, #password: password, }), - returnValue: _i4.Future<_i2.UserModel>.value( + returnValue: _i5.Future<_i2.UserModel>.value( _FakeUserModel_0( this, Invocation.method(#register, [], { @@ -131,10 +120,10 @@ class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { ), ), ) - as _i4.Future<_i2.UserModel>); + as _i5.Future<_i2.UserModel>); @override - _i4.Future signUpWithPhone({ + _i5.Future signUpWithPhone({ required String? phone, required String? password, }) => @@ -143,12 +132,12 @@ class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { #phone: phone, #password: password, }), - returnValue: _i4.Future.value(false), + returnValue: _i5.Future.value(false), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future updatePassword({ + _i5.Future updatePassword({ required String? newPassword, String? currentPassword, }) => @@ -157,35 +146,35 @@ class MockAuthRepository extends _i1.Mock implements _i5.AuthRepository { #newPassword: newPassword, #currentPassword: currentPassword, }), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future logout() => + _i5.Future logout() => (super.noSuchMethod( Invocation.method(#logout, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future<_i2.UserModel?> getCurrentUser() => + _i5.Future<_i2.UserModel?> getCurrentUser() => (super.noSuchMethod( Invocation.method(#getCurrentUser, []), - returnValue: _i4.Future<_i2.UserModel?>.value(), + returnValue: _i5.Future<_i2.UserModel?>.value(), ) - as _i4.Future<_i2.UserModel?>); + as _i5.Future<_i2.UserModel?>); @override - _i4.Future isAuthenticated() => + _i5.Future isAuthenticated() => (super.noSuchMethod( Invocation.method(#isAuthenticated, []), - returnValue: _i4.Future.value(false), + returnValue: _i5.Future.value(false), ) - as _i4.Future); + as _i5.Future); } /// A class which mocks [TokenService]. @@ -208,12 +197,17 @@ class MockTokenService extends _i1.Mock implements _i6.TokenService { as _i3.RxBool); @override - _i4.Future get ready => + _i5.Future get ready => (super.noSuchMethod( Invocation.getter(#ready), - returnValue: _i4.Future.value(), + returnValue: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); + + @override + bool get isReady => + (super.noSuchMethod(Invocation.getter(#isReady), returnValue: false) + as bool); @override bool get hasValidToken => @@ -270,7 +264,7 @@ class MockTokenService extends _i1.Mock implements _i6.TokenService { ); @override - _i4.Future storeTokens({ + _i5.Future storeTokens({ required String? accessToken, String? refreshToken, Duration? expiresIn, @@ -281,27 +275,27 @@ class MockTokenService extends _i1.Mock implements _i6.TokenService { #refreshToken: refreshToken, #expiresIn: expiresIn, }), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future refreshIfNeeded() => + _i5.Future refreshIfNeeded() => (super.noSuchMethod( Invocation.method(#refreshIfNeeded, []), - returnValue: _i4.Future.value(false), + returnValue: _i5.Future.value(false), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future clearTokens() => + _i5.Future clearTokens() => (super.noSuchMethod( Invocation.method(#clearTokens, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override bool validateTokenFormat(String? token) => @@ -312,12 +306,12 @@ class MockTokenService extends _i1.Mock implements _i6.TokenService { as bool); @override - _i4.Future performTokenRefresh() => + _i5.Future performTokenRefresh() => (super.noSuchMethod( Invocation.method(#performTokenRefresh, []), - returnValue: _i4.Future.value(false), + returnValue: _i5.Future.value(false), ) - as _i4.Future); + as _i5.Future); @override void onReady() => super.noSuchMethod( @@ -332,319 +326,162 @@ class MockTokenService extends _i1.Mock implements _i6.TokenService { ); } -/// A class which mocks [SessionController]. +/// A class which mocks [ProfileRepository]. /// /// See the documentation for Mockito's code generation for more information. -class MockSessionController extends _i1.Mock implements _i7.SessionController { - MockSessionController() { +class MockProfileRepository extends _i1.Mock implements _i7.ProfileRepository { + MockProfileRepository() { _i1.throwOnMissingStub(this); } @override - _i3.RxBool get rememberMe => - (super.noSuchMethod( - Invocation.getter(#rememberMe), - returnValue: _FakeRxBool_1(this, Invocation.getter(#rememberMe)), - ) - as _i3.RxBool); - - @override - _i3.RxBool get isAuthenticated => - (super.noSuchMethod( - Invocation.getter(#isAuthenticated), - returnValue: _FakeRxBool_1( - this, - Invocation.getter(#isAuthenticated), - ), - ) - as _i3.RxBool); - - @override - _i4.Future get ready => - (super.noSuchMethod( - Invocation.getter(#ready), - returnValue: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i3.RxBool get isLoading => - (super.noSuchMethod( - Invocation.getter(#isLoading), - returnValue: _FakeRxBool_1(this, Invocation.getter(#isLoading)), - ) - as _i3.RxBool); - - @override - _i3.RxString get errorMessage => + _i5.Future<_i2.UserModel> getProfile() => (super.noSuchMethod( - Invocation.getter(#errorMessage), - returnValue: _FakeRxString_3( - this, - Invocation.getter(#errorMessage), + Invocation.method(#getProfile, []), + returnValue: _i5.Future<_i2.UserModel>.value( + _FakeUserModel_0(this, Invocation.method(#getProfile, [])), ), ) - as _i3.RxString); + as _i5.Future<_i2.UserModel>); @override - _i3.InternalFinalCallback get onStart => - (super.noSuchMethod( - Invocation.getter(#onStart), - returnValue: _FakeInternalFinalCallback_2( - this, - Invocation.getter(#onStart), - ), - ) - as _i3.InternalFinalCallback); - - @override - _i3.InternalFinalCallback get onDelete => + _i5.Future<_i2.UserModel> updateProfile({ + String? firstName, + String? lastName, + String? fullName, + String? bio, + String? phone, + DateTime? dateOfBirth, + String? avatarUrl, + String? agentId, + }) => (super.noSuchMethod( - Invocation.getter(#onDelete), - returnValue: _FakeInternalFinalCallback_2( - this, - Invocation.getter(#onDelete), + Invocation.method(#updateProfile, [], { + #firstName: firstName, + #lastName: lastName, + #fullName: fullName, + #bio: bio, + #phone: phone, + #dateOfBirth: dateOfBirth, + #avatarUrl: avatarUrl, + #agentId: agentId, + }), + returnValue: _i5.Future<_i2.UserModel>.value( + _FakeUserModel_0( + this, + Invocation.method(#updateProfile, [], { + #firstName: firstName, + #lastName: lastName, + #fullName: fullName, + #bio: bio, + #phone: phone, + #dateOfBirth: dateOfBirth, + #avatarUrl: avatarUrl, + #agentId: agentId, + }), + ), ), ) - as _i3.InternalFinalCallback); - - @override - bool get initialized => - (super.noSuchMethod(Invocation.getter(#initialized), returnValue: false) - as bool); - - @override - bool get isClosed => - (super.noSuchMethod(Invocation.getter(#isClosed), returnValue: false) - as bool); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); - - @override - int get listeners => - (super.noSuchMethod(Invocation.getter(#listeners), returnValue: 0) - as int); - - @override - void onInit() => super.noSuchMethod( - Invocation.method(#onInit, []), - returnValueForMissingStub: null, - ); - - @override - void onClose() => super.noSuchMethod( - Invocation.method(#onClose, []), - returnValueForMissingStub: null, - ); - - @override - _i4.Future setRememberMe({required bool? value}) => - (super.noSuchMethod( - Invocation.method(#setRememberMe, [], {#value: value}), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future syncRememberMeStateAfterLogin() => - (super.noSuchMethod( - Invocation.method(#syncRememberMeStateAfterLogin, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - _i4.Future updateTokenServiceFromCurrentSession() => - (super.noSuchMethod( - Invocation.method(#updateTokenServiceFromCurrentSession, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); + as _i5.Future<_i2.UserModel>); @override - _i4.Future clearSession() => + _i5.Future<_i2.UserModel> updatePreferences( + Map? preferences, + ) => (super.noSuchMethod( - Invocation.method(#clearSession, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) - as _i4.Future); - - @override - void setAuthenticated({required bool? value}) => super.noSuchMethod( - Invocation.method(#setAuthenticated, [], {#value: value}), - returnValueForMissingStub: null, - ); - - @override - T trackSubscription>(T? sub) => - (super.noSuchMethod( - Invocation.method(#trackSubscription, [sub]), - returnValue: _i8.dummyValue( - this, - Invocation.method(#trackSubscription, [sub]), + Invocation.method(#updatePreferences, [preferences]), + returnValue: _i5.Future<_i2.UserModel>.value( + _FakeUserModel_0( + this, + Invocation.method(#updatePreferences, [preferences]), + ), ), ) - as T); + as _i5.Future<_i2.UserModel>); @override - T trackWorker(T? worker) => + _i5.Future<_i2.UserModel> updateNotificationSettings( + Map? settings, + ) => (super.noSuchMethod( - Invocation.method(#trackWorker, [worker]), - returnValue: _i8.dummyValue( - this, - Invocation.method(#trackWorker, [worker]), + Invocation.method(#updateNotificationSettings, [settings]), + returnValue: _i5.Future<_i2.UserModel>.value( + _FakeUserModel_0( + this, + Invocation.method(#updateNotificationSettings, [settings]), + ), ), ) - as T); + as _i5.Future<_i2.UserModel>); @override - _i4.Completer trackCompleter(_i4.Completer? completer) => + _i5.Future<_i2.UserModel> updatePrivacySettings( + Map? settings, + ) => (super.noSuchMethod( - Invocation.method(#trackCompleter, [completer]), - returnValue: _FakeCompleter_4( - this, - Invocation.method(#trackCompleter, [completer]), + Invocation.method(#updatePrivacySettings, [settings]), + returnValue: _i5.Future<_i2.UserModel>.value( + _FakeUserModel_0( + this, + Invocation.method(#updatePrivacySettings, [settings]), + ), ), ) - as _i4.Completer); + as _i5.Future<_i2.UserModel>); @override - void handleError(dynamic error, [StackTrace? stackTrace]) => - super.noSuchMethod( - Invocation.method(#handleError, [error, stackTrace]), - returnValueForMissingStub: null, - ); - - @override - _i4.Future executeWithErrorHandling( - _i4.Future Function()? operation, { - bool? showLoading = true, - String? loadingMessage, - bool? swallowError = false, + _i5.Future<_i2.UserModel> updateLocation({ + required double? latitude, + required double? longitude, + bool? shareLocation = true, }) => (super.noSuchMethod( - Invocation.method( - #executeWithErrorHandling, - [operation], - { - #showLoading: showLoading, - #loadingMessage: loadingMessage, - #swallowError: swallowError, - }, + Invocation.method(#updateLocation, [], { + #latitude: latitude, + #longitude: longitude, + #shareLocation: shareLocation, + }), + returnValue: _i5.Future<_i2.UserModel>.value( + _FakeUserModel_0( + this, + Invocation.method(#updateLocation, [], { + #latitude: latitude, + #longitude: longitude, + #shareLocation: shareLocation, + }), + ), ), - returnValue: _i4.Future.value(), ) - as _i4.Future); - - @override - void clearError() => super.noSuchMethod( - Invocation.method(#clearError, []), - returnValueForMissingStub: null, - ); + as _i5.Future<_i2.UserModel>); @override - _i4.Future executeWithRetry( - _i4.Future Function()? operation, { - int? maxRetries = 3, - Duration? delay = const Duration(seconds: 1), - bool? showLoading = true, - }) => + _i5.Future uploadAvatar(_i8.File? file) => (super.noSuchMethod( - Invocation.method( - #executeWithRetry, - [operation], - { - #maxRetries: maxRetries, - #delay: delay, - #showLoading: showLoading, - }, + Invocation.method(#uploadAvatar, [file]), + returnValue: _i5.Future.value( + _i9.dummyValue( + this, + Invocation.method(#uploadAvatar, [file]), + ), ), - returnValue: _i4.Future.value(), ) - as _i4.Future); - - @override - void onReady() => super.noSuchMethod( - Invocation.method(#onReady, []), - returnValueForMissingStub: null, - ); - - @override - void update([List? ids, bool? condition = true]) => - super.noSuchMethod( - Invocation.method(#update, [ids, condition]), - returnValueForMissingStub: null, - ); + as _i5.Future); @override - void $configureLifeCycle() => super.noSuchMethod( - Invocation.method(#$configureLifeCycle, []), - returnValueForMissingStub: null, - ); - - @override - _i9.Disposer addListener(_i9.GetStateUpdate? listener) => + _i5.Future requestDataExport() => (super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValue: () {}, + Invocation.method(#requestDataExport, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i9.Disposer); + as _i5.Future); @override - void removeListener(_i10.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void refresh() => super.noSuchMethod( - Invocation.method(#refresh, []), - returnValueForMissingStub: null, - ); - - @override - void refreshGroup(Object? id) => super.noSuchMethod( - Invocation.method(#refreshGroup, [id]), - returnValueForMissingStub: null, - ); - - @override - void notifyChildrens() => super.noSuchMethod( - Invocation.method(#notifyChildrens, []), - returnValueForMissingStub: null, - ); - - @override - void removeListenerId(Object? id, _i10.VoidCallback? listener) => - super.noSuchMethod( - Invocation.method(#removeListenerId, [id, listener]), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - _i9.Disposer addListenerId(Object? key, _i9.GetStateUpdate? listener) => + _i5.Future deleteAccount() => (super.noSuchMethod( - Invocation.method(#addListenerId, [key, listener]), - returnValue: () {}, + Invocation.method(#deleteAccount, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i9.Disposer); - - @override - void disposeId(Object? id) => super.noSuchMethod( - Invocation.method(#disposeId, [id]), - returnValueForMissingStub: null, - ); + as _i5.Future); }