Compare commits
33 Commits
10eae3577f
...
Anuj
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d962b3bde | |||
| c12464e89a | |||
| aeeb1c27e0 | |||
| c06c844210 | |||
| 9d27389bf2 | |||
| d1038e846e | |||
| 177f891a31 | |||
| adc737a6af | |||
| 265bddc784 | |||
| 60486e737a | |||
| 77aba2f1a0 | |||
| 06e60cfd57 | |||
| f59b14bec7 | |||
| cbe03f21b4 | |||
| a80a0ac790 | |||
|
|
cdfb9c74ca | ||
| 80b724d6d4 | |||
| 0abdd2b796 | |||
| dd1991da09 | |||
| 46906b04f4 | |||
| 8f7a68edbc | |||
|
|
48fd7037ea | ||
|
|
40f0ed3a52 | ||
|
|
b08e2699e9 | ||
|
|
53264619a8 | ||
|
|
5d08e07de3 | ||
|
|
68c3f28d76 | ||
|
|
3a08830cce | ||
|
|
0c663bdec7 | ||
|
|
e91d24becc | ||
|
|
09726eb4e6 | ||
| eb9ca9299e | |||
| e15a979c0c |
@@ -1,7 +1,7 @@
|
||||
# citycards_customer
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<application
|
||||
android:label="CityCard Customer"
|
||||
android:name="${applicationName}"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
663
android/build/reports/problems/problems-report.html
Normal file
BIN
assets/font/Poppins-Regular.ttf
Normal file
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.0 KiB |
BIN
assets/icons/calendar.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
assets/icons/compass_outlined.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/downlaod.png
Normal file
|
After Width: | Height: | Size: 991 B |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 6.7 KiB |
BIN
assets/icons/location_outlined.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
assets/icons/love_them.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/icons/maybe.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
assets/icons/no_kids.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
assets/icons/not_interested.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
assets/icons/payment_summary_outlined.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/icons/person.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
BIN
assets/icons/refresh.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 5.6 KiB |
BIN
assets/icons/sounds_good.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
assets/icons/time.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
assets/icons/traveling_with_kids.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 10 KiB |
BIN
assets/images/card_bg.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 95 KiB |
BIN
assets/images/no_itinerary.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 749 KiB After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/unlimited_card_details.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
141
assets/intro/city_cards_splash_screen.json
Normal file
@@ -0,0 +1,141 @@
|
||||
[{
|
||||
"version": "1.0",
|
||||
"image": {
|
||||
"name": "frames/frame002.png",
|
||||
"baseName": "frame002.png",
|
||||
"permissions": 664,
|
||||
"format": "PNG",
|
||||
"formatDescription": "Portable Network Graphics",
|
||||
"mimeType": "image/png",
|
||||
"class": "DirectClass",
|
||||
"geometry": {
|
||||
"width": 1868,
|
||||
"height": 3840,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"resolution": {
|
||||
"x": 370753,
|
||||
"y": 370798
|
||||
},
|
||||
"printSize": {
|
||||
"x": 0.00503839,
|
||||
"y": 0.010356
|
||||
},
|
||||
"units": "Undefined",
|
||||
"type": "TrueColor",
|
||||
"endianness": "Undefined",
|
||||
"colorspace": "sRGB",
|
||||
"depth": 8,
|
||||
"baseDepth": 8,
|
||||
"channelDepth": {
|
||||
"red": 8,
|
||||
"green": 8,
|
||||
"blue": 1
|
||||
},
|
||||
"pixels": 7173120,
|
||||
"imageStatistics": {
|
||||
"Overall": {
|
||||
"min": 67,
|
||||
"max": 255,
|
||||
"mean": 142.829,
|
||||
"median": 140,
|
||||
"standardDeviation": 17.1849,
|
||||
"kurtosis": 37.2771,
|
||||
"skewness": 4.24387,
|
||||
"entropy": 0.291301
|
||||
}
|
||||
},
|
||||
"channelStatistics": {
|
||||
"red": {
|
||||
"min": 174,
|
||||
"max": 255,
|
||||
"mean": 237.888,
|
||||
"median": 238,
|
||||
"standardDeviation": 2.65253,
|
||||
"kurtosis": 41.5763,
|
||||
"skewness": 0.61346,
|
||||
"entropy": 0.338084
|
||||
},
|
||||
"green": {
|
||||
"min": 73,
|
||||
"max": 255,
|
||||
"mean": 94.2729,
|
||||
"median": 90,
|
||||
"standardDeviation": 24.5069,
|
||||
"kurtosis": 35.19,
|
||||
"skewness": 6.06676,
|
||||
"entropy": 0.237928
|
||||
},
|
||||
"blue": {
|
||||
"min": 67,
|
||||
"max": 255,
|
||||
"mean": 96.325,
|
||||
"median": 92,
|
||||
"standardDeviation": 24.3954,
|
||||
"kurtosis": 35.0649,
|
||||
"skewness": 6.05138,
|
||||
"entropy": 0.297891
|
||||
}
|
||||
},
|
||||
"renderingIntent": "Perceptual",
|
||||
"gamma": 0.454545,
|
||||
"chromaticity": {
|
||||
"redPrimary": {
|
||||
"x": 0.64,
|
||||
"y": 0.33
|
||||
},
|
||||
"greenPrimary": {
|
||||
"x": 0.3,
|
||||
"y": 0.6
|
||||
},
|
||||
"bluePrimary": {
|
||||
"x": 0.15,
|
||||
"y": 0.06
|
||||
},
|
||||
"whitePrimary": {
|
||||
"x": 0.3127,
|
||||
"y": 0.329
|
||||
}
|
||||
},
|
||||
"matteColor": "#BDBDBDBDBDBD",
|
||||
"backgroundColor": "#FFFFFFFFFFFF",
|
||||
"borderColor": "#DFDFDFDFDFDF",
|
||||
"transparentColor": "#000000000000",
|
||||
"interlace": "None",
|
||||
"intensity": "Undefined",
|
||||
"compose": "Over",
|
||||
"pageGeometry": {
|
||||
"width": 1868,
|
||||
"height": 3840,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"dispose": "Undefined",
|
||||
"iterations": 0,
|
||||
"scene": 1,
|
||||
"scenes": 2,
|
||||
"compression": "Zip",
|
||||
"orientation": "Undefined",
|
||||
"properties": {
|
||||
"date:create": "2026-02-18T13:36:29+00:00",
|
||||
"date:modify": "2026-02-18T13:36:29+00:00",
|
||||
"date:timestamp": "2026-02-18T13:36:29+00:00",
|
||||
"png:IHDR.bit-depth-orig": "8",
|
||||
"png:IHDR.bit_depth": "8",
|
||||
"png:IHDR.color-type-orig": "2",
|
||||
"png:IHDR.color_type": "2 (Truecolor)",
|
||||
"png:IHDR.interlace_method": "0 (Not interlaced)",
|
||||
"png:IHDR.width,height": "1868, 3840",
|
||||
"png:pHYs": "x_res=370753, y_res=370798, units=0",
|
||||
"signature": "7fb181e6439aa51f6eb134a4991711167b5850e80e40ae5cb0c67cf29c118dfe"
|
||||
},
|
||||
"tainted": false,
|
||||
"filesize": "3422B",
|
||||
"numberPixels": "7.17312M",
|
||||
"pixelsPerSecond": "2.74974MB",
|
||||
"userTime": "2.880u",
|
||||
"elapsedTime": "0:03.608",
|
||||
"version": "ImageMagick 7.1.1-41 Q16-HDRI x86_64 22504 https://imagemagick.org"
|
||||
}
|
||||
}]
|
||||
1
assets/intro/citycards_splash_screen.json
Normal file
1
assets/intro/itinerary_animation.json
Normal file
1
assets/intro/itinerary_creating.json
Normal file
228
ios/Podfile.lock
@@ -1,4 +1,6 @@
|
||||
PODS:
|
||||
- app_links (7.0.0):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_angle (0.3.8):
|
||||
- Flutter
|
||||
@@ -7,6 +9,8 @@ PODS:
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- FlutterAngle (0.0.8)
|
||||
- geocoding_ios (1.0.5):
|
||||
- Flutter
|
||||
- geolocator_apple (1.2.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -16,83 +20,287 @@ PODS:
|
||||
- Flutter
|
||||
- Google-Maps-iOS-Utils (< 7.0, >= 5.0)
|
||||
- GoogleMaps (< 10.0, >= 8.4)
|
||||
- google_mlkit_commons (0.11.1):
|
||||
- Flutter
|
||||
- MLKitVision (~> 10.0.0)
|
||||
- google_mlkit_translation (0.13.1):
|
||||
- Flutter
|
||||
- google_mlkit_commons
|
||||
- GoogleMLKit/Translate (~> 9.0.0)
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleMaps (9.4.0):
|
||||
- GoogleMaps/Maps (= 9.4.0)
|
||||
- GoogleMaps/Maps (9.4.0)
|
||||
- GoogleMLKit/MLKitCore (9.0.0):
|
||||
- MLKitCommon (~> 14.0.0)
|
||||
- GoogleMLKit/Translate (9.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitTranslate (~> 8.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleToolboxForMac/StringEncoding (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- MLImage (1.0.0-beta8)
|
||||
- MLKitCommon (14.0.0):
|
||||
- GoogleDataTransport (~> 10.0)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitNaturalLanguage (10.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleToolboxForMac/StringEncoding (< 5.0, >= 4.2.1)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitCommon (~> 14.0)
|
||||
- MLKitTranslate (8.0.0):
|
||||
- MLKitNaturalLanguage (~> 10.0)
|
||||
- SSZipArchive (< 3.0, >= 2.5.5)
|
||||
- MLKitVision (10.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta8)
|
||||
- MLKitCommon (~> 14.0)
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- open_filex (0.0.2):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- PromisesObjC (2.4.0)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SSZipArchive (2.6.0)
|
||||
- Stripe (25.0.1):
|
||||
- StripeApplePay (= 25.0.1)
|
||||
- StripeCore (= 25.0.1)
|
||||
- StripePayments (= 25.0.1)
|
||||
- StripePaymentsUI (= 25.0.1)
|
||||
- StripeUICore (= 25.0.1)
|
||||
- stripe_ios (0.0.1):
|
||||
- Flutter
|
||||
- Stripe (~> 25.0.1)
|
||||
- stripe_ios/stripe_ios (= 0.0.1)
|
||||
- stripe_ios/stripe_objc (= 0.0.1)
|
||||
- StripeApplePay (~> 25.0.1)
|
||||
- StripeFinancialConnections (~> 25.0.1)
|
||||
- StripePayments (~> 25.0.1)
|
||||
- StripePaymentSheet (~> 25.0.1)
|
||||
- StripePaymentsUI (~> 25.0.1)
|
||||
- stripe_ios/stripe_ios (0.0.1):
|
||||
- Flutter
|
||||
- Stripe (~> 25.0.1)
|
||||
- stripe_ios/stripe_objc
|
||||
- StripeApplePay (~> 25.0.1)
|
||||
- StripeFinancialConnections (~> 25.0.1)
|
||||
- StripePayments (~> 25.0.1)
|
||||
- StripePaymentSheet (~> 25.0.1)
|
||||
- StripePaymentsUI (~> 25.0.1)
|
||||
- stripe_ios/stripe_objc (0.0.1):
|
||||
- Flutter
|
||||
- Stripe (~> 25.0.1)
|
||||
- StripeApplePay (~> 25.0.1)
|
||||
- StripeFinancialConnections (~> 25.0.1)
|
||||
- StripePayments (~> 25.0.1)
|
||||
- StripePaymentSheet (~> 25.0.1)
|
||||
- StripePaymentsUI (~> 25.0.1)
|
||||
- StripeApplePay (25.0.1):
|
||||
- StripeCore (= 25.0.1)
|
||||
- StripeCore (25.0.1)
|
||||
- StripeFinancialConnections (25.0.1):
|
||||
- StripeCore (= 25.0.1)
|
||||
- StripeUICore (= 25.0.1)
|
||||
- StripePayments (25.0.1):
|
||||
- StripeCore (= 25.0.1)
|
||||
- StripePayments/Stripe3DS2 (= 25.0.1)
|
||||
- StripePayments/Stripe3DS2 (25.0.1):
|
||||
- StripeCore (= 25.0.1)
|
||||
- StripePaymentSheet (25.0.1):
|
||||
- StripeApplePay (= 25.0.1)
|
||||
- StripeCore (= 25.0.1)
|
||||
- StripePayments (= 25.0.1)
|
||||
- StripePaymentsUI (= 25.0.1)
|
||||
- StripePaymentsUI (25.0.1):
|
||||
- StripeCore (= 25.0.1)
|
||||
- StripePayments (= 25.0.1)
|
||||
- StripeUICore (= 25.0.1)
|
||||
- StripeUICore (25.0.1):
|
||||
- StripeCore (= 25.0.1)
|
||||
- three_js_sensors (0.1.2):
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_angle (from `.symlinks/plugins/flutter_angle/darwin`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- geocoding_ios (from `.symlinks/plugins/geocoding_ios/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||
- google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`)
|
||||
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
|
||||
- google_mlkit_translation (from `.symlinks/plugins/google_mlkit_translation/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- stripe_ios (from `.symlinks/plugins/stripe_ios/ios`)
|
||||
- three_js_sensors (from `.symlinks/plugins/three_js_sensors/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- FlutterAngle
|
||||
- Google-Maps-iOS-Utils
|
||||
- GoogleDataTransport
|
||||
- GoogleMaps
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitCommon
|
||||
- MLKitNaturalLanguage
|
||||
- MLKitTranslate
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- SSZipArchive
|
||||
- Stripe
|
||||
- StripeApplePay
|
||||
- StripeCore
|
||||
- StripeFinancialConnections
|
||||
- StripePayments
|
||||
- StripePaymentSheet
|
||||
- StripePaymentsUI
|
||||
- StripeUICore
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
app_links:
|
||||
:path: ".symlinks/plugins/app_links/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_angle:
|
||||
:path: ".symlinks/plugins/flutter_angle/darwin"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
geocoding_ios:
|
||||
:path: ".symlinks/plugins/geocoding_ios/ios"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||
google_maps_flutter_ios:
|
||||
:path: ".symlinks/plugins/google_maps_flutter_ios/ios"
|
||||
google_mlkit_commons:
|
||||
:path: ".symlinks/plugins/google_mlkit_commons/ios"
|
||||
google_mlkit_translation:
|
||||
:path: ".symlinks/plugins/google_mlkit_translation/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
open_filex:
|
||||
:path: ".symlinks/plugins/open_filex/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
stripe_ios:
|
||||
:path: ".symlinks/plugins/stripe_ios/ios"
|
||||
three_js_sensors:
|
||||
:path: ".symlinks/plugins/three_js_sensors/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_angle: 7b1a2b3e733221bf2e0325e42fc3edf95b5d44c4
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_angle: fc44e198cea1f07e1a5919bad1484049fab65c96
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
FlutterAngle: c810891af800750361b1d0e7cc944f2338d5ae18
|
||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||
geocoding_ios: eafacae6ad11a1eb56681f7d11df602a5fd49416
|
||||
geolocator_apple: 66b711889fd333205763b83c9dcf0a57a28c7afd
|
||||
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
|
||||
google_maps_flutter_ios: 0291eb2aa252298a769b04d075e4a9d747ff7264
|
||||
google_maps_flutter_ios: e31555a04d1986ab130f2b9f24b6cdc861acc6d3
|
||||
google_mlkit_commons: 1e6ef6605d7281f35baf2b355e6049b9984fd624
|
||||
google_mlkit_translation: 8a0e84c632121250843e9bab526cb926a2a2a7b7
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
three_js_sensors: f516b092803411e05b1e3dc7625efa36acd8f455
|
||||
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||
GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0
|
||||
MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6
|
||||
MLKitNaturalLanguage: 498154f2461f97abb00eb161eb773670ffc46250
|
||||
MLKitTranslate: fcb283a2cbaaa595e1cf1d3fedffcea2e97a1168
|
||||
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea
|
||||
Stripe: 4728e3e0dd8df134e4a420ab504e929a93a815f0
|
||||
stripe_ios: c552a249333c2e810e02539140dba366c7f0683f
|
||||
StripeApplePay: 43997281ace138a1c75a8f2d7be11925ea28644c
|
||||
StripeCore: 457c30e2fd3a7c4b274a5ad53d1ff03661eef2a0
|
||||
StripeFinancialConnections: 8c2e326f767fb014b53174b3a5f8592c0a45fa56
|
||||
StripePayments: 6955de4298a5265e66f02cffcc7954475ac7f6c8
|
||||
StripePaymentSheet: 3f93ce6ea84afde770d3c7e18a9b8f99aed63896
|
||||
StripePaymentsUI: 626726a01255a6458c35436f7f6431dacee82684
|
||||
StripeUICore: 30f8352fd7a5cf1541b7777a57b3ad1133bf6763
|
||||
three_js_sensors: ab5f24fbeb97ab5c5ce2978c3e63a25d67a076f5
|
||||
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
||||
video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86
|
||||
|
||||
PODFILE CHECKSUM: 1857a7cdb7dfafe45f2b0e9a9af44644190f7506
|
||||
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
00C1AB7B0C8F1922F3F1AE65 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54C8901E9D1856D980DFFE46 /* Pods_Runner.framework */; };
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
60A4FC1A895BADD3C1597C0B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E2BCDF72F2D56D34CC9E967 /* Pods_RunnerTests.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
81D638B66EB4658C8192CA0D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 445696AB37183A7C63CB7E98 /* Pods_RunnerTests.framework */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
C9496C6E1D2E0AEA2083C14C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D485254B2B32B84B17D20864 /* Pods_Runner.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -44,13 +44,13 @@
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
18E5A2491D54EBB2484B6D9E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
445696AB37183A7C63CB7E98 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
4FD33ADDA221C4BBA29FA3D6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
54C8901E9D1856D980DFFE46 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
626B072D1717B50A277DA3C7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
5C263389B0D2FA3EC95111B1 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
6E2BCDF72F2D56D34CC9E967 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
6F51EB881CD063E2C9A71BA6 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -61,10 +61,10 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B691822B373AD22ECA93B798 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
C1FCB3EF88270ED76DFA3FBD /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
D56ABB8F306EF9F6809C0C1E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
E2E6DC2B6718F55E3BF165E7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9EEFD1F245CF2AAD027ADE1E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
D485254B2B32B84B17D20864 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D719B3676BADD267F44F4A59 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
D8B9C9F2F3A8FEF639B5A528 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -72,7 +72,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
00C1AB7B0C8F1922F3F1AE65 /* Pods_Runner.framework in Frameworks */,
|
||||
C9496C6E1D2E0AEA2083C14C /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -80,13 +80,22 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
81D638B66EB4658C8192CA0D /* Pods_RunnerTests.framework in Frameworks */,
|
||||
60A4FC1A895BADD3C1597C0B /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
227A5CBF4270AAC4960A7CAF /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D485254B2B32B84B17D20864 /* Pods_Runner.framework */,
|
||||
6E2BCDF72F2D56D34CC9E967 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -95,24 +104,15 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5D45FB84C63476582408C414 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
54C8901E9D1856D980DFFE46 /* Pods_Runner.framework */,
|
||||
445696AB37183A7C63CB7E98 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6D4A73F1E55857ADBD000C6A /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B691822B373AD22ECA93B798 /* Pods-Runner.debug.xcconfig */,
|
||||
4FD33ADDA221C4BBA29FA3D6 /* Pods-Runner.release.xcconfig */,
|
||||
D56ABB8F306EF9F6809C0C1E /* Pods-Runner.profile.xcconfig */,
|
||||
E2E6DC2B6718F55E3BF165E7 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
626B072D1717B50A277DA3C7 /* Pods-RunnerTests.release.xcconfig */,
|
||||
C1FCB3EF88270ED76DFA3FBD /* Pods-RunnerTests.profile.xcconfig */,
|
||||
9EEFD1F245CF2AAD027ADE1E /* Pods-Runner.debug.xcconfig */,
|
||||
D8B9C9F2F3A8FEF639B5A528 /* Pods-Runner.release.xcconfig */,
|
||||
D719B3676BADD267F44F4A59 /* Pods-Runner.profile.xcconfig */,
|
||||
6F51EB881CD063E2C9A71BA6 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
18E5A2491D54EBB2484B6D9E /* Pods-RunnerTests.release.xcconfig */,
|
||||
5C263389B0D2FA3EC95111B1 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -136,7 +136,7 @@
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
6D4A73F1E55857ADBD000C6A /* Pods */,
|
||||
5D45FB84C63476582408C414 /* Frameworks */,
|
||||
227A5CBF4270AAC4960A7CAF /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -171,7 +171,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
BC66FA7BADCD3982DC87655E /* [CP] Check Pods Manifest.lock */,
|
||||
FCD91A1B9B088634E06F7C98 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
CF8A29BE993C0C902CB143AF /* Frameworks */,
|
||||
@@ -190,15 +190,15 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
3825EC0F330C0B58EA2A8981 /* [CP] Check Pods Manifest.lock */,
|
||||
8B4387D9368AF1A601B17194 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
41FC0A605EBADE26C841287E /* [CP] Embed Pods Frameworks */,
|
||||
D10E98BB568B7005161E1ABD /* [CP] Copy Pods Resources */,
|
||||
CA082AA85EC21FFAA42CE12F /* [CP] Embed Pods Frameworks */,
|
||||
3F2F0E04CC81D63DDD8C37A9 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -270,7 +270,40 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3825EC0F330C0B58EA2A8981 /* [CP] Check Pods Manifest.lock */ = {
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
3F2F0E04CC81D63DDD8C37A9 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
8B4387D9368AF1A601B17194 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -292,23 +325,22 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
41FC0A605EBADE26C841287E /* [CP] Embed Pods Frameworks */ = {
|
||||
CA082AA85EC21FFAA42CE12F /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -325,22 +357,7 @@
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
BC66FA7BADCD3982DC87655E /* [CP] Check Pods Manifest.lock */ = {
|
||||
FCD91A1B9B088634E06F7C98 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -362,23 +379,6 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
D10E98BB568B7005161E1ABD /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -515,7 +515,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = E2E6DC2B6718F55E3BF165E7 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
baseConfigurationReference = 6F51EB881CD063E2C9A71BA6 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -533,7 +533,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 626B072D1717B50A277DA3C7 /* Pods-RunnerTests.release.xcconfig */;
|
||||
baseConfigurationReference = 18E5A2491D54EBB2484B6D9E /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -549,7 +549,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = C1FCB3EF88270ED76DFA3FBD /* Pods-RunnerTests.profile.xcconfig */;
|
||||
baseConfigurationReference = 5C263389B0D2FA3EC95111B1 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,59 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Citycards Customer</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>citycards_customer</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need access to your camera for taking photos for profile and to build a postcard.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Citycard customer needs your location to find the closest place you can visit.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Citycard customer needs your location to find the closest place you can visit.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need access to your camera for taking photos for profile and to build a postcard.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan your card to add it automatically</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>To scan cards</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Citycards Customer</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>citycards_customer</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string></string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>To scan cards</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Citycard customer needs your location to find the closest place you can visit.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Citycard customer needs your location to find the closest place you can visit.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need access to your camera for taking photos for profile and to build a postcard.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -9,21 +9,34 @@ import 'stripe_payment_state.dart';
|
||||
class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
|
||||
final StripeService _stripeService;
|
||||
|
||||
// 🔒 Flag to prevent re-initialization after success
|
||||
bool _paymentCompleted = false;
|
||||
|
||||
StripePaymentBloc({
|
||||
StripeService? stripeService,
|
||||
}) : _stripeService = stripeService ?? StripeService(),
|
||||
super(const StripePaymentInitial()) {
|
||||
on<InitiatePayment>(_onInitiatePayment);
|
||||
on<InitiatePaymentWithClientSecret>(_onInitiatePaymentWithClientSecret);
|
||||
on<CancelPaymentEvent>(_onCancelPayment);
|
||||
on<ResetPaymentState>(_onResetPaymentState);
|
||||
on<RetryPaymentEvent>(_onRetryPayment);
|
||||
}
|
||||
|
||||
Future<void> _onInitiatePayment(
|
||||
InitiatePayment event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) async {
|
||||
// 🛑 Prevent re-initialization if payment already completed
|
||||
if (_paymentCompleted) {
|
||||
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
emit(const StripePaymentLoading());
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Creating payment intent...',
|
||||
));
|
||||
|
||||
/// Stripe expects smallest currency unit
|
||||
/// USD → cents, INR → paise
|
||||
@@ -35,45 +48,57 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
|
||||
currency: event.currency,
|
||||
);
|
||||
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Initializing payment sheet...',
|
||||
));
|
||||
|
||||
// 2️⃣ Init Payment Sheet
|
||||
await Stripe.instance.initPaymentSheet(
|
||||
paymentSheetParameters: SetupPaymentSheetParameters(
|
||||
paymentIntentClientSecret: clientSecret,
|
||||
merchantDisplayName: "CityCards",
|
||||
style: ThemeMode.light,
|
||||
allowsDelayedPaymentMethods: true,
|
||||
),
|
||||
);
|
||||
await Stripe.instance.presentPaymentSheet();
|
||||
emit(const StripePaymentSheetReady());
|
||||
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Processing payment...',
|
||||
));
|
||||
|
||||
// 3️⃣ Show Payment Sheet
|
||||
await Stripe.instance.presentPaymentSheet();
|
||||
|
||||
// ✅ SUCCESS
|
||||
// ✅ SUCCESS - Mark as completed
|
||||
_paymentCompleted = true;
|
||||
emit(const StripePaymentSuccess());
|
||||
} on StripeException catch (e) {
|
||||
// Handle Stripe-specific errors
|
||||
if (e.error.code == FailureCode.Canceled) {
|
||||
emit(StripePaymentCancelled(
|
||||
message: e.error.localizedMessage ?? 'Payment Cancelled',
|
||||
));
|
||||
} else {
|
||||
emit(StripePaymentFailure(
|
||||
error: e.error.localizedMessage ?? 'Payment failed',
|
||||
));
|
||||
}
|
||||
_handleStripeException(e, emit);
|
||||
} catch (e) {
|
||||
emit(StripePaymentFailure(
|
||||
error: e.toString(),
|
||||
error: 'An unexpected error occurred: ${e.toString()}',
|
||||
isRetryable: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 NEW: Handle payment with clientSecret directly from backend
|
||||
/// Handle payment with clientSecret directly from backend
|
||||
Future<void> _onInitiatePaymentWithClientSecret(
|
||||
InitiatePaymentWithClientSecret event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) async {
|
||||
// 🛑 Prevent re-initialization if payment already completed
|
||||
if (_paymentCompleted) {
|
||||
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
emit(const StripePaymentLoading());
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Initializing payment...',
|
||||
));
|
||||
|
||||
// 1️⃣ Init Payment Sheet with clientSecret from backend
|
||||
await Stripe.instance.initPaymentSheet(
|
||||
@@ -81,36 +106,132 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
|
||||
paymentIntentClientSecret: event.clientSecret,
|
||||
merchantDisplayName: "CityCards",
|
||||
style: ThemeMode.light,
|
||||
allowsDelayedPaymentMethods: true,
|
||||
|
||||
),
|
||||
);
|
||||
|
||||
emit(const StripePaymentSheetReady());
|
||||
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Processing payment...',
|
||||
));
|
||||
|
||||
// 2️⃣ Show Payment Sheet
|
||||
await Stripe.instance.presentPaymentSheet();
|
||||
|
||||
// ✅ SUCCESS
|
||||
// ✅ SUCCESS - Mark as completed
|
||||
_paymentCompleted = true;
|
||||
emit(const StripePaymentSuccess());
|
||||
} on StripeException catch (e) {
|
||||
// Handle Stripe-specific errors
|
||||
if (e.error.code == FailureCode.Canceled) {
|
||||
emit(StripePaymentCancelled(
|
||||
message: e.error.localizedMessage ?? 'Payment Cancelled',
|
||||
));
|
||||
} else {
|
||||
emit(StripePaymentFailure(
|
||||
error: e.error.localizedMessage ?? 'Payment failed',
|
||||
));
|
||||
}
|
||||
_handleStripeException(e, emit);
|
||||
} catch (e) {
|
||||
emit(StripePaymentFailure(
|
||||
error: e.toString(),
|
||||
error: 'An unexpected error occurred: ${e.toString()}',
|
||||
isRetryable: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle payment cancellation
|
||||
void _onCancelPayment(
|
||||
CancelPaymentEvent event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
// Only emit cancelled if not already completed
|
||||
if (!_paymentCompleted) {
|
||||
emit(const StripePaymentCancelled(
|
||||
message: 'Payment cancelled by user',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle payment retry
|
||||
Future<void> _onRetryPayment(
|
||||
RetryPaymentEvent event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) async {
|
||||
// 🔄 Reset completion flag for retry
|
||||
_paymentCompleted = false;
|
||||
|
||||
// Reset state first
|
||||
emit(const StripePaymentInitial());
|
||||
|
||||
// Then initiate payment again
|
||||
add(InitiatePaymentWithClientSecret(
|
||||
clientSecret: event.clientSecret,
|
||||
));
|
||||
}
|
||||
|
||||
/// Reset payment state back to initial
|
||||
void _onResetPaymentState(
|
||||
ResetPaymentState event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
// 🔄 Reset completion flag
|
||||
_paymentCompleted = false;
|
||||
emit(const StripePaymentInitial());
|
||||
}
|
||||
|
||||
/// Centralized Stripe exception handling
|
||||
void _handleStripeException(
|
||||
StripeException e,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
final errorCode = e.error.code;
|
||||
final errorMessage = e.error.localizedMessage ?? 'Payment failed';
|
||||
|
||||
// Handle cancellation separately
|
||||
if (errorCode == FailureCode.Canceled) {
|
||||
emit(StripePaymentCancelled(
|
||||
message: errorMessage,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different error types
|
||||
switch (errorCode) {
|
||||
case FailureCode.Failed:
|
||||
emit(StripePaymentFailure(
|
||||
error: errorMessage,
|
||||
errorCode: errorCode.toString(),
|
||||
isRetryable: true,
|
||||
));
|
||||
break;
|
||||
|
||||
case FailureCode.Timeout:
|
||||
emit(const StripePaymentFailure(
|
||||
error: 'Payment timed out. Please try again.',
|
||||
errorCode: 'timeout',
|
||||
isRetryable: true,
|
||||
));
|
||||
break;
|
||||
|
||||
default:
|
||||
emit(StripePaymentFailure(
|
||||
error: errorMessage,
|
||||
errorCode: errorCode?.toString(),
|
||||
isRetryable: _isRetryableError(errorCode),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if an error is retryable
|
||||
bool _isRetryableError(FailureCode? errorCode) {
|
||||
if (errorCode == null) return true;
|
||||
|
||||
// Non-retryable errors
|
||||
const nonRetryableErrors = [
|
||||
// Add specific non-retryable error codes here if needed
|
||||
];
|
||||
|
||||
return !nonRetryableErrors.contains(errorCode);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
// Reset flag on bloc disposal
|
||||
_paymentCompleted = false;
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class InitiatePayment extends StripePaymentEvent {
|
||||
List<Object?> get props => [amount, currency];
|
||||
}
|
||||
|
||||
/// 🆕 NEW: Event to initiate payment with clientSecret from backend
|
||||
/// Event to initiate payment with clientSecret from backend
|
||||
class InitiatePaymentWithClientSecret extends StripePaymentEvent {
|
||||
final String clientSecret;
|
||||
|
||||
@@ -32,6 +32,24 @@ class InitiatePaymentWithClientSecret extends StripePaymentEvent {
|
||||
List<Object?> get props => [clientSecret];
|
||||
}
|
||||
|
||||
/// Event to cancel ongoing payment
|
||||
class CancelPaymentEvent extends StripePaymentEvent {
|
||||
const CancelPaymentEvent();
|
||||
}
|
||||
|
||||
/// Event to reset payment state back to initial
|
||||
class ResetPaymentState extends StripePaymentEvent {
|
||||
const ResetPaymentState();
|
||||
}
|
||||
|
||||
/// Event to retry failed payment
|
||||
class RetryPaymentEvent extends StripePaymentEvent {
|
||||
final String clientSecret;
|
||||
|
||||
const RetryPaymentEvent({
|
||||
required this.clientSecret,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [clientSecret];
|
||||
}
|
||||
@@ -7,36 +7,59 @@ abstract class StripePaymentState extends Equatable {
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Initial state before any payment action
|
||||
class StripePaymentInitial extends StripePaymentState {
|
||||
const StripePaymentInitial();
|
||||
}
|
||||
|
||||
/// Payment is being processed
|
||||
class StripePaymentLoading extends StripePaymentState {
|
||||
const StripePaymentLoading();
|
||||
}
|
||||
final String? message;
|
||||
|
||||
class StripePaymentSuccess extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentSuccess({
|
||||
this.message = 'Payment Successful',
|
||||
const StripePaymentLoading({
|
||||
this.message,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
class StripePaymentFailure extends StripePaymentState {
|
||||
final String error;
|
||||
/// Payment sheet is initialized and ready to be presented
|
||||
class StripePaymentSheetReady extends StripePaymentState {
|
||||
const StripePaymentSheetReady();
|
||||
}
|
||||
|
||||
const StripePaymentFailure({
|
||||
required this.error,
|
||||
/// Payment was successful
|
||||
class StripePaymentSuccess extends StripePaymentState {
|
||||
final String message;
|
||||
final String? paymentIntentId;
|
||||
|
||||
const StripePaymentSuccess({
|
||||
this.message = 'Payment Successful',
|
||||
this.paymentIntentId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error];
|
||||
List<Object?> get props => [message, paymentIntentId];
|
||||
}
|
||||
|
||||
/// Payment failed
|
||||
class StripePaymentFailure extends StripePaymentState {
|
||||
final String error;
|
||||
final String? errorCode;
|
||||
final bool isRetryable;
|
||||
|
||||
const StripePaymentFailure({
|
||||
required this.error,
|
||||
this.errorCode,
|
||||
this.isRetryable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error, errorCode, isRetryable];
|
||||
}
|
||||
|
||||
/// Payment was cancelled by user
|
||||
class StripePaymentCancelled extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
@@ -44,6 +67,30 @@ class StripePaymentCancelled extends StripePaymentState {
|
||||
this.message = 'Payment Cancelled',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// Payment requires additional authentication (3D Secure, etc.)
|
||||
class StripePaymentRequiresAction extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentRequiresAction({
|
||||
this.message = 'Additional authentication required',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// Payment is processing on the backend
|
||||
class StripePaymentProcessing extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentProcessing({
|
||||
this.message = 'Payment is being processed...',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -1,230 +1,475 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../bloc/stripe_payment_bloc.dart';
|
||||
import '../bloc/stripe_payment_event.dart';
|
||||
import '../bloc/stripe_payment_state.dart';
|
||||
import '../repository/stripe_service.dart';
|
||||
|
||||
class StripePaymentView extends StatelessWidget {
|
||||
const StripePaymentView({super.key});
|
||||
/// 🎯 Reusable Stripe Payment Screen
|
||||
///
|
||||
/// This widget handles Stripe payment flow and can be used across different features
|
||||
/// like postcards, subscriptions, bookings, etc.
|
||||
class StripePaymentScreen extends StatelessWidget {
|
||||
/// Client secret from your backend payment intent
|
||||
final String clientSecret;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final args =
|
||||
ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
||||
/// Amount to display (optional)
|
||||
final double? amount;
|
||||
|
||||
final double amount = args['amount'];
|
||||
final String currency = args['currency'];
|
||||
/// Currency symbol (default: \$)
|
||||
final String currencySymbol;
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => StripePaymentBloc(
|
||||
stripeService: StripeService(),
|
||||
),
|
||||
child: StripePaymentViewContent(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Custom title for the payment screen
|
||||
final String? title;
|
||||
|
||||
class StripePaymentViewContent extends StatefulWidget {
|
||||
final double amount;
|
||||
final String currency;
|
||||
/// Custom loading message
|
||||
final String loadingMessage;
|
||||
|
||||
const StripePaymentViewContent({
|
||||
/// Custom success message
|
||||
final String successMessage;
|
||||
|
||||
/// Custom failure message prefix
|
||||
final String failureMessage;
|
||||
|
||||
/// Callback when payment succeeds
|
||||
final VoidCallback? onPaymentSuccess;
|
||||
|
||||
/// Callback when payment fails
|
||||
final void Function(String error)? onPaymentFailure;
|
||||
|
||||
/// Callback when payment is cancelled
|
||||
final VoidCallback? onPaymentCancelled;
|
||||
|
||||
/// Primary color for the UI
|
||||
final Color primaryColor;
|
||||
|
||||
/// Success icon color
|
||||
final Color successColor;
|
||||
|
||||
/// Error icon color
|
||||
final Color errorColor;
|
||||
|
||||
/// Custom height ratio (0.0 to 1.0)
|
||||
final double heightRatio;
|
||||
|
||||
/// Whether to show close button during loading
|
||||
final bool showCloseButtonDuringLoading;
|
||||
|
||||
/// Custom widget to show above the status (optional)
|
||||
final Widget? headerWidget;
|
||||
|
||||
/// Custom widget to show below the status (optional)
|
||||
final Widget? footerWidget;
|
||||
|
||||
const StripePaymentScreen({
|
||||
super.key,
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
required this.clientSecret,
|
||||
this.amount,
|
||||
this.currencySymbol = '\$',
|
||||
this.title,
|
||||
this.loadingMessage = 'Processing payment...',
|
||||
this.successMessage = 'Payment Successful!',
|
||||
this.failureMessage = 'Payment Failed',
|
||||
this.onPaymentSuccess,
|
||||
this.onPaymentFailure,
|
||||
this.onPaymentCancelled,
|
||||
this.primaryColor = const Color(0xFFF95F62),
|
||||
this.successColor = Colors.green,
|
||||
this.errorColor = Colors.red,
|
||||
this.heightRatio = 0.5,
|
||||
this.showCloseButtonDuringLoading = false,
|
||||
this.headerWidget,
|
||||
this.footerWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StripePaymentViewContent> createState() =>
|
||||
_StripePaymentViewContentState();
|
||||
}
|
||||
/// 🚀 Static method to show as bottom sheet
|
||||
static Future<bool?> showAsBottomSheet({
|
||||
required BuildContext context,
|
||||
required String clientSecret,
|
||||
double? amount,
|
||||
String currencySymbol = '\$',
|
||||
String? title,
|
||||
String loadingMessage = 'Processing payment...',
|
||||
String successMessage = 'Payment Successful!',
|
||||
String failureMessage = 'Payment Failed',
|
||||
VoidCallback? onPaymentSuccess,
|
||||
void Function(String error)? onPaymentFailure,
|
||||
VoidCallback? onPaymentCancelled,
|
||||
Color primaryColor = const Color(0xFFF95F62),
|
||||
Color successColor = Colors.green,
|
||||
Color errorColor = Colors.red,
|
||||
double heightRatio = 0.5,
|
||||
bool isDismissible = false,
|
||||
bool enableDrag = false,
|
||||
bool showCloseButtonDuringLoading = false,
|
||||
Widget? headerWidget,
|
||||
Widget? footerWidget,
|
||||
}) async {
|
||||
return await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (bottomSheetContext) {
|
||||
return BlocProvider(
|
||||
create: (_) => StripePaymentBloc(stripeService: StripeService())
|
||||
..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)),
|
||||
child: StripePaymentScreen(
|
||||
clientSecret: clientSecret,
|
||||
amount: amount,
|
||||
currencySymbol: currencySymbol,
|
||||
title: title,
|
||||
loadingMessage: loadingMessage,
|
||||
successMessage: successMessage,
|
||||
failureMessage: failureMessage,
|
||||
onPaymentSuccess: onPaymentSuccess,
|
||||
onPaymentFailure: onPaymentFailure,
|
||||
onPaymentCancelled: onPaymentCancelled,
|
||||
primaryColor: primaryColor,
|
||||
successColor: successColor,
|
||||
errorColor: errorColor,
|
||||
heightRatio: heightRatio,
|
||||
showCloseButtonDuringLoading: showCloseButtonDuringLoading,
|
||||
headerWidget: headerWidget,
|
||||
footerWidget: footerWidget,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _StripePaymentViewContentState extends State<StripePaymentViewContent> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Automatically initiate payment when screen loads
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<StripePaymentBloc>().add(
|
||||
InitiatePayment(
|
||||
amount: widget.amount,
|
||||
currency: widget.currency,
|
||||
),
|
||||
);
|
||||
});
|
||||
/// 🚀 Static method to show as full screen dialog
|
||||
static Future<bool?> showAsDialog({
|
||||
required BuildContext context,
|
||||
required String clientSecret,
|
||||
double? amount,
|
||||
String currencySymbol = '\$',
|
||||
String? title,
|
||||
String loadingMessage = 'Processing payment...',
|
||||
String successMessage = 'Payment Successful!',
|
||||
String failureMessage = 'Payment Failed',
|
||||
VoidCallback? onPaymentSuccess,
|
||||
void Function(String error)? onPaymentFailure,
|
||||
VoidCallback? onPaymentCancelled,
|
||||
Color primaryColor = const Color(0xFFF95F62),
|
||||
Color successColor = Colors.green,
|
||||
Color errorColor = Colors.red,
|
||||
bool barrierDismissible = false,
|
||||
bool showCloseButtonDuringLoading = false,
|
||||
Widget? headerWidget,
|
||||
Widget? footerWidget,
|
||||
}) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (dialogContext) {
|
||||
return BlocProvider(
|
||||
create: (_) => StripePaymentBloc(stripeService: StripeService())
|
||||
..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)),
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: StripePaymentScreen(
|
||||
clientSecret: clientSecret,
|
||||
amount: amount,
|
||||
currencySymbol: currencySymbol,
|
||||
title: title,
|
||||
loadingMessage: loadingMessage,
|
||||
successMessage: successMessage,
|
||||
failureMessage: failureMessage,
|
||||
onPaymentSuccess: onPaymentSuccess,
|
||||
onPaymentFailure: onPaymentFailure,
|
||||
onPaymentCancelled: onPaymentCancelled,
|
||||
primaryColor: primaryColor,
|
||||
successColor: successColor,
|
||||
errorColor: errorColor,
|
||||
heightRatio: 1.0,
|
||||
showCloseButtonDuringLoading: showCloseButtonDuringLoading,
|
||||
headerWidget: headerWidget,
|
||||
footerWidget: footerWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<StripePaymentBloc, StripePaymentState>(
|
||||
return BlocConsumer<StripePaymentBloc, StripePaymentState>(
|
||||
// 🔒 CRITICAL: Only listen when state actually changes to prevent duplicate triggers
|
||||
listenWhen: (previous, current) {
|
||||
// Don't re-trigger if both states are the same success state
|
||||
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
|
||||
debugPrint('⚠️ Preventing duplicate success listener');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
listener: (context, state) {
|
||||
if (state is StripePaymentSuccess) {
|
||||
// Show success message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
// Return success to previous screen
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
debugPrint('✅ Payment Success - Calling callback');
|
||||
// ✅ Call the callback first
|
||||
onPaymentSuccess?.call();
|
||||
// ✅ Then auto-close and return true after 1.5 seconds
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
});
|
||||
} else if (state is StripePaymentFailure) {
|
||||
// Show error message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.error),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
// Go back to checkout on error
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
Navigator.pop(context, false);
|
||||
debugPrint('❌ Payment Failure - ${state.error}');
|
||||
onPaymentFailure?.call(state.error);
|
||||
// Auto-close after 2 seconds on failure
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
});
|
||||
} else if (state is StripePaymentCancelled) {
|
||||
// Show cancellation message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
// Go back to checkout on cancellation
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
Navigator.pop(context, false);
|
||||
}
|
||||
});
|
||||
debugPrint('🚫 Payment Cancelled');
|
||||
onPaymentCancelled?.call();
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text("Processing Payment"),
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: false, // Remove back button during processing
|
||||
centerTitle: true,
|
||||
),
|
||||
body: BlocBuilder<StripePaymentBloc, StripePaymentState>(
|
||||
builder: (context, state) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Loading Indicator
|
||||
if (state is StripePaymentLoading) ...[
|
||||
const CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"Preparing secure payment...",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
"Please wait",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Amount Display
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFE0E0E0),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
"Payment Amount",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"\$${widget.amount.toStringAsFixed(2)}",
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.currency.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Security Badge
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"Secured by Stripe",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
buildWhen: (previous, current) {
|
||||
// 🔒 Prevent unnecessary rebuilds on duplicate success states
|
||||
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
height: heightRatio == 1.0
|
||||
? MediaQuery.of(context).size.height
|
||||
: MediaQuery.of(context).size.height * heightRatio,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: heightRatio == 1.0
|
||||
? null
|
||||
: const BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Main content
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Custom header widget
|
||||
if (headerWidget != null) ...[
|
||||
headerWidget!,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
// Title
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Amount display
|
||||
if (amount != null) ...[
|
||||
Text(
|
||||
'$currencySymbol${amount!.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
fontSize: 32.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Payment status
|
||||
_buildPaymentStatus(context, state),
|
||||
|
||||
// Custom footer widget
|
||||
if (footerWidget != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
footerWidget!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Close button (only show when allowed)
|
||||
if (_shouldShowCloseButton(state))
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
if (state is StripePaymentLoading) {
|
||||
// Cancel payment if loading
|
||||
context
|
||||
.read<StripePaymentBloc>()
|
||||
.add(CancelPaymentEvent());
|
||||
} else {
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Colors.grey[600],
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build payment status widget based on state
|
||||
Widget _buildPaymentStatus(BuildContext context, StripePaymentState state) {
|
||||
if (state is StripePaymentLoading) {
|
||||
return Column(
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: Color(0xffF95F62),
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
loadingMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentSuccess) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: successColor,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
successMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentFailure) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error,
|
||||
color: errorColor,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
failureMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.error,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Retry payment
|
||||
context.read<StripePaymentBloc>().add(
|
||||
RetryPaymentEvent(
|
||||
clientSecret: clientSecret,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Retry Payment',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentCancelled) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cancel,
|
||||
color: Colors.orange,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Payment Cancelled',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// Determine if close button should be shown
|
||||
bool _shouldShowCloseButton(StripePaymentState state) {
|
||||
if (state is StripePaymentLoading) {
|
||||
return showCloseButtonDuringLoading;
|
||||
}
|
||||
// Show for failure and cancelled states
|
||||
return state is StripePaymentFailure || state is StripePaymentCancelled;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_textfield.dart';
|
||||
import 'package:country_code_picker/country_code_picker.dart'; // ✅ NEW IMPORT
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:phone_numbers_parser/phone_numbers_parser.dart'; // ✅ NEW IMPORT
|
||||
|
||||
import '../checkout/bloc/pass_purchase_details_bloc.dart';
|
||||
import '../checkout/bloc/pass_purchase_details_event.dart';
|
||||
@@ -25,29 +27,46 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
final TextEditingController cityController = TextEditingController();
|
||||
String? selectedCountry;
|
||||
final TextEditingController countryController = TextEditingController();
|
||||
|
||||
String _selectedIsdCode = '+61'; // ✅ NEW: tracks selected country dial code
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
firstNameController.dispose();
|
||||
lastNameController.dispose();
|
||||
emailController.dispose();
|
||||
countryController.dispose();
|
||||
phoneController.dispose();
|
||||
cityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isValidEmail(String email) {
|
||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||
return emailRegex.hasMatch(email);
|
||||
}
|
||||
|
||||
// ✅ UPDATED: now validates phone using phone_numbers_parser against the selected ISD code
|
||||
bool _isValidPhone(String phone) {
|
||||
try {
|
||||
final fullNumber = '$_selectedIsdCode$phone';
|
||||
final parsed = PhoneNumber.parse(fullNumber);
|
||||
return parsed.isValid();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(BuildContext context, bool isSubmitting) {
|
||||
// If already submitting, do nothing
|
||||
if (isSubmitting) return;
|
||||
|
||||
// Validate inputs
|
||||
if (firstNameController.text.isEmpty ||
|
||||
lastNameController.text.isEmpty ||
|
||||
emailController.text.isEmpty ||
|
||||
phoneController.text.isEmpty ||
|
||||
cityController.text.isEmpty ||
|
||||
selectedCountry == null) {
|
||||
countryController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please fill all fields'),
|
||||
@@ -57,17 +76,38 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit gift details
|
||||
if (!_isValidEmail(emailController.text)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please enter a valid email address'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ UPDATED: error message now shows the selected ISD code
|
||||
if (!_isValidPhone(phoneController.text)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Enter a valid phone number for $_selectedIsdCode'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<PurchaseDetailsBloc>().add(
|
||||
SubmitUserDetailsEvent(
|
||||
bookingId: widget.bookingId,
|
||||
isForSelf: false,
|
||||
recipientFirstName: firstNameController.text,
|
||||
recipientLastName: lastNameController.text,
|
||||
isdCode: _selectedIsdCode,
|
||||
recipientEmail: emailController.text,
|
||||
recipientPhone: phoneController.text,
|
||||
city: cityController.text,
|
||||
country: selectedCountry!,
|
||||
country: countryController.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -78,21 +118,10 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
create: (_) => PurchaseDetailsBloc(),
|
||||
child: BlocConsumer<PurchaseDetailsBloc, PurchaseDetailsState>(
|
||||
listener: (context, state) {
|
||||
// Handle API submission success
|
||||
if (state is PurchaseDetailsSubmitted) {
|
||||
// Show success message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Gift details submitted successfully!'),
|
||||
backgroundColor: Color(0xffF95F62),
|
||||
),
|
||||
);
|
||||
|
||||
// Navigate back
|
||||
Navigator.of(context).pop('success');
|
||||
}
|
||||
|
||||
// Handle API submission error
|
||||
if (state is PurchaseDetailsError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -151,106 +180,100 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "First Name",
|
||||
label: "First Name *",
|
||||
hint: "Enter recipient's first name",
|
||||
controller: firstNameController,
|
||||
onlyLetters: true,
|
||||
maxLength: 50,
|
||||
noSpace: true,
|
||||
isFirstLetterCapital: true,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Last Name",
|
||||
label: "Last Name *",
|
||||
hint: "Enter recipient's last name",
|
||||
controller: lastNameController,
|
||||
onlyLetters: true,
|
||||
maxLength: 50,
|
||||
noSpace: true,
|
||||
isFirstLetterCapital: true,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Email",
|
||||
label: "Email *",
|
||||
hint: "Enter recipient's email address",
|
||||
controller: emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
),
|
||||
|
||||
// ✅ NEW: Phone field with CountryCodePicker (replaces plain CustomTextField)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Phone Number",
|
||||
hint: "Enter recipient's phone number",
|
||||
label: "Phone Number *",
|
||||
hint: "Enter phone number",
|
||||
controller: phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 12,
|
||||
numbersOnly: true,
|
||||
prefixWidget: CountryCodePicker(
|
||||
onChanged: (country) {
|
||||
setState(() => _selectedIsdCode = country.dialCode!);
|
||||
},
|
||||
initialSelection: 'AU',
|
||||
favorite: const ['+61', '+1', '+44', '+91'],
|
||||
showCountryOnly: false,
|
||||
showOnlyCountryWhenClosed: false,
|
||||
alignLeft: false,
|
||||
flagWidth: 24.w,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: const Color(0xFF2D3134),
|
||||
),
|
||||
dialogTextStyle: TextStyle(fontSize: 14.sp),
|
||||
searchDecoration: const InputDecoration(
|
||||
hintText: 'Search country...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// ✅ END of new phone field
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "City",
|
||||
label: "City *",
|
||||
hint: "Enter the name of the city",
|
||||
controller: cityController,
|
||||
maxLength: 50,
|
||||
onlyLetters: true,
|
||||
isFirstLetterCapital: true,
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(text: "Country", size: 14.sp),
|
||||
SizedBox(height: 6.h),
|
||||
Container(
|
||||
height: 42.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5F5),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: const Color(0xBBC83B61).withOpacity(0.4),
|
||||
width: 0.4.w,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedCountry,
|
||||
isExpanded: true,
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Color(0xFF8E8E8E),
|
||||
),
|
||||
hint: Text(
|
||||
"Select country",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF8E8E8E),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF2D3134),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedCountry = value;
|
||||
});
|
||||
},
|
||||
items: ["India", "USA", "UK", "Canada"]
|
||||
.map((value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 14.sp),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Country *",
|
||||
hint: "Enter country name",
|
||||
controller: countryController,
|
||||
maxLength: 50,
|
||||
onlyLetters: true,
|
||||
isFirstLetterCapital: true,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
// Option 1: Pass empty function when disabled (doesn't change button appearance)
|
||||
CustomFilledButton(
|
||||
onTap: () => _handleSubmit(context, isSubmitting),
|
||||
label: isSubmitting ? "Submitting..." : "Continue",
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../core/route_constants.dart';
|
||||
import '../bloc/attraction_details_bloc.dart';
|
||||
@@ -33,7 +34,7 @@ class AttractionDetailsView extends StatelessWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
child: CircularProgressIndicator(color: Color(0xffF95F62)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -150,12 +151,9 @@ class AttractionDetailsView extends StatelessWidget {
|
||||
right: 17.w,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) =>
|
||||
const ShareBottomSheet(),
|
||||
Share.share(
|
||||
'www.google.com',
|
||||
subject: 'Check this out',
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
@@ -174,7 +172,7 @@ class AttractionDetailsView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
@@ -26,15 +26,18 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// drag handle
|
||||
Container(
|
||||
height: 4.h,
|
||||
width: 47.w,
|
||||
margin: EdgeInsets.only(bottom: 16),
|
||||
margin: EdgeInsets.only(bottom: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF222222),
|
||||
color: const Color(0xFF222222),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
|
||||
// link field
|
||||
TextField(
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(
|
||||
@@ -51,7 +54,10 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// grid
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -67,7 +73,16 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(item['icon']!, width: 55.w),
|
||||
// FIXED SIZE ICON CONTAINER
|
||||
Container(
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
item['icon']!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
item['title']!,
|
||||
@@ -78,26 +93,32 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// page indicator
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
4,
|
||||
(index) => Container(
|
||||
(index) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: 8.w,
|
||||
height: 8.h,
|
||||
decoration: BoxDecoration(
|
||||
color: index == 0 ? Color(0xFF676363) : Colors.white,
|
||||
border: Border.all(color: Color(0xFF676363)),
|
||||
color: index == 0
|
||||
? const Color(0xFF676363)
|
||||
: Colors.white,
|
||||
border: Border.all(color: const Color(0xFF676363)),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 10.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import 'attractions_state.dart';
|
||||
class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
|
||||
final AttractionsRepository repository;
|
||||
|
||||
AttractionsBloc({required this.repository})
|
||||
: super(AttractionsInitial()) {
|
||||
AttractionsBloc({required this.repository}) : super(AttractionsInitial()) {
|
||||
on<FetchAttractionsByCategory>(_onFetchAttractionsByCategory);
|
||||
on<SearchAttractions>(_onSearchAttractions);
|
||||
}
|
||||
|
||||
Future<void> _onFetchAttractionsByCategory(
|
||||
@@ -21,22 +21,50 @@ class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
|
||||
try {
|
||||
final AttractionsResponse response =
|
||||
await repository.fetchAttractionsByCategory(
|
||||
categoryXid: event.categoryXid, // Can be null now
|
||||
categoryXid: event.categoryXid,
|
||||
);
|
||||
|
||||
final allAttractions = response.attractions ?? [];
|
||||
|
||||
emit(
|
||||
AttractionsLoaded(
|
||||
attractions: response.attractions ?? [],
|
||||
attractions: allAttractions,
|
||||
allAttractions: allAttractions,
|
||||
categories: response.categories ?? [],
|
||||
selectedCategoryId: event.categoryXid, // Can be null
|
||||
selectedCategoryId: event.categoryXid,
|
||||
searchQuery: '',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
AttractionsError(
|
||||
e.toString(),
|
||||
),
|
||||
);
|
||||
emit(AttractionsError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchAttractions(
|
||||
SearchAttractions event,
|
||||
Emitter<AttractionsState> emit,
|
||||
) {
|
||||
final currentState = state;
|
||||
if (currentState is! AttractionsLoaded) return;
|
||||
|
||||
final query = event.query.trim().toLowerCase();
|
||||
|
||||
final filtered = query.isEmpty
|
||||
? currentState.allAttractions
|
||||
: currentState.allAttractions.where((attraction) {
|
||||
final name = (attraction.title ?? '').toLowerCase();
|
||||
final description = (attraction.description ?? '').toLowerCase();
|
||||
return name.contains(query) || description.contains(query);
|
||||
}).toList();
|
||||
|
||||
emit(
|
||||
AttractionsLoaded(
|
||||
attractions: filtered,
|
||||
allAttractions: currentState.allAttractions,
|
||||
categories: currentState.categories,
|
||||
selectedCategoryId: currentState.selectedCategoryId,
|
||||
searchQuery: event.query,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,19 @@ abstract class AttractionsEvent extends Equatable {
|
||||
}
|
||||
|
||||
class FetchAttractionsByCategory extends AttractionsEvent {
|
||||
final int? categoryXid; // Make it nullable
|
||||
final int? categoryXid;
|
||||
|
||||
const FetchAttractionsByCategory({this.categoryXid}); // Remove required
|
||||
const FetchAttractionsByCategory({this.categoryXid});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [categoryXid];
|
||||
}
|
||||
|
||||
class SearchAttractions extends AttractionsEvent {
|
||||
final String query;
|
||||
|
||||
const SearchAttractions(this.query);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [query];
|
||||
}
|
||||
@@ -14,17 +14,27 @@ class AttractionsLoading extends AttractionsState {}
|
||||
|
||||
class AttractionsLoaded extends AttractionsState {
|
||||
final List<Attraction> attractions;
|
||||
final List<Attraction> allAttractions; // Keep full list for local filtering
|
||||
final List<Category> categories;
|
||||
final int? selectedCategoryId; // Make it nullable
|
||||
final int? selectedCategoryId;
|
||||
final String searchQuery;
|
||||
|
||||
const AttractionsLoaded({
|
||||
required this.attractions,
|
||||
required this.allAttractions,
|
||||
required this.categories,
|
||||
this.selectedCategoryId, // Remove required
|
||||
this.selectedCategoryId,
|
||||
this.searchQuery = '',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [attractions, categories, selectedCategoryId];
|
||||
List<Object?> get props => [
|
||||
attractions,
|
||||
allAttractions,
|
||||
categories,
|
||||
selectedCategoryId,
|
||||
searchQuery,
|
||||
];
|
||||
}
|
||||
|
||||
class AttractionsError extends AttractionsState {
|
||||
|
||||
@@ -37,9 +37,9 @@ class Attraction {
|
||||
final String title;
|
||||
final String description;
|
||||
final String urlSlug;
|
||||
final int cityXid;
|
||||
final int cardTypeXid;
|
||||
final int partnerXid;
|
||||
final num cityXid;
|
||||
final num cardTypeXid;
|
||||
final num partnerXid;
|
||||
final String productCode;
|
||||
|
||||
final bool isBookingRequired;
|
||||
@@ -47,14 +47,14 @@ class Attraction {
|
||||
final String bookingEmail;
|
||||
final String bookingPhoneNumber;
|
||||
|
||||
final double latitudeCoordinate;
|
||||
final double longitudeCoordinate;
|
||||
final num latitudeCoordinate;
|
||||
final num longitudeCoordinate;
|
||||
final String address;
|
||||
|
||||
final double? ticketPriceAdult;
|
||||
final double? ticketPriceChild;
|
||||
final int durations;
|
||||
final int groupSize;
|
||||
final num? ticketPriceAdult;
|
||||
final num? ticketPriceChild;
|
||||
final num durations;
|
||||
final num groupSize;
|
||||
final String ageRange;
|
||||
|
||||
final String seoTitle;
|
||||
@@ -115,13 +115,11 @@ class Attraction {
|
||||
isPartnerAccess: json['isPartnerAccess'] ?? false,
|
||||
bookingEmail: json['bookingEmail'] ?? '',
|
||||
bookingPhoneNumber: json['bookingPhonenumber'] ?? '',
|
||||
latitudeCoordinate:
|
||||
(json['latitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
|
||||
longitudeCoordinate:
|
||||
(json['longitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
|
||||
latitudeCoordinate: (json['latitudeCoordinate'] as num?) ?? 0,
|
||||
longitudeCoordinate: (json['longitudeCoordinate'] as num?) ?? 0,
|
||||
address: json['address'] ?? '',
|
||||
ticketPriceAdult: (json['ticketPriceAdult'] as num?)?.toDouble(),
|
||||
ticketPriceChild: (json['ticketPriceChild'] as num?)?.toDouble(),
|
||||
ticketPriceAdult: json['ticketPriceAdult'] as num?,
|
||||
ticketPriceChild: json['ticketPriceChild'] as num?,
|
||||
durations: json['durations'] ?? 0,
|
||||
groupSize: json['groupSize'] ?? 0,
|
||||
ageRange: json['ageRange'] ?? '',
|
||||
@@ -197,9 +195,9 @@ class Attraction {
|
||||
class CardModel {
|
||||
final int id;
|
||||
final String title;
|
||||
final int cardTypeXid;
|
||||
final int adultPrice;
|
||||
final int childPrice;
|
||||
final num cardTypeXid;
|
||||
final num adultPrice;
|
||||
final num childPrice;
|
||||
final String cardStatus;
|
||||
|
||||
CardModel({
|
||||
@@ -234,7 +232,6 @@ class CardModel {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* -------------------- GALLERY -------------------- */
|
||||
|
||||
class Gallery {
|
||||
@@ -275,7 +272,6 @@ class Gallery {
|
||||
bool get hasImage => filePathUrl.isNotEmpty;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------- CATEGORY -------------------- */
|
||||
|
||||
class Category {
|
||||
@@ -300,5 +296,4 @@ class Category {
|
||||
'categoryName': categoryName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -56,8 +56,7 @@ class AttractionsPage extends StatelessWidget {
|
||||
hint: "Search attractions...",
|
||||
hintColor: Colors.grey.shade500,
|
||||
onChanged: (value) {
|
||||
// ❌ Search logic intentionally disabled
|
||||
// UI only, no API call
|
||||
bloc.add(SearchAttractions(value));
|
||||
},
|
||||
),
|
||||
|
||||
@@ -106,7 +105,7 @@ class AttractionsPage extends StatelessWidget {
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: CircularProgressIndicator(),
|
||||
child: CircularProgressIndicator(color: Color(0xffF95F62)),
|
||||
),
|
||||
)
|
||||
else if (state is AttractionsLoaded)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
@@ -24,7 +25,7 @@ class AttractionCard extends StatelessWidget {
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionDetails,
|
||||
arguments: attraction,
|
||||
arguments: attraction.id,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
@@ -42,12 +43,13 @@ class AttractionCard extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: imageUrl.isNotEmpty
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
height: 94.h,
|
||||
width: 94.w,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _imageFallback(),
|
||||
placeholder: (context, url) => _imageFallback(),
|
||||
errorWidget: (_, __, ___) => _imageFallback(),
|
||||
)
|
||||
: _imageFallback(),
|
||||
),
|
||||
@@ -61,6 +63,8 @@ class AttractionCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
attraction.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -71,6 +75,8 @@ class AttractionCard extends StatelessWidget {
|
||||
|
||||
Text(
|
||||
attraction.address,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
@@ -84,7 +90,7 @@ class AttractionCard extends StatelessWidget {
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "from \$${attraction.ticketPriceAdult}",
|
||||
text: "\$${attraction.ticketPriceAdult}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -104,10 +110,8 @@ class AttractionCard extends StatelessWidget {
|
||||
),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
/// TAGS (CARD TITLES)
|
||||
attraction.isBookingRequired == false
|
||||
? Wrap(
|
||||
Wrap(
|
||||
spacing: 6.w,
|
||||
runSpacing: 6.h,
|
||||
children: tags
|
||||
@@ -145,27 +149,6 @@ class AttractionCard extends StatelessWidget {
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 10.w,
|
||||
vertical: 4.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffC1D2F8),
|
||||
border: Border.all(
|
||||
color: const Color(0xff2563EB),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
"Booking Required",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11.sp,
|
||||
color: const Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,7 +20,18 @@ class BuyPassBloc extends Bloc<BuyPassEvent, BuyPassState> {
|
||||
on<UpdateChildCount>(_onUpdateChildCount);
|
||||
|
||||
/// Handle update validity duration event
|
||||
on<UpdateValidityDuration>(_onUpdateValidityDuration); // ✅ Added
|
||||
on<UpdateValidityDuration>(_onUpdateValidityDuration);
|
||||
on<AddToCartLoading>((event, emit) {
|
||||
if (state is BuyPassLoaded) {
|
||||
emit((state as BuyPassLoaded).copyWith(isAddingToCart: true));
|
||||
}
|
||||
});
|
||||
|
||||
on<AddToCartDone>((event, emit) {
|
||||
if (state is BuyPassLoaded) {
|
||||
emit((state as BuyPassLoaded).copyWith(isAddingToCart: false));
|
||||
}
|
||||
});// ✅ Added
|
||||
}
|
||||
|
||||
/// Fetch buy pass data from repository
|
||||
|
||||
@@ -29,4 +29,6 @@ class UpdateValidityDuration extends BuyPassEvent {
|
||||
final int duration;
|
||||
|
||||
UpdateValidityDuration(this.duration);
|
||||
}
|
||||
}
|
||||
class AddToCartLoading extends BuyPassEvent {}
|
||||
class AddToCartDone extends BuyPassEvent {}
|
||||
@@ -14,15 +14,17 @@ class BuyPassLoaded extends BuyPassState {
|
||||
final int selectedCardIndex;
|
||||
final int adultCount;
|
||||
final int childCount;
|
||||
final int validityDuration; // ✅ Added
|
||||
final int validityDuration;
|
||||
final bool isAddingToCart;
|
||||
|
||||
BuyPassLoaded({
|
||||
required this.data,
|
||||
this.selectedCardIndex = 0,
|
||||
this.adultCount = 1,
|
||||
this.childCount = 1,
|
||||
int? validityDuration, // ✅ Added as optional parameter
|
||||
}) : validityDuration = validityDuration ?? data.cards[selectedCardIndex].minNumber; // ✅ Initialize with minNumber
|
||||
int? validityDuration,
|
||||
this.isAddingToCart = false, // ✅ default false, NOT required
|
||||
}) : validityDuration = validityDuration ?? data.cards[selectedCardIndex].minNumber;
|
||||
|
||||
/// Method to copy state with updated values
|
||||
BuyPassLoaded copyWith({
|
||||
@@ -30,14 +32,16 @@ class BuyPassLoaded extends BuyPassState {
|
||||
int? selectedCardIndex,
|
||||
int? adultCount,
|
||||
int? childCount,
|
||||
int? validityDuration, // ✅ Added
|
||||
int? validityDuration,
|
||||
bool? isAddingToCart,
|
||||
}) {
|
||||
return BuyPassLoaded(
|
||||
data: data ?? this.data,
|
||||
selectedCardIndex: selectedCardIndex ?? this.selectedCardIndex,
|
||||
adultCount: adultCount ?? this.adultCount,
|
||||
childCount: childCount ?? this.childCount,
|
||||
validityDuration: validityDuration ?? this.validityDuration, // ✅ Added
|
||||
validityDuration: validityDuration ?? this.validityDuration,
|
||||
isAddingToCart: isAddingToCart ?? this.isAddingToCart,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +51,8 @@ class BuyPassLoaded extends BuyPassState {
|
||||
/// Calculate total price
|
||||
double get totalPrice {
|
||||
final card = selectedCard;
|
||||
return ((card.adultPrice * adultCount) + (card.childPrice * childCount)) * validityDuration.toDouble(); // ✅ Multiply by validityDuration
|
||||
return ((card.adultPrice * adultCount) + (card.childPrice * childCount)) *
|
||||
validityDuration.toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ String buyPassModelToJson(BuyPassModel data) =>
|
||||
json.encode(data.toJson());
|
||||
|
||||
class BuyPassModel {
|
||||
final City city;
|
||||
final List<Offer> offers;
|
||||
final List<CardPass> cards;
|
||||
final List<Attraction> attractions;
|
||||
City city;
|
||||
List<Offer> offers;
|
||||
List<CardPass> cards;
|
||||
List<Attraction> attractions;
|
||||
|
||||
BuyPassModel({
|
||||
required this.city,
|
||||
@@ -20,41 +20,49 @@ class BuyPassModel {
|
||||
required this.attractions,
|
||||
});
|
||||
|
||||
factory BuyPassModel.fromJson(Map<String, dynamic> json) {
|
||||
factory BuyPassModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return BuyPassModel(
|
||||
city: City.fromJson(json['city']),
|
||||
offers: List<Offer>.from(
|
||||
json['offers'].map((x) => Offer.fromJson(x)),
|
||||
),
|
||||
cards: List<CardPass>.from(
|
||||
json['cards'].map((x) => CardPass.fromJson(x)),
|
||||
),
|
||||
attractions: List<Attraction>.from(
|
||||
json['attractions'].map((x) => Attraction.fromJson(x)),
|
||||
),
|
||||
offers: json['offers'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['offers'])
|
||||
.map((e) => Offer.fromJson(e))
|
||||
.toList(),
|
||||
cards: json['cards'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['cards'])
|
||||
.map((e) => CardPass.fromJson(e))
|
||||
.toList(),
|
||||
attractions: json['attractions'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['attractions'])
|
||||
.map((e) => Attraction.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"city": city.toJson(),
|
||||
"offers": offers.map((x) => x.toJson()).toList(),
|
||||
"cards": cards.map((x) => x.toJson()).toList(),
|
||||
"attractions": attractions.map((x) => x.toJson()).toList(),
|
||||
"offers": offers.map((e) => e.toJson()).toList(),
|
||||
"cards": cards.map((e) => e.toJson()).toList(),
|
||||
"attractions": attractions.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CITY ----------
|
||||
class City {
|
||||
final int id;
|
||||
final String name;
|
||||
final String slug;
|
||||
final String tagLine;
|
||||
final String description;
|
||||
final String bestTimeToVisit;
|
||||
final String priceRange;
|
||||
final num individualTicketAmount; // Changed from int to num
|
||||
final num cityCardTicketAmount; // Changed from int to num
|
||||
final HeroBanner heroBanner;
|
||||
int id;
|
||||
String name;
|
||||
String slug;
|
||||
String tagLine;
|
||||
String description;
|
||||
String bestTimeToVisit;
|
||||
String priceRange;
|
||||
num individualTicketAmount;
|
||||
num cityCardTicketAmount;
|
||||
HeroBanner heroBanner;
|
||||
|
||||
City({
|
||||
required this.id,
|
||||
@@ -69,17 +77,19 @@ class City {
|
||||
required this.heroBanner,
|
||||
});
|
||||
|
||||
factory City.fromJson(Map<String, dynamic> json) {
|
||||
factory City.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return City(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
slug: json['slug'],
|
||||
tagLine: json['tagLine'],
|
||||
description: json['description'],
|
||||
bestTimeToVisit: json['bestTimeToVisit'],
|
||||
priceRange: json['priceRange'],
|
||||
individualTicketAmount: json['individualTicketAmount'],
|
||||
cityCardTicketAmount: json['cityCardTicketAmount'],
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? "",
|
||||
slug: json['slug']?.toString() ?? "",
|
||||
tagLine: json['tagLine']?.toString() ?? "",
|
||||
description: json['description']?.toString() ?? "",
|
||||
bestTimeToVisit: json['bestTimeToVisit']?.toString() ?? "",
|
||||
priceRange: json['priceRange']?.toString() ?? "",
|
||||
individualTicketAmount: json['individualTicketAmount'] ?? 0,
|
||||
cityCardTicketAmount: json['cityCardTicketAmount'] ?? 0,
|
||||
heroBanner: HeroBanner.fromJson(json['heroBanner']),
|
||||
);
|
||||
}
|
||||
@@ -100,18 +110,20 @@ class City {
|
||||
|
||||
/// ---------- HERO BANNER ----------
|
||||
class HeroBanner {
|
||||
final String title;
|
||||
final String image;
|
||||
String title;
|
||||
String image;
|
||||
|
||||
HeroBanner({
|
||||
required this.title,
|
||||
required this.image,
|
||||
});
|
||||
|
||||
factory HeroBanner.fromJson(Map<String, dynamic> json) {
|
||||
factory HeroBanner.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return HeroBanner(
|
||||
title: json['title'],
|
||||
image: json['image'],
|
||||
title: json['title']?.toString() ?? "",
|
||||
image: json['image']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,25 +135,25 @@ class HeroBanner {
|
||||
|
||||
/// ---------- OFFER ----------
|
||||
class Offer {
|
||||
final int id;
|
||||
final String title;
|
||||
final String offerCode;
|
||||
final String? description; // ✅ optional
|
||||
final String? redemptionLink; // ✅ optional
|
||||
final String websiteBannerImage;
|
||||
final String mobileBannerImage;
|
||||
final String passType;
|
||||
final DateTime startDateTime;
|
||||
final DateTime endDateTime;
|
||||
final String offerStatus;
|
||||
final bool applyToPasses;
|
||||
int id;
|
||||
String title;
|
||||
String offerCode;
|
||||
String description;
|
||||
String redemptionLink;
|
||||
String websiteBannerImage;
|
||||
String mobileBannerImage;
|
||||
String passType;
|
||||
DateTime startDateTime;
|
||||
DateTime endDateTime;
|
||||
String offerStatus;
|
||||
bool applyToPasses;
|
||||
|
||||
Offer({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.offerCode,
|
||||
this.description,
|
||||
this.redemptionLink,
|
||||
required this.description,
|
||||
required this.redemptionLink,
|
||||
required this.websiteBannerImage,
|
||||
required this.mobileBannerImage,
|
||||
required this.passType,
|
||||
@@ -151,20 +163,24 @@ class Offer {
|
||||
required this.applyToPasses,
|
||||
});
|
||||
|
||||
factory Offer.fromJson(Map<String, dynamic> json) {
|
||||
factory Offer.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return Offer(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
offerCode: json['offerCode'],
|
||||
description: json['description'], // ✅
|
||||
redemptionLink: json['redemptionLink'], // ✅
|
||||
websiteBannerImage: json['websiteBannerImage'],
|
||||
mobileBannerImage: json['mobileBannerImage'],
|
||||
passType: json['passType'],
|
||||
startDateTime: DateTime.parse(json['startDateTime']),
|
||||
endDateTime: DateTime.parse(json['endDateTime']),
|
||||
offerStatus: json['offerStatus'],
|
||||
applyToPasses: json['applyToPasses'],
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
title: json['title']?.toString() ?? "",
|
||||
offerCode: json['offerCode']?.toString() ?? "",
|
||||
description: json['description']?.toString() ?? "",
|
||||
redemptionLink: json['redemptionLink']?.toString() ?? "",
|
||||
websiteBannerImage: json['websiteBannerImage']?.toString() ?? "",
|
||||
mobileBannerImage: json['mobileBannerImage']?.toString() ?? "",
|
||||
passType: json['passType']?.toString() ?? "",
|
||||
startDateTime: DateTime.tryParse(json['startDateTime'] ?? "") ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
endDateTime: DateTime.tryParse(json['endDateTime'] ?? "") ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
offerStatus: json['offerStatus']?.toString() ?? "",
|
||||
applyToPasses: json['applyToPasses'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,16 +202,16 @@ class Offer {
|
||||
|
||||
/// ---------- CARD PASS ----------
|
||||
class CardPass {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final int validityDuration;
|
||||
final num adultPrice; // Changed from int to num
|
||||
final num childPrice; // Changed from int to num
|
||||
final int minNumber; // ✅ NEW
|
||||
final int maxNumber; // ✅ NEW
|
||||
final CardType cardType;
|
||||
final List<Offer> offers;
|
||||
int id;
|
||||
String title;
|
||||
String description;
|
||||
int validityDuration;
|
||||
num adultPrice;
|
||||
num childPrice;
|
||||
int minNumber;
|
||||
int maxNumber;
|
||||
CardType cardType;
|
||||
List<Offer> offers;
|
||||
|
||||
CardPass({
|
||||
required this.id,
|
||||
@@ -210,20 +226,24 @@ class CardPass {
|
||||
required this.offers,
|
||||
});
|
||||
|
||||
factory CardPass.fromJson(Map<String, dynamic> json) {
|
||||
factory CardPass.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return CardPass(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
validityDuration: json['validityDuration'],
|
||||
adultPrice: json['adultPrice'],
|
||||
childPrice: json['childPrice'],
|
||||
minNumber: json['minNumber'], // ✅
|
||||
maxNumber: json['maxNumber'], // ✅
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
title: json['title']?.toString() ?? "",
|
||||
description: json['description']?.toString() ?? "",
|
||||
validityDuration: (json['validityDuration'] as num?)?.toInt() ?? 0,
|
||||
adultPrice: json['adultPrice'] ?? 0,
|
||||
childPrice: json['childPrice'] ?? 0,
|
||||
minNumber: (json['minNumber'] as num?)?.toInt() ?? 0,
|
||||
maxNumber: (json['maxNumber'] as num?)?.toInt() ?? 0,
|
||||
cardType: CardType.fromJson(json['cardType']),
|
||||
offers: List<Offer>.from(
|
||||
json['offers'].map((x) => Offer.fromJson(x)),
|
||||
),
|
||||
offers: json['offers'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['offers'])
|
||||
.map((e) => Offer.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,15 +257,15 @@ class CardPass {
|
||||
"minNumber": minNumber,
|
||||
"maxNumber": maxNumber,
|
||||
"cardType": cardType.toJson(),
|
||||
"offers": offers.map((x) => x.toJson()).toList(),
|
||||
"offers": offers.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CARD TYPE ----------
|
||||
class CardType {
|
||||
final int id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
int id;
|
||||
String name;
|
||||
String displayName;
|
||||
|
||||
CardType({
|
||||
required this.id,
|
||||
@@ -253,11 +273,13 @@ class CardType {
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
factory CardType.fromJson(Map<String, dynamic> json) {
|
||||
factory CardType.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return CardType(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
displayName: json['displayName'],
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? "",
|
||||
displayName: json['displayName']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,27 +292,29 @@ class CardType {
|
||||
|
||||
/// ---------- ATTRACTION ----------
|
||||
class Attraction {
|
||||
final int id;
|
||||
final String title;
|
||||
final String slug;
|
||||
final String thumbnail;
|
||||
final num? startingFrom; // Changed from int? to num?
|
||||
int id;
|
||||
String title;
|
||||
String slug;
|
||||
String thumbnail;
|
||||
num startingFrom;
|
||||
|
||||
Attraction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.slug,
|
||||
required this.thumbnail,
|
||||
this.startingFrom,
|
||||
required this.startingFrom,
|
||||
});
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic> json) {
|
||||
factory Attraction.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return Attraction(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
slug: json['slug'],
|
||||
thumbnail: json['thumbnail'],
|
||||
startingFrom: json['startingFrom'],
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
title: json['title']?.toString() ?? "",
|
||||
slug: json['slug']?.toString() ?? "",
|
||||
thumbnail: json['thumbnail']?.toString() ?? "",
|
||||
startingFrom: json['startingFrom'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,4 +325,4 @@ class Attraction {
|
||||
"thumbnail": thumbnail,
|
||||
"startingFrom": startingFrom,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@ class BuyPassRepository {
|
||||
required int totalChild,
|
||||
required int noOfAttractions,
|
||||
required int noOfDays,
|
||||
required double baseAmount,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiService.postApi(
|
||||
url: ApiUrls.addToCartPasses, // add this key in ApiUrls
|
||||
url: ApiUrls.addToCartPasses,
|
||||
data: {
|
||||
"cityXid": cityXid,
|
||||
"cardTypeXid": cardTypeXid,
|
||||
@@ -38,6 +39,8 @@ class BuyPassRepository {
|
||||
"cardMode": cardMode,
|
||||
"totalAdult": totalAdult,
|
||||
"totalChild": totalChild,
|
||||
"baseAmount": baseAmount,
|
||||
"taxAmount": 2, // Fixed tax amount
|
||||
"noOfAttractions": noOfAttractions,
|
||||
"noOfDays": noOfDays,
|
||||
},
|
||||
@@ -48,4 +51,4 @@ class BuyPassRepository {
|
||||
throw Exception('Failed to add passes to cart: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,25 @@ class BuyPassView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class BuyPassContent extends StatelessWidget {
|
||||
class BuyPassContent extends StatefulWidget {
|
||||
const BuyPassContent({super.key});
|
||||
|
||||
@override
|
||||
State<BuyPassContent> createState() => _BuyPassContentState();
|
||||
}
|
||||
|
||||
class _BuyPassContentState extends State<BuyPassContent> {
|
||||
late PageController _pageController;@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(viewportFraction: 0.85);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -92,58 +108,49 @@ class BuyPassContent extends StatelessWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(text: "Buy a Pass", size: 12.sp),
|
||||
],
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.arrow_back),
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(text: "Buy a Card", size: 12.sp),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
// Pass Cards Horizontal List
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 20.0.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
data.cards.length,
|
||||
(index) {
|
||||
final card = data.cards[index];
|
||||
final isSelected = index == state.selectedCardIndex;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<BuyPassBloc>().add(
|
||||
ChangeSelectedCard(index),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 12.w),
|
||||
child: PassCardView(
|
||||
themeColor: isSelected
|
||||
? Color(0xFFF97316)
|
||||
: Color(0xFF1E8AF6),
|
||||
city: data.city.name,
|
||||
heroImage: data.city.heroBanner.image,
|
||||
adultPrice: card.adultPrice,
|
||||
childPrice: card.childPrice,
|
||||
cardType: card.cardType.displayName,
|
||||
description: card.description,
|
||||
isSelected: isSelected,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Pass Cards Horizontal List
|
||||
SizedBox(
|
||||
height: 140.h,
|
||||
child: PageView.builder(
|
||||
controller: PageController(viewportFraction: 0.92),
|
||||
itemCount: data.cards.length,
|
||||
onPageChanged: (index) {
|
||||
context.read<BuyPassBloc>().add(ChangeSelectedCard(index));
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final card = data.cards[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||||
child: PassCardView(
|
||||
themeColor: card.cardType.name == "selective_pass"
|
||||
? const Color(0xFFF95FAF)
|
||||
: const Color(0xFFF95F62),
|
||||
city: data.city.name,
|
||||
heroImage: data.city.heroBanner.image,
|
||||
adultPrice: card.adultPrice,
|
||||
childPrice: card.childPrice,
|
||||
cardType: card.cardType.displayName,
|
||||
description: card.description,
|
||||
isSelected: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -159,9 +166,9 @@ class BuyPassContent extends StatelessWidget {
|
||||
heroImage: data.city.heroBanner.image,
|
||||
cardType: selectedCard.cardType.name,
|
||||
cardDisplayName: selectedCard.cardType.displayName,
|
||||
themeColor: state.selectedCardIndex == 0
|
||||
? Color(0xFFF97316)
|
||||
: Color(0xFF1E8AF6),
|
||||
themeColor: selectedCard.cardType.name == "selective_pass"
|
||||
? Color(0xFFF95FAF) // pink for flexi/selective pass
|
||||
: Color(0xFFF95F62),
|
||||
adultPrice: selectedCard.adultPrice.toDouble(),
|
||||
childPrice: selectedCard.childPrice.toDouble(),
|
||||
adults: state.adultCount,
|
||||
@@ -209,7 +216,7 @@ class BuyPassContent extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Card Offers", size: 18.sp),
|
||||
CustomText(text: "Member Privileges", size: 18.sp),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pushNamed(
|
||||
@@ -246,12 +253,12 @@ class BuyPassContent extends StatelessWidget {
|
||||
final offer = selectedCard.offers[index];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.offerPassDetail,
|
||||
arguments: offer.id, // ✅ pass offerId
|
||||
);
|
||||
},
|
||||
// onTap: () {
|
||||
// Navigator.of(context).pushNamed(
|
||||
// RouteConstants.offerPassDetail,
|
||||
// arguments: offer.id, // ✅ pass offerId
|
||||
// );
|
||||
// },
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 6.w,
|
||||
@@ -344,7 +351,7 @@ class BuyPassContent extends StatelessWidget {
|
||||
text: offer.description??"N/A",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
maxLines: 2,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
@@ -401,10 +408,10 @@ class BuyPassContent extends StatelessWidget {
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
// Navigator.of(context).pushNamed(
|
||||
// RouteConstants.attractionDetails,
|
||||
// arguments: attraction,
|
||||
// );
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionDetails,
|
||||
arguments: attraction.id,
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
@@ -426,7 +433,7 @@ class BuyPassContent extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: 20.w,
|
||||
height: 20.w,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(color: Color(0xffF95F62),strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
class PassCardView extends StatelessWidget {
|
||||
final Color? themeColor;
|
||||
final String? city;
|
||||
final String? heroImage; // ✅ heroBanner.image from API
|
||||
final String? heroImage;
|
||||
final num? adultPrice;
|
||||
final num? childPrice;
|
||||
final String? cardType;
|
||||
@@ -31,140 +31,143 @@ class PassCardView extends StatelessWidget {
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: (themeColor ?? const Color(0xFFF95FAF)).withOpacity(0.24),
|
||||
width: isSelected ? 2 : 1,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
/// -------- HERO BANNER IMAGE --------
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r),
|
||||
),
|
||||
child: Container(
|
||||
width: 103.w,
|
||||
height: 140.h,
|
||||
color: Colors.grey[200],
|
||||
child: heroImage != null && heroImage!.isNotEmpty
|
||||
? Image.network(
|
||||
heroImage!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _fallbackIcon();
|
||||
},
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
/// -------- LEFT: IMAGE + DETAILS --------
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
/// HERO BANNER IMAGE
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r),
|
||||
),
|
||||
child: Container(
|
||||
width: 103.w,
|
||||
height: 140.h,
|
||||
color: Colors.grey[200],
|
||||
child: heroImage != null && heroImage!.isNotEmpty
|
||||
? Image.network(
|
||||
heroImage!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _fallbackIcon();
|
||||
},
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
child: const CircularProgressIndicator(
|
||||
color: Color(0xffF95F62),
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: _fallbackIcon(),
|
||||
);
|
||||
},
|
||||
)
|
||||
: _fallbackIcon(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 6.66.w),
|
||||
SizedBox(width: 6.66.w),
|
||||
|
||||
/// -------- CARD DETAILS --------
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(
|
||||
text: city ?? "City",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
|
||||
/// Adult Price
|
||||
Row(
|
||||
/// CARD DETAILS
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"From ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
CustomText(
|
||||
text: city ?? "City",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
Text(
|
||||
"\$${adultPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
|
||||
/// Adult Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"From ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${adultPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color:Color(0xFFF95F62),
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /Adult",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
" /Adult",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
|
||||
/// Child Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"and ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${childPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color:Color(0xFFF95F62),
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /child",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// Description
|
||||
CustomText(
|
||||
text: description ??
|
||||
"Dive into an extensive selection of thrilling destinations!",
|
||||
color: const Color(0xFF000000).withOpacity(0.6),
|
||||
size: 11.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// Child Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"and ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${childPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /child",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// Description
|
||||
SizedBox(
|
||||
width: 193.w,
|
||||
child: CustomText(
|
||||
text: description ??
|
||||
"Dive into an extensive selection of thrilling destinations!",
|
||||
color: const Color(0xFF000000).withOpacity(0.6),
|
||||
size: 11.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
/// -------- CARD TYPE LABEL --------
|
||||
/// -------- RIGHT: CARD TYPE LABEL --------
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 140.h,
|
||||
@@ -194,7 +197,7 @@ class PassCardView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// -------- FALLBACK ICON --------
|
||||
/// FALLBACK ICON
|
||||
Widget _fallbackIcon() {
|
||||
return Icon(
|
||||
Icons.card_travel,
|
||||
@@ -202,4 +205,4 @@ class PassCardView extends StatelessWidget {
|
||||
color: Colors.grey[400],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../bloc/buy_pass_bloc.dart';
|
||||
import '../bloc/buy_pass_event.dart';
|
||||
import '../bloc/buy_pass_state.dart';
|
||||
import '../models/checkout_model.dart';
|
||||
import '../../checkout/view/checkout_view.dart';
|
||||
import '../repository/buy_pass_repository.dart'; // ✅ Import repository
|
||||
|
||||
class PaymentCard extends StatelessWidget {
|
||||
class PaymentCard extends StatefulWidget {
|
||||
final String city;
|
||||
final String heroImage;
|
||||
final String cardType;
|
||||
@@ -56,10 +59,16 @@ class PaymentCard extends StatelessWidget {
|
||||
required this.cardXid, // ✅ NEW
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentCard> createState() => _PaymentCardState();
|
||||
}
|
||||
|
||||
class _PaymentCardState extends State<PaymentCard> {
|
||||
bool _isLoading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isUnlimitedCard = cardType == "unlimited_card";
|
||||
final bool isSelectivePass = cardType == "selective_pass";
|
||||
final bool isUnlimitedCard = widget.cardType == "unlimited_card";
|
||||
final bool isSelectivePass = widget.cardType == "selective_pass";
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
@@ -83,7 +92,7 @@ class PaymentCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(
|
||||
text: city,
|
||||
text: widget.city,
|
||||
size: 20.sp,
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
@@ -91,32 +100,32 @@ class PaymentCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95FAF),
|
||||
color: widget.themeColor.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: CustomText(
|
||||
text: "$cardDisplayName Card",
|
||||
text: widget.cardDisplayName,
|
||||
size: 12.sp,
|
||||
color: Colors.white,
|
||||
color: widget.themeColor,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_buildCounterRow("No. of Adults", adults, onAdultChanged),
|
||||
_buildCounterRow("No. of Adults", widget.adults, widget.onAdultChanged, context, minValue: 1),
|
||||
SizedBox(height: 10.h),
|
||||
_buildCounterRow("No. of Children", children, onChildChanged),
|
||||
_buildCounterRow("No. of Children", widget.children, widget.onChildChanged, context),
|
||||
SizedBox(height: 10.h),
|
||||
if (isUnlimitedCard)
|
||||
_buildDropdownRow(
|
||||
label: "No. of Days",
|
||||
value: selectedValue,
|
||||
onChanged: onValidityChanged,
|
||||
value: widget.selectedValue,
|
||||
onChanged: widget.onValidityChanged,
|
||||
)
|
||||
else if (isSelectivePass)
|
||||
_buildDropdownRow(
|
||||
label: "No. of Attractions",
|
||||
value: selectedValue,
|
||||
onChanged: onValidityChanged,
|
||||
value: widget.selectedValue,
|
||||
onChanged: widget.onValidityChanged,
|
||||
),
|
||||
Divider(height: 30.h, thickness: 1),
|
||||
Row(
|
||||
@@ -128,7 +137,7 @@ class PaymentCard extends StatelessWidget {
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
CustomText(
|
||||
text: "\$${totalPrice.toStringAsFixed(0)}",
|
||||
text: "\$${widget.totalPrice.toStringAsFixed(0)}",
|
||||
size: 18.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
weight: FontWeight.bold,
|
||||
@@ -136,100 +145,111 @@ class PaymentCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
CustomFilledButton(
|
||||
onTap: () async {
|
||||
try {
|
||||
// ✅ Check login status first
|
||||
final bool isLoggedIn = await LocalPreference.getLogin();
|
||||
BlocBuilder<BuyPassBloc, BuyPassState>(
|
||||
builder: (context, state) {
|
||||
final isLoading = state is BuyPassLoaded && state.isAddingToCart;
|
||||
|
||||
// ✅ Create checkout data (needed for both cases)
|
||||
final checkoutData = CheckoutData(
|
||||
cityName: city,
|
||||
heroImage: heroImage,
|
||||
cardTypeName: cardType,
|
||||
cardDisplayName: cardDisplayName,
|
||||
themeColor: themeColor,
|
||||
adultCount: adults,
|
||||
childCount: children,
|
||||
adultPrice: adultPrice,
|
||||
childPrice: childPrice,
|
||||
validityDuration: selectedValue,
|
||||
totalPrice: totalPrice,
|
||||
description: description,
|
||||
);
|
||||
return CustomFilledButton(
|
||||
onTap: isLoading
|
||||
? null
|
||||
: () async {
|
||||
final bloc = context.read<BuyPassBloc>();
|
||||
bloc.add(AddToCartLoading());
|
||||
try {
|
||||
// ✅ Check login status first
|
||||
final bool isLoggedIn = await LocalPreference.getLogin();
|
||||
|
||||
// ✅ Save to local preference (for both logged in and guest users)
|
||||
await LocalPreference.setPassCart(
|
||||
cityName: city,
|
||||
heroImage: heroImage,
|
||||
cardTypeName: cardType,
|
||||
cardDisplayName: cardDisplayName,
|
||||
themeColor: themeColor.value,
|
||||
adultCount: adults,
|
||||
childCount: children,
|
||||
adultPrice: adultPrice,
|
||||
childPrice: childPrice,
|
||||
validityDuration: selectedValue,
|
||||
totalPrice: totalPrice,
|
||||
description: description,
|
||||
);
|
||||
|
||||
if (isLoggedIn) {
|
||||
// ✅ User is logged in - hit API
|
||||
final repository = BuyPassRepository();
|
||||
final response = await repository.addToCartPasses(
|
||||
cityXid: cityXid,
|
||||
cardTypeXid: cardTypeXid,
|
||||
cardXid: cardXid,
|
||||
cardMode: isSelectivePass ? 'flexi' : 'fixed',
|
||||
totalAdult: adults,
|
||||
totalChild: children,
|
||||
noOfAttractions: isSelectivePass ? selectedValue : 0,
|
||||
noOfDays: isUnlimitedCard ? selectedValue : 0,
|
||||
);
|
||||
|
||||
// ✅ Extract bookingId from response
|
||||
final int bookingId = response['id'];
|
||||
|
||||
// ✅ Navigate to checkout with bookingId
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CheckoutView(bookingId: bookingId),
|
||||
settings: RouteSettings(
|
||||
arguments: checkoutData,
|
||||
),
|
||||
),
|
||||
// ✅ Create checkout data (needed for both cases)
|
||||
final checkoutData = CheckoutData(
|
||||
cityName: widget.city,
|
||||
heroImage: widget.heroImage,
|
||||
cardTypeName: widget.cardType,
|
||||
cardDisplayName: widget.cardDisplayName,
|
||||
themeColor: widget.themeColor,
|
||||
adultCount: widget.adults,
|
||||
childCount: widget.children,
|
||||
adultPrice: widget.adultPrice,
|
||||
childPrice: widget.childPrice,
|
||||
validityDuration: widget.selectedValue,
|
||||
totalPrice: widget.totalPrice,
|
||||
description: widget.description,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// ✅ User is NOT logged in - skip API, navigate directly
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CheckoutView(bookingId: 0), // or 0, depending on your CheckoutView implementation
|
||||
settings: RouteSettings(
|
||||
arguments: checkoutData,
|
||||
|
||||
if (isLoggedIn) {
|
||||
// ✅ User is logged in - hit API
|
||||
final repository = BuyPassRepository();
|
||||
final response = await repository.addToCartPasses(
|
||||
cityXid: widget.cityXid,
|
||||
cardTypeXid: widget.cardTypeXid,
|
||||
cardXid: widget.cardXid,
|
||||
cardMode: isSelectivePass ? 'flexi' : 'unlimited',
|
||||
totalAdult: widget.adults,
|
||||
totalChild: widget.children,
|
||||
noOfAttractions: isSelectivePass ? widget.selectedValue : 0,
|
||||
noOfDays: isUnlimitedCard ? widget.selectedValue : 0,
|
||||
baseAmount: widget.totalPrice,
|
||||
);
|
||||
|
||||
// ✅ Extract bookingId from response
|
||||
final int bookingId = response['id'];
|
||||
|
||||
// ✅ Navigate to checkout with bookingId
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CheckoutView(bookingId: bookingId),
|
||||
settings: RouteSettings(
|
||||
arguments: checkoutData,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// ✅ User is NOT logged in - skip API, navigate directly
|
||||
await LocalPreference.setPassCart(
|
||||
cityName: widget.city,
|
||||
heroImage: widget.heroImage,
|
||||
cardTypeName: widget.cardType,
|
||||
cardDisplayName: widget.cardDisplayName,
|
||||
themeColor: widget.themeColor.value,
|
||||
adultCount: widget.adults,
|
||||
childCount: widget.children,
|
||||
adultPrice: widget.adultPrice,
|
||||
childPrice: widget.childPrice,
|
||||
validityDuration: widget.selectedValue,
|
||||
totalPrice: widget.totalPrice,
|
||||
description: widget.description,
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CheckoutView(bookingId: 0),
|
||||
settings: RouteSettings(
|
||||
arguments: checkoutData,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ✅ Show error message
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to proceed: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
bloc.add(AddToCartDone()); // ✅ stop loading
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ✅ Show error message
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to proceed: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
label: isLoading ? "Please wait..." : "Proceed to Pay",
|
||||
);
|
||||
},
|
||||
label: "Proceed to Pay",
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -243,8 +263,8 @@ class PaymentCard extends StatelessWidget {
|
||||
required Function(int) onChanged,
|
||||
}) {
|
||||
List<int> numbersList = List.generate(
|
||||
maxNumber - minNumber + 1,
|
||||
(index) => minNumber + index,
|
||||
widget.maxNumber - widget.minNumber + 1,
|
||||
(index) => widget.minNumber + index,
|
||||
);
|
||||
|
||||
return Row(
|
||||
@@ -304,7 +324,9 @@ class PaymentCard extends StatelessWidget {
|
||||
String label,
|
||||
int value,
|
||||
Function(int) onChanged,
|
||||
) {
|
||||
BuildContext context, {
|
||||
int minValue = 0,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -312,7 +334,22 @@ class PaymentCard extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
_circleButton(Icons.remove, () {
|
||||
if (value > 0) onChanged(value - 1);
|
||||
if (value > minValue) {
|
||||
onChanged(value - 1);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
minValue == 1
|
||||
? "At least 1 adult is required"
|
||||
: "Cannot go below 0",
|
||||
),
|
||||
backgroundColor: const Color(0xFFF95F62),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||
|
||||
@@ -8,18 +8,122 @@ class MyPassCartBloc extends Bloc<MyPassCartEvent, MyPassCartState> {
|
||||
final MyPassCartRepository repository;
|
||||
|
||||
MyPassCartBloc({required this.repository}) : super(const MyPassCartInitial()) {
|
||||
on<CheckLoginAndFetchEvent>(_onCheckLoginAndFetch);
|
||||
on<FetchPassCartEvent>(_onFetchPassCart);
|
||||
on<ClearPassCartEvent>(_onClearPassCart);
|
||||
}
|
||||
|
||||
/// Handle fetching pass cart data
|
||||
/// Handle checking login status and fetching cart data accordingly
|
||||
Future<void> _onCheckLoginAndFetch(
|
||||
CheckLoginAndFetchEvent event,
|
||||
Emitter<MyPassCartState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔍 [BLOC] Checking login status and fetching cart...');
|
||||
}
|
||||
|
||||
emit(const MyPassCartLoading());
|
||||
|
||||
// Check if user is logged in
|
||||
final isLoggedIn = await repository.isUserLoggedIn();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('🔐 [BLOC] User logged in: $isLoggedIn');
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
// User is logged in - fetch from API
|
||||
if (kDebugMode) {
|
||||
print('🌐 [BLOC] Fetching cart data from API...');
|
||||
}
|
||||
|
||||
try {
|
||||
final apiCartData = await repository.fetchMyPassesCart();
|
||||
|
||||
// Check if API data is empty
|
||||
if (apiCartData.cartItems.isEmpty) {
|
||||
if (kDebugMode) {
|
||||
print('⚠️ [BLOC] API returned empty cart, checking local data...');
|
||||
}
|
||||
|
||||
// Try to fetch from local if API is empty
|
||||
final localCartData = await repository.fetchPassesCartByLocal();
|
||||
|
||||
if (localCartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] Using local cart data as fallback');
|
||||
}
|
||||
emit(MyPassCartLoaded(cartData: localCartData));
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('ℹ️ [BLOC] No local data available, cart is empty');
|
||||
}
|
||||
emit(const MyPassCartEmpty());
|
||||
}
|
||||
} else {
|
||||
// API has cart items
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] API cart data loaded successfully with ${apiCartData.cartItems.length} items');
|
||||
}
|
||||
emit(MyPassCartApiLoaded(apiCartData: apiCartData));
|
||||
}
|
||||
} catch (apiError) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [BLOC] API error: $apiError, trying local data...');
|
||||
}
|
||||
|
||||
// API failed, try local data as fallback
|
||||
final localCartData = await repository.fetchPassesCartByLocal();
|
||||
|
||||
if (localCartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] Using local cart data after API failure');
|
||||
}
|
||||
emit(MyPassCartLoaded(cartData: localCartData));
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('❌ [BLOC] No local data available after API failure');
|
||||
}
|
||||
emit(MyPassCartError(message: 'Failed to load cart data: ${apiError.toString()}'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User is not logged in - fetch from local only
|
||||
if (kDebugMode) {
|
||||
print('📱 [BLOC] User not logged in, fetching from local storage...');
|
||||
}
|
||||
|
||||
final localCartData = await repository.fetchPassesCartByLocal();
|
||||
|
||||
if (localCartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] Local cart data loaded successfully');
|
||||
}
|
||||
emit(MyPassCartLoaded(cartData: localCartData));
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('ℹ️ [BLOC] No local cart data available');
|
||||
}
|
||||
emit(const MyPassCartEmpty());
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [BLOC] Error in CheckLoginAndFetch: $e');
|
||||
}
|
||||
emit(MyPassCartError(message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle fetching pass cart data from local storage
|
||||
Future<void> _onFetchPassCart(
|
||||
FetchPassCartEvent event,
|
||||
Emitter<MyPassCartState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔄 [BLOC] Fetching pass cart...');
|
||||
print('📄 [BLOC] Fetching pass cart from local...');
|
||||
}
|
||||
|
||||
emit(const MyPassCartLoading());
|
||||
@@ -52,7 +156,7 @@ class MyPassCartBloc extends Bloc<MyPassCartEvent, MyPassCartState> {
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔄 [BLOC] Clearing pass cart...');
|
||||
print('📄 [BLOC] Clearing pass cart...');
|
||||
}
|
||||
|
||||
// You can add clearPassCart method to repository if needed
|
||||
|
||||
@@ -7,6 +7,14 @@ abstract class MyPassCartEvent extends Equatable {
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Event to check login status and fetch pass cart data accordingly
|
||||
/// - If logged in: fetch from API
|
||||
/// - If not logged in: fetch from local
|
||||
/// - If API returns empty and local data exists: use local data
|
||||
class CheckLoginAndFetchEvent extends MyPassCartEvent {
|
||||
const CheckLoginAndFetchEvent();
|
||||
}
|
||||
|
||||
/// Event to fetch pass cart data from local database
|
||||
class FetchPassCartEvent extends MyPassCartEvent {
|
||||
const FetchPassCartEvent();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../model/my_passes_cart_model.dart';
|
||||
|
||||
abstract class MyPassCartState extends Equatable {
|
||||
const MyPassCartState();
|
||||
|
||||
@@ -17,7 +19,7 @@ class MyPassCartLoading extends MyPassCartState {
|
||||
const MyPassCartLoading();
|
||||
}
|
||||
|
||||
/// Loaded state with cart data
|
||||
/// Loaded state with cart data from local storage
|
||||
class MyPassCartLoaded extends MyPassCartState {
|
||||
final Map<String, dynamic> cartData;
|
||||
|
||||
@@ -27,6 +29,16 @@ class MyPassCartLoaded extends MyPassCartState {
|
||||
List<Object?> get props => [cartData];
|
||||
}
|
||||
|
||||
/// Loaded state with cart data from API
|
||||
class MyPassCartApiLoaded extends MyPassCartState {
|
||||
final MyPassesCartModel apiCartData;
|
||||
|
||||
const MyPassCartApiLoaded({required this.apiCartData});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [apiCartData];
|
||||
}
|
||||
|
||||
/// Empty state when no cart data exists
|
||||
class MyPassCartEmpty extends MyPassCartState {
|
||||
const MyPassCartEmpty();
|
||||
|
||||
57
lib/cart/blocs/myPostcardsCart/my_postcards_cart_bloc.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../../localPreference/local_preference.dart';
|
||||
import '../../repository/my_postcards_cart_repository.dart';
|
||||
import 'my_postcards_cart_state.dart';
|
||||
part 'my_postcards_cart_event.dart';
|
||||
|
||||
class MyPostCardsCartBloc
|
||||
extends Bloc<MyPostCardsCartEvent, MyPostCardsCartState> {
|
||||
final MyPostCardCartRepository _repository;
|
||||
|
||||
MyPostCardsCartBloc({MyPostCardCartRepository? repository})
|
||||
: _repository = repository ?? MyPostCardCartRepository(),
|
||||
super(MyPostCardsCartInitial()) {
|
||||
on<CheckLoginAndFetchPostcardsCart>(_onCheckLoginAndFetch);
|
||||
}
|
||||
|
||||
Future<void> _onCheckLoginAndFetch(
|
||||
CheckLoginAndFetchPostcardsCart event,
|
||||
Emitter<MyPostCardsCartState> emit,
|
||||
) async {
|
||||
emit(MyPostCardsCartLoading());
|
||||
|
||||
try {
|
||||
// 1. Check login status
|
||||
final isLoggedIn = await LocalPreference.getLogin();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('🔐 [CART-BLOC] isLoggedIn: $isLoggedIn');
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// User not logged in → show not-logged-in screen
|
||||
emit(MyPostCardsCartNotLoggedIn());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fetch cart from API
|
||||
final cartData = await _repository.fetchMyPostCardsCart();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('🛒 [CART-BLOC] Cart items: ${cartData.totalItems}');
|
||||
}
|
||||
|
||||
if (cartData.cartItems.isEmpty) {
|
||||
emit(MyPostCardsCartEmpty());
|
||||
} else {
|
||||
emit(MyPostCardsCartLoaded(cartData: cartData));
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [CART-BLOC] Error: $e');
|
||||
}
|
||||
emit(MyPostCardsCartError(message: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
part of 'my_postcards_cart_bloc.dart';
|
||||
|
||||
abstract class MyPostCardsCartEvent {}
|
||||
|
||||
/// Checks login status then fetches cart if logged in
|
||||
class CheckLoginAndFetchPostcardsCart extends MyPostCardsCartEvent {}
|
||||
27
lib/cart/blocs/myPostcardsCart/my_postcards_cart_state.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import '../../model/my_postcards_cart_model.dart';
|
||||
|
||||
abstract class MyPostCardsCartState {}
|
||||
|
||||
/// Initial / idle state
|
||||
class MyPostCardsCartInitial extends MyPostCardsCartState {}
|
||||
|
||||
/// Checking login or fetching data
|
||||
class MyPostCardsCartLoading extends MyPostCardsCartState {}
|
||||
|
||||
/// User is NOT logged in
|
||||
class MyPostCardsCartNotLoggedIn extends MyPostCardsCartState {}
|
||||
|
||||
/// Logged in but cart is empty
|
||||
class MyPostCardsCartEmpty extends MyPostCardsCartState {}
|
||||
|
||||
/// Logged in and data loaded
|
||||
class MyPostCardsCartLoaded extends MyPostCardsCartState {
|
||||
final MyPostCardsCartModel cartData;
|
||||
MyPostCardsCartLoaded({required this.cartData});
|
||||
}
|
||||
|
||||
/// Error state
|
||||
class MyPostCardsCartError extends MyPostCardsCartState {
|
||||
final String message;
|
||||
MyPostCardsCartError({required this.message});
|
||||
}
|
||||
@@ -1,40 +1,40 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../model/pass_model.dart';
|
||||
|
||||
abstract class PassEvent {}
|
||||
class LoadPasses extends PassEvent {}
|
||||
|
||||
abstract class PassState {}
|
||||
class PassLoading extends PassState {}
|
||||
class PassLoaded extends PassState {
|
||||
final List<PassModel> passes;
|
||||
final double subtotal;
|
||||
final double discountPercent;
|
||||
final double total;
|
||||
|
||||
PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
|
||||
}
|
||||
|
||||
class PassBloc extends Bloc<PassEvent, PassState> {
|
||||
PassBloc() : super(PassLoading()) {
|
||||
on<LoadPasses>((event, emit) {
|
||||
final passes = [
|
||||
PassModel(
|
||||
title: "Melbourne",
|
||||
imageUrl: "assets/images/city_melbourne.png",
|
||||
duration: "2 days",
|
||||
adults: 3,
|
||||
kids: 3,
|
||||
quantity: 2,
|
||||
price: 49.50,
|
||||
discount: 7.2,
|
||||
),
|
||||
];
|
||||
|
||||
final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
|
||||
final discountPercent = passes.first.discount;
|
||||
final total = subtotal - (subtotal * discountPercent / 100);
|
||||
emit(PassLoaded(passes, subtotal, discountPercent, total));
|
||||
});
|
||||
}
|
||||
}
|
||||
// import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
// import '../model/pass_model.dart';
|
||||
//
|
||||
// abstract class PassEvent {}
|
||||
// class LoadPasses extends PassEvent {}
|
||||
//
|
||||
// abstract class PassState {}
|
||||
// class PassLoading extends PassState {}
|
||||
// class PassLoaded extends PassState {
|
||||
// final List<PassModel> passes;
|
||||
// final double subtotal;
|
||||
// final double discountPercent;
|
||||
// final double total;
|
||||
//
|
||||
// PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
|
||||
// }
|
||||
//
|
||||
// class PassBloc extends Bloc<PassEvent, PassState> {
|
||||
// PassBloc() : super(PassLoading()) {
|
||||
// on<LoadPasses>((event, emit) {
|
||||
// final passes = [
|
||||
// PassModel(
|
||||
// title: "Melbourne",
|
||||
// imageUrl: "assets/images/city_melbourne.png",
|
||||
// duration: "2 days",
|
||||
// adults: 3,
|
||||
// kids: 3,
|
||||
// quantity: 2,
|
||||
// price: 49.50,
|
||||
// discount: 7.2,
|
||||
// ),
|
||||
// ];
|
||||
//
|
||||
// final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
|
||||
// final discountPercent = passes.first.discount;
|
||||
// final total = subtotal - (subtotal * discountPercent / 100);
|
||||
// emit(PassLoaded(passes, subtotal, discountPercent, total));
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
277
lib/cart/model/my_passes_cart_model.dart
Normal file
@@ -0,0 +1,277 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// ---------- MAIN RESPONSE ----------
|
||||
MyPassesCartModel myPassesCartModelFromJson(String str) =>
|
||||
MyPassesCartModel.fromJson(json.decode(str));
|
||||
|
||||
String myPassesCartModelToJson(MyPassesCartModel data) =>
|
||||
json.encode(data.toJson());
|
||||
|
||||
class MyPassesCartModel {
|
||||
CartCity city;
|
||||
List<CartItem> cartItems;
|
||||
|
||||
MyPassesCartModel({
|
||||
required this.city,
|
||||
required this.cartItems,
|
||||
});
|
||||
|
||||
factory MyPassesCartModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return MyPassesCartModel(
|
||||
city: CartCity.fromJson(json['city']),
|
||||
cartItems: json['cartItems'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['cartItems'])
|
||||
.map((e) => CartItem.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"city": city.toJson(),
|
||||
"cartItems": cartItems.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- TOP LEVEL CITY ----------
|
||||
class CartCity {
|
||||
int id;
|
||||
String name;
|
||||
String bannerImage;
|
||||
|
||||
CartCity({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.bannerImage,
|
||||
});
|
||||
|
||||
factory CartCity.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return CartCity(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? "",
|
||||
bannerImage: json['bannerImage']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"bannerImage": bannerImage,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CART ITEM ----------
|
||||
class CartItem {
|
||||
int id;
|
||||
String bookingNumber;
|
||||
String cardMode;
|
||||
String displayCardMode;
|
||||
int noOfDays;
|
||||
int noOfAttractions;
|
||||
int totalAdult;
|
||||
int totalChild;
|
||||
num baseAmount;
|
||||
num totalTaxAmount;
|
||||
num totalAmount;
|
||||
String bookingStatus;
|
||||
bool isForSelf;
|
||||
|
||||
String recipientFirstName;
|
||||
String recipientLastName;
|
||||
String recipientEmail;
|
||||
String recipientPhone;
|
||||
String recipientCity;
|
||||
String recipientCountry;
|
||||
String giftMessage;
|
||||
|
||||
bool isPaymentRequired;
|
||||
int couponXid;
|
||||
num couponDiscountAmount;
|
||||
num couponDiscountPercent;
|
||||
String paymentStatus;
|
||||
String createdAt;
|
||||
|
||||
Coupon? coupon;
|
||||
ItemCity city;
|
||||
|
||||
CartItem({
|
||||
required this.id,
|
||||
required this.bookingNumber,
|
||||
required this.cardMode,
|
||||
required this.displayCardMode,
|
||||
required this.noOfDays,
|
||||
required this.noOfAttractions,
|
||||
required this.totalAdult,
|
||||
required this.totalChild,
|
||||
required this.baseAmount,
|
||||
required this.totalTaxAmount,
|
||||
required this.totalAmount,
|
||||
required this.bookingStatus,
|
||||
required this.isForSelf,
|
||||
required this.recipientFirstName,
|
||||
required this.recipientLastName,
|
||||
required this.recipientEmail,
|
||||
required this.recipientPhone,
|
||||
required this.recipientCity,
|
||||
required this.recipientCountry,
|
||||
required this.giftMessage,
|
||||
required this.isPaymentRequired,
|
||||
required this.couponXid,
|
||||
required this.couponDiscountAmount,
|
||||
required this.couponDiscountPercent,
|
||||
required this.paymentStatus,
|
||||
required this.createdAt,
|
||||
required this.coupon,
|
||||
required this.city,
|
||||
});
|
||||
|
||||
factory CartItem.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return CartItem(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
bookingNumber: json['bookingNumber']?.toString() ?? "",
|
||||
cardMode: json['cardMode']?.toString() ?? "",
|
||||
displayCardMode: json['displayCardMode']?.toString() ?? "",
|
||||
noOfDays: (json['noOfDays'] as num?)?.toInt() ?? 0,
|
||||
noOfAttractions: (json['noOfAttractions'] as num?)?.toInt() ?? 0,
|
||||
totalAdult: (json['totalAdult'] as num?)?.toInt() ?? 0,
|
||||
totalChild: (json['totalChild'] as num?)?.toInt() ?? 0,
|
||||
baseAmount: json['baseAmount'] ?? 0,
|
||||
totalTaxAmount: json['totalTaxAmount'] ?? 0,
|
||||
totalAmount: json['totalAmount'] ?? 0,
|
||||
bookingStatus: json['bookingStatus']?.toString() ?? "",
|
||||
isForSelf: json['isForSelf'] ?? false,
|
||||
recipientFirstName: json['recipientFirstName']?.toString() ?? "",
|
||||
recipientLastName: json['recipientLastName']?.toString() ?? "",
|
||||
recipientEmail: json['recipientEmail']?.toString() ?? "",
|
||||
recipientPhone: json['recipientPhone']?.toString() ?? "",
|
||||
recipientCity: json['recipientCity']?.toString() ?? "",
|
||||
recipientCountry: json['recipientCountry']?.toString() ?? "",
|
||||
giftMessage: json['giftMessage']?.toString() ?? "",
|
||||
isPaymentRequired: json['isPaymentRequired'] ?? false,
|
||||
couponXid: (json['couponXid'] as num?)?.toInt() ?? 0,
|
||||
couponDiscountAmount: json['couponDiscountAmount'] ?? 0,
|
||||
couponDiscountPercent: json['couponDiscountPercent'] ?? 0,
|
||||
paymentStatus: json['paymentStatus']?.toString() ?? "",
|
||||
createdAt: json['createdAt']?.toString() ?? "",
|
||||
coupon:
|
||||
json['coupon'] == null ? null : Coupon.fromJson(json['coupon']),
|
||||
city: ItemCity.fromJson(json['city']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"bookingNumber": bookingNumber,
|
||||
"cardMode": cardMode,
|
||||
"displayCardMode": displayCardMode,
|
||||
"noOfDays": noOfDays,
|
||||
"noOfAttractions": noOfAttractions,
|
||||
"totalAdult": totalAdult,
|
||||
"totalChild": totalChild,
|
||||
"baseAmount": baseAmount,
|
||||
"totalTaxAmount": totalTaxAmount,
|
||||
"totalAmount": totalAmount,
|
||||
"bookingStatus": bookingStatus,
|
||||
"isForSelf": isForSelf,
|
||||
"recipientFirstName": recipientFirstName,
|
||||
"recipientLastName": recipientLastName,
|
||||
"recipientEmail": recipientEmail,
|
||||
"recipientPhone": recipientPhone,
|
||||
"recipientCity": recipientCity,
|
||||
"recipientCountry": recipientCountry,
|
||||
"giftMessage": giftMessage,
|
||||
"isPaymentRequired": isPaymentRequired,
|
||||
"couponXid": couponXid,
|
||||
"couponDiscountAmount": couponDiscountAmount,
|
||||
"couponDiscountPercent": couponDiscountPercent,
|
||||
"paymentStatus": paymentStatus,
|
||||
"createdAt": createdAt,
|
||||
"coupon": coupon?.toJson(),
|
||||
"city": city.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- COUPON ----------
|
||||
class Coupon {
|
||||
int id;
|
||||
String couponCode;
|
||||
String title;
|
||||
|
||||
Coupon({
|
||||
required this.id,
|
||||
required this.couponCode,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
factory Coupon.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
return Coupon(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
couponCode: json['couponCode']?.toString() ?? "",
|
||||
title: json['title']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"couponCode": couponCode,
|
||||
"title": title,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- ITEM CITY ----------
|
||||
class ItemCity {
|
||||
int id;
|
||||
String cityName;
|
||||
List<CityBanner> cityBanners;
|
||||
|
||||
ItemCity({
|
||||
required this.id,
|
||||
required this.cityName,
|
||||
required this.cityBanners,
|
||||
});
|
||||
|
||||
factory ItemCity.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return ItemCity(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
cityName: json['cityName']?.toString() ?? "",
|
||||
cityBanners: json['cityBanners'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['cityBanners'])
|
||||
.map((e) => CityBanner.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"cityName": cityName,
|
||||
"cityBanners": cityBanners.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CITY BANNER ----------
|
||||
class CityBanner {
|
||||
String imageFilePath;
|
||||
|
||||
CityBanner({required this.imageFilePath});
|
||||
|
||||
factory CityBanner.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
return CityBanner(
|
||||
imageFilePath: json['imageFilePath']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"imageFilePath": imageFilePath,
|
||||
};
|
||||
}
|
||||
163
lib/cart/model/my_postcards_cart_model.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
class MyPostCardsCartModel {
|
||||
final int totalItems;
|
||||
final List<CartItem> cartItems;
|
||||
|
||||
MyPostCardsCartModel({
|
||||
required this.totalItems,
|
||||
required this.cartItems,
|
||||
});
|
||||
|
||||
factory MyPostCardsCartModel.fromJson(Map<String, dynamic> json) {
|
||||
return MyPostCardsCartModel(
|
||||
totalItems: json['totalItems'] ?? 0,
|
||||
cartItems: (json['cartItems'] as List<dynamic>? ?? [])
|
||||
.map((e) => CartItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'totalItems': totalItems,
|
||||
'cartItems': cartItems.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CartItem {
|
||||
final int id;
|
||||
final String pcTitle;
|
||||
final String pcNumber;
|
||||
final String cityName;
|
||||
final DateTime? pcDatetime;
|
||||
final String pcContent;
|
||||
final String pcImagePath;
|
||||
final bool isForSelf;
|
||||
|
||||
final String? senderFullName;
|
||||
final String? senderCityName;
|
||||
final String? senderCountryName;
|
||||
|
||||
final String fullname;
|
||||
final String emailAddress;
|
||||
final String isdCode;
|
||||
final String mobileNumber;
|
||||
final String address1;
|
||||
final String? address2;
|
||||
final String zipCode;
|
||||
final String stateName;
|
||||
final String countryName;
|
||||
|
||||
final num baseAmount;
|
||||
final num totalTaxAmount;
|
||||
final num totalAmount;
|
||||
|
||||
final String paymentStatus;
|
||||
final String orderStatus;
|
||||
|
||||
final bool isDraft;
|
||||
final bool isAddedToCart;
|
||||
|
||||
final DateTime? createdAt;
|
||||
|
||||
CartItem({
|
||||
required this.id,
|
||||
required this.pcTitle,
|
||||
required this.pcNumber,
|
||||
required this.cityName,
|
||||
required this.pcDatetime,
|
||||
required this.pcContent,
|
||||
required this.pcImagePath,
|
||||
required this.isForSelf,
|
||||
required this.senderFullName,
|
||||
required this.senderCityName,
|
||||
required this.senderCountryName,
|
||||
required this.fullname,
|
||||
required this.emailAddress,
|
||||
required this.isdCode,
|
||||
required this.mobileNumber,
|
||||
required this.address1,
|
||||
required this.address2,
|
||||
required this.zipCode,
|
||||
required this.stateName,
|
||||
required this.countryName,
|
||||
required this.baseAmount,
|
||||
required this.totalTaxAmount,
|
||||
required this.totalAmount,
|
||||
required this.paymentStatus,
|
||||
required this.orderStatus,
|
||||
required this.isDraft,
|
||||
required this.isAddedToCart,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory CartItem.fromJson(Map<String, dynamic> json) {
|
||||
return CartItem(
|
||||
id: json['id'] ?? 0,
|
||||
pcTitle: json['pcTitle'] ?? '',
|
||||
pcNumber: json['pcNumber'] ?? '',
|
||||
cityName: json['cityName'] ?? '',
|
||||
pcDatetime: json['pcDatetime'] != null
|
||||
? DateTime.tryParse(json['pcDatetime'])
|
||||
: null,
|
||||
pcContent: json['pcContent'] ?? '',
|
||||
pcImagePath: json['pcImagePath'] ?? '',
|
||||
isForSelf: json['isForSelf'] ?? false,
|
||||
senderFullName: json['senderFullName'],
|
||||
senderCityName: json['senderCityName'],
|
||||
senderCountryName: json['senderCountryName'],
|
||||
fullname: json['fullname'] ?? '',
|
||||
emailAddress: json['emailAddress'] ?? '',
|
||||
isdCode: json['isdCode'] ?? '',
|
||||
mobileNumber: json['mobileNumber'] ?? '',
|
||||
address1: json['address1'] ?? '',
|
||||
address2: json['address2'],
|
||||
zipCode: json['zipCode'] ?? '',
|
||||
stateName: json['stateName'] ?? '',
|
||||
countryName: json['countryName'] ?? '',
|
||||
baseAmount: json['baseAmount'] ?? 0,
|
||||
totalTaxAmount: json['totalTaxAmount'] ?? 0,
|
||||
totalAmount: json['totalAmount'] ?? 0,
|
||||
paymentStatus: json['paymentStatus'] ?? '',
|
||||
orderStatus: json['orderStatus'] ?? '',
|
||||
isDraft: json['isDraft'] ?? false,
|
||||
isAddedToCart: json['isAddedToCart'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.tryParse(json['createdAt'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'pcTitle': pcTitle,
|
||||
'pcNumber': pcNumber,
|
||||
'cityName': cityName,
|
||||
'pcDatetime': pcDatetime?.toIso8601String(),
|
||||
'pcContent': pcContent,
|
||||
'pcImagePath': pcImagePath,
|
||||
'isForSelf': isForSelf,
|
||||
'senderFullName': senderFullName,
|
||||
'senderCityName': senderCityName,
|
||||
'senderCountryName': senderCountryName,
|
||||
'fullname': fullname,
|
||||
'emailAddress': emailAddress,
|
||||
'isdCode': isdCode,
|
||||
'mobileNumber': mobileNumber,
|
||||
'address1': address1,
|
||||
'address2': address2,
|
||||
'zipCode': zipCode,
|
||||
'stateName': stateName,
|
||||
'countryName': countryName,
|
||||
'baseAmount': baseAmount,
|
||||
'totalTaxAmount': totalTaxAmount,
|
||||
'totalAmount': totalAmount,
|
||||
'paymentStatus': paymentStatus,
|
||||
'orderStatus': orderStatus,
|
||||
'isDraft': isDraft,
|
||||
'isAddedToCart': isAddedToCart,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
class PassModel {
|
||||
final String title;
|
||||
final String imageUrl;
|
||||
final String duration;
|
||||
final int adults;
|
||||
final int kids;
|
||||
final int quantity;
|
||||
final double price;
|
||||
final double discount;
|
||||
|
||||
PassModel({
|
||||
required this.title,
|
||||
required this.imageUrl,
|
||||
required this.duration,
|
||||
required this.adults,
|
||||
required this.kids,
|
||||
required this.quantity,
|
||||
required this.price,
|
||||
required this.discount,
|
||||
});
|
||||
}
|
||||
// class PassModel {
|
||||
// final String title;
|
||||
// final String imageUrl;
|
||||
// final String duration;
|
||||
// final int adults;
|
||||
// final int kids;
|
||||
// final int quantity;
|
||||
// final double price;
|
||||
// final double discount;
|
||||
//
|
||||
// PassModel({
|
||||
// required this.title,
|
||||
// required this.imageUrl,
|
||||
// required this.duration,
|
||||
// required this.adults,
|
||||
// required this.kids,
|
||||
// required this.quantity,
|
||||
// required this.price,
|
||||
// required this.discount,
|
||||
// });
|
||||
// }
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../model/my_passes_cart_model.dart';
|
||||
|
||||
class MyPassCartRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Check if user is logged in
|
||||
Future<bool> isUserLoggedIn() async {
|
||||
try {
|
||||
final isLogin = await LocalPreference.getLogin();
|
||||
if (kDebugMode) {
|
||||
print('🔐 [REPO] User login status: $isLogin');
|
||||
}
|
||||
return isLogin;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [REPO] Error checking login status: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch pass cart data from local database
|
||||
Future<Map<String, dynamic>?> fetchPassesCartByLocal() async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔄 [REPO] Fetching pass cart from local database...');
|
||||
print('📄 [REPO] Fetching pass cart from local database...');
|
||||
}
|
||||
|
||||
final passCartData = await LocalPreference.getPassCart();
|
||||
|
||||
|
||||
if (passCartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [REPO] Pass cart retrieved successfully');
|
||||
@@ -32,4 +53,31 @@ class MyPassCartRepository {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch pass cart data from API
|
||||
Future<MyPassesCartModel> fetchMyPassesCart() async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🌐 [REPO] Fetching pass cart from API...');
|
||||
}
|
||||
|
||||
final cityID = await LocalPreference.getSelectedCityId();
|
||||
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.myPassesCart}?cityXid=$cityID',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ [REPO] API response received');
|
||||
}
|
||||
|
||||
return MyPassesCartModel.fromJson(response.data);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [REPO] Error fetching pass cart from API: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
35
lib/cart/repository/my_postcards_cart_repository.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../model/my_postcards_cart_model.dart';
|
||||
|
||||
class MyPostCardCartRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Fetch postcards cart data from API
|
||||
Future<MyPostCardsCartModel> fetchMyPostCardsCart() async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🌐 [POSTCARD-REPO] Fetching postcards cart from API...');
|
||||
}
|
||||
|
||||
final cityID = await LocalPreference.getSelectedCityId();
|
||||
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.myPostCardsCart}?cityXid=$cityID',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ [POSTCARD-REPO] Postcards cart API response received');
|
||||
}
|
||||
|
||||
return MyPostCardsCartModel.fromJson(response.data);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [POSTCARD-REPO] Error fetching postcards cart from API: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,9 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../../common_packages/back_widget.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_bloc.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_event.dart';
|
||||
import '../blocs/postcard_bloc.dart';
|
||||
import '../repository/my_pass_cart_repository.dart';
|
||||
import '../blocs/myPostcardsCart/my_postcards_cart_bloc.dart';
|
||||
import 'my_pass_cart_page_view.dart';
|
||||
import 'my_postcard_page_view.dart';
|
||||
import 'my_postcard_cart_page_view.dart';
|
||||
|
||||
class MyCartPage extends StatefulWidget {
|
||||
const MyCartPage({super.key});
|
||||
@@ -20,62 +19,61 @@ class MyCartPage extends StatefulWidget {
|
||||
class _MyCartPageState extends State<MyCartPage> {
|
||||
int selectedTab = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<MyPassCartBloc>().add(const CheckLoginAndFetchEvent());
|
||||
context.read<MyPostCardsCartBloc>().add(CheckLoginAndFetchPostcardsCart());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (_) => PostCardBloc()..add(LoadPostCards()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => MyPassCartBloc(
|
||||
repository: MyPassCartRepository(),
|
||||
)..add(const FetchPassCartEvent()),
|
||||
),
|
||||
],
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showCart: false,
|
||||
showDivider: true,
|
||||
),
|
||||
backWidget(context, "Your Cart", Colors.black),
|
||||
SizedBox(height: 24.h),
|
||||
Container(
|
||||
padding: EdgeInsets.all(4.0),
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffFEE7E7),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showCart: false,
|
||||
showDivider: true,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_tabButton("My Passes", 0),
|
||||
_tabButton("My Post Cards", 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: selectedTab == 0
|
||||
? const MyPassesPage()
|
||||
: const MyPostCardsPage(),
|
||||
backWidget(context, "Your Cart", Colors.black),
|
||||
SizedBox(height: 24.h),
|
||||
Container(
|
||||
padding: EdgeInsets.all(4.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffFEE7E7),
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: Row(
|
||||
children: [
|
||||
_tabButton("My Passes", 0),
|
||||
_tabButton("My Post Cards", 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: selectedTab,
|
||||
children: const [
|
||||
MyPassesCartPage(),
|
||||
MyPostCardsCartPage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -87,17 +85,27 @@ class _MyCartPageState extends State<MyCartPage> {
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => selectedTab = index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xff2A2A2A),
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
fontSize: 13.sp,
|
||||
color: const Color(0xff2A2A2A),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -105,4 +113,4 @@ class _MyCartPageState extends State<MyCartPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:citycards_customer/cart/views/view_pass_page_view.dart';
|
||||
import 'package:citycards_customer/checkout/widget/all_coupons_bottomsheet.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_dashed_line.dart';
|
||||
@@ -6,6 +7,10 @@ import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../../add_details/add_details_view.dart';
|
||||
import '../../buy_a_pass/models/checkout_model.dart';
|
||||
import '../../checkout/view/checkout_view.dart';
|
||||
import '../../checkout/widget/pass_purchase_details_bottomsheet.dart';
|
||||
import '../../login/view/login_email_bottomsheet.dart';
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import '../../localPreference/local_preference.dart';
|
||||
@@ -13,23 +18,24 @@ import '../blocs/myPassCart/my_pass_cart_bloc.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_event.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_state.dart';
|
||||
|
||||
class MyPassesPage extends StatefulWidget {
|
||||
const MyPassesPage({super.key});
|
||||
class MyPassesCartPage extends StatefulWidget {
|
||||
const MyPassesCartPage({super.key});
|
||||
|
||||
@override
|
||||
State<MyPassesPage> createState() => _MyPassesPageState();
|
||||
State<MyPassesCartPage> createState() => _MyPassesCartPageState();
|
||||
}
|
||||
|
||||
class _MyPassesPageState extends State<MyPassesPage> {
|
||||
class _MyPassesCartPageState extends State<MyPassesCartPage> {
|
||||
// For coupon/discount management
|
||||
String? appliedCouponCode;
|
||||
double discountPercentage = 0.0;
|
||||
bool isPurchaseDetailsConfirmed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Fetch cart data when page loads
|
||||
context.read<MyPassCartBloc>().add(const FetchPassCartEvent());
|
||||
context.read<MyPassCartBloc>().add(const CheckLoginAndFetchEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -37,426 +43,189 @@ class _MyPassesPageState extends State<MyPassesPage> {
|
||||
return BlocBuilder<MyPassCartBloc, MyPassCartState>(
|
||||
builder: (context, state) {
|
||||
if (state is MyPassCartLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is MyPassCartLoaded) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xffF95F62)),
|
||||
);
|
||||
}
|
||||
// ========== HANDLE API DATA (LOGGED IN USER) ==========
|
||||
else if (state is MyPassCartApiLoaded) {
|
||||
final apiCartData = state.apiCartData;
|
||||
|
||||
if (apiCartData.cartItems.isEmpty) {
|
||||
return const Center(child: Text('Your cart is empty'));
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 22.h),
|
||||
...apiCartData.cartItems.map((cartItem) {
|
||||
// Get hero image from cityBanners imageFilePath
|
||||
final String heroImage = cartItem.city.cityBanners.isNotEmpty
|
||||
? cartItem.city.cityBanners.first.imageFilePath
|
||||
: '';
|
||||
final bool isFlexiCard =
|
||||
cartItem.cardMode.toLowerCase() == 'flexi';
|
||||
|
||||
final String cityName = cartItem.city.cityName;
|
||||
final String cardDisplayName = cartItem.displayCardMode;
|
||||
final String cardTypeName = cartItem.cardMode;
|
||||
final int themeColor = isFlexiCard ? 0xFFF95FAF : 0xFFF95F62;
|
||||
final int adultCount = cartItem.totalAdult;
|
||||
final int childCount = cartItem.totalChild;
|
||||
final int validityDuration = cartItem.noOfDays;
|
||||
final double totalPrice = cartItem.totalAmount.toDouble();
|
||||
|
||||
final bool isUnlimitedCard = cardTypeName
|
||||
.toLowerCase()
|
||||
.contains("unlimited");
|
||||
final String validityLabel = isUnlimitedCard
|
||||
? "$validityDuration Days"
|
||||
: "${cartItem.noOfAttractions} Attractions";
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 15.h),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final checkoutData = CheckoutData(
|
||||
cityName: cityName,
|
||||
heroImage: heroImage,
|
||||
cardTypeName: cardTypeName,
|
||||
cardDisplayName: cardDisplayName,
|
||||
themeColor: Color(themeColor),
|
||||
adultCount: adultCount,
|
||||
childCount: childCount,
|
||||
adultPrice: 0.0,
|
||||
childPrice: 0.0,
|
||||
validityDuration: validityDuration,
|
||||
totalPrice: cartItem.baseAmount,
|
||||
description: null,
|
||||
);
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CheckoutView(
|
||||
bookingId: cartItem.id,
|
||||
couponId: cartItem.couponXid,
|
||||
),
|
||||
settings: RouteSettings(arguments: checkoutData),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _CartItemCard(
|
||||
heroImage: heroImage,
|
||||
cityName: cityName,
|
||||
validityLabel: validityLabel,
|
||||
adultCount: adultCount,
|
||||
childCount: childCount,
|
||||
totalPrice: cartItem.baseAmount.toDouble(),
|
||||
themeColor: themeColor,
|
||||
cardDisplayName: cardDisplayName,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
SizedBox(height: 16.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// ========== HANDLE LOCAL DATA (NOT LOGGED IN) ==========
|
||||
else if (state is MyPassCartLoaded) {
|
||||
final cartData = state.cartData;
|
||||
|
||||
// Extract data from cart
|
||||
final String cityName = cartData['city_name'] as String? ?? '';
|
||||
final String heroImage = cartData['hero_image'] as String? ?? '';
|
||||
final String cardTypeName = cartData['card_type_name'] as String? ?? '';
|
||||
final String cardDisplayName = cartData['card_display_name'] as String? ?? '';
|
||||
final String cardTypeName =
|
||||
cartData['card_type_name'] as String? ?? '';
|
||||
final String cardDisplayName =
|
||||
cartData['card_display_name'] as String? ?? '';
|
||||
final int themeColor = cartData['theme_color'] as int? ?? 0xFFF95FAF;
|
||||
final int adultCount = cartData['adult_count'] as int? ?? 0;
|
||||
final int childCount = cartData['child_count'] as int? ?? 0;
|
||||
final double adultPrice = (cartData['adult_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final double childPrice = (cartData['child_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final int validityDuration = cartData['validity_duration'] as int? ?? 0;
|
||||
final double totalPrice = (cartData['total_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final double adultPrice =
|
||||
(cartData['adult_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final double childPrice =
|
||||
(cartData['child_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final int validityDuration =
|
||||
cartData['validity_duration'] as int? ?? 0;
|
||||
final double totalPrice =
|
||||
(cartData['total_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final String? description = cartData['description'] as String?;
|
||||
|
||||
// Calculate pricing
|
||||
final double subtotal = totalPrice;
|
||||
final double discountAmount = subtotal * (discountPercentage / 100);
|
||||
final double taxRate = 0.05; // 5% tax
|
||||
final double totalBeforeTax = subtotal - discountAmount;
|
||||
final double taxAmount = totalBeforeTax * taxRate;
|
||||
final double taxAmount = 2;
|
||||
final double finalTotal = totalBeforeTax + taxAmount;
|
||||
|
||||
// Determine if unlimited card
|
||||
final bool isUnlimitedCard = cardTypeName == "unlimited_card";
|
||||
final String validityLabel = isUnlimitedCard
|
||||
? "$validityDuration Days"
|
||||
: "$validityDuration Attractions";
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 22.h),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: Color(themeColor).withOpacity(0.2),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r),
|
||||
),
|
||||
child: heroImage.isNotEmpty
|
||||
? Image.network(
|
||||
heroImage,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.66.w),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: cityName,
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
CustomText(
|
||||
text: validityLabel,
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * .5,
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/adult.png',
|
||||
scale: 4,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text: "$adultCount ${adultCount == 1 ? 'adult' : 'adults'}",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/qty.png',
|
||||
scale: 4,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "Qty:",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF8E8E8E),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " ${adultCount + childCount}",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF000000),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
"assets/icons/kid.png",
|
||||
scale: 4,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text: "$childCount ${childCount == 1 ? 'Kid' : 'Kids'}",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
SizedBox(width: 53.w),
|
||||
CustomText(
|
||||
text: "\$${totalPrice.toStringAsFixed(2)}",
|
||||
size: 24.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 123.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(themeColor),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(8.r),
|
||||
topRight: Radius.circular(8.r),
|
||||
),
|
||||
),
|
||||
child: RotatedBox(
|
||||
quarterTurns: -1,
|
||||
child: Center(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "$cardDisplayName ",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
),
|
||||
// TextSpan(
|
||||
// text: "Card",
|
||||
// style: TextStyle(
|
||||
// color: Colors.white,
|
||||
// fontSize: 12.sp,
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5F5),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: Color(0xFFBB474A).withOpacity(0.4),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Get 10% off on your first trip",
|
||||
color: Color(0xFF262626),
|
||||
size: 14.sp,
|
||||
),
|
||||
SizedBox(height: 7.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(12.r),
|
||||
),
|
||||
),
|
||||
builder: (_) => AllCouponsBottomsheet(),
|
||||
);
|
||||
},
|
||||
child: CustomText(
|
||||
text: "View all coupons",
|
||||
color: Color(0xFFF95F62),
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 3.w),
|
||||
Icon(Icons.arrow_right, color: Color(0xFFF95F62)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (appliedCouponCode == null) {
|
||||
appliedCouponCode = "FIRST10";
|
||||
discountPercentage = 10.0;
|
||||
} else {
|
||||
appliedCouponCode = null;
|
||||
discountPercentage = 0.0;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 10.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: CustomText(
|
||||
text: appliedCouponCode != null ? "Remove" : "Apply",
|
||||
color: Color(0xFFF95F62),
|
||||
size: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
DashedDivider(
|
||||
color: Color(0xFFACACAC),
|
||||
thickness: 1.h,
|
||||
dashLength: 4,
|
||||
dashSpace: 4,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Subtotal", size: 14.sp),
|
||||
CustomText(
|
||||
text: "\$${subtotal.toStringAsFixed(2)}",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
if (discountPercentage > 0) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Discount", size: 14.sp),
|
||||
CustomText(
|
||||
text: "-\$${discountAmount.toStringAsFixed(2)} (${discountPercentage.toStringAsFixed(0)}%)",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
],
|
||||
DashedDivider(
|
||||
color: Color(0xFFACACAC),
|
||||
thickness: 1.h,
|
||||
dashLength: 4,
|
||||
dashSpace: 4,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(text: 'Total', size: 14.sp),
|
||||
SizedBox(height: 4.h),
|
||||
CustomText(
|
||||
text: "Including \$${taxAmount.toStringAsFixed(2)} in taxes",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CustomText(
|
||||
text: "\$${finalTotal.toStringAsFixed(2)}",
|
||||
size: 24.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 150.h),
|
||||
|
||||
// FutureBuilder for login check
|
||||
FutureBuilder<bool>(
|
||||
future: LocalPreference.getLogin(),
|
||||
builder: (context, snapshot) {
|
||||
final isLoggedIn = snapshot.data ?? false;
|
||||
|
||||
return CustomFilledButton(
|
||||
onTap: () {
|
||||
if (!isLoggedIn) {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.white,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(12.r),
|
||||
),
|
||||
),
|
||||
builder: (_) => const LoginEmailBottomsheet(),
|
||||
);
|
||||
} else {
|
||||
// Handle checkout logic for logged in user
|
||||
// You can navigate to checkout or payment screen
|
||||
print("✅ User is logged in, proceed to checkout");
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
label: isLoggedIn ? "Checkout" : "Login to Checkout",
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 25.h),
|
||||
],
|
||||
);
|
||||
} else if (state is MyPassCartEmpty) {
|
||||
return Center(
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset("assets/gif/empty_cart.gif", width: 250.w),
|
||||
CustomText(
|
||||
text: "You do not have any passes",
|
||||
size: 24.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
"Get a pass and get offers and discounts and more on your trip to your favourite city",
|
||||
style: TextStyle(color: Color(0xFF656565), fontSize: 14.sp),
|
||||
textAlign: TextAlign.center,
|
||||
SizedBox(height: 22.h),
|
||||
_CartItemCard(
|
||||
heroImage: heroImage,
|
||||
cityName: cityName,
|
||||
validityLabel: validityLabel,
|
||||
adultCount: adultCount,
|
||||
childCount: childCount,
|
||||
totalPrice: totalPrice,
|
||||
themeColor: themeColor,
|
||||
cardDisplayName: cardDisplayName,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is MyPassCartError) {
|
||||
}
|
||||
// ========== EMPTY STATE ==========
|
||||
else if (state is MyPassCartEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Center(
|
||||
child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset("assets/gif/empty_cart.gif", width: 250.w),
|
||||
CustomText(
|
||||
text: "You do not have any passes",
|
||||
size: 22.sp,
|
||||
color: const Color(0xFFF95F62),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
"Get a pass and get offers and discounts and more on your trip to your favourite city",
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF656565),
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
label: "Buy a Pass",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// ========== ERROR STATE ==========
|
||||
else if (state is MyPassCartError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -483,4 +252,183 @@ class _MyPassesPageState extends State<MyPassesPage> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Cart Item Card Widget
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
class _CartItemCard extends StatelessWidget {
|
||||
final String heroImage;
|
||||
final String cityName;
|
||||
final String validityLabel;
|
||||
final int adultCount;
|
||||
final int childCount;
|
||||
final double totalPrice;
|
||||
final int themeColor;
|
||||
final String cardDisplayName;
|
||||
|
||||
const _CartItemCard({
|
||||
required this.heroImage,
|
||||
required this.cityName,
|
||||
required this.validityLabel,
|
||||
required this.adultCount,
|
||||
required this.childCount,
|
||||
required this.totalPrice,
|
||||
required this.themeColor,
|
||||
required this.cardDisplayName,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Color(themeColor).withOpacity(0.2)),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Left image
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r),
|
||||
),
|
||||
child: heroImage.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: heroImage,
|
||||
width: 105.w,
|
||||
height: 130.h,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (context, url, error) => Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
placeholder: (context, url) => Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.66.w),
|
||||
|
||||
// Middle content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 10.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(
|
||||
text: cityName,
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
CustomText(
|
||||
text: validityLabel,
|
||||
color: const Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
|
||||
// Adult row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Image.asset('assets/icons/adult.png', scale: 4),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text:
|
||||
"$adultCount ${adultCount == 1 ? 'adult' : 'adults'}",
|
||||
color: const Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 5.h),
|
||||
|
||||
// Kid + Price row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Image.asset("assets/icons/kid.png", scale: 4),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text:
|
||||
"$childCount ${childCount == 1 ? 'Kid' : 'Kids'}",
|
||||
color: const Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
CustomText(
|
||||
text: "\$${totalPrice.toStringAsFixed(2)}",
|
||||
size: 20.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(themeColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 6.w),
|
||||
|
||||
// Right colored tab
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 130.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(themeColor),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(8.r),
|
||||
topRight: Radius.circular(8.r),
|
||||
),
|
||||
),
|
||||
child: RotatedBox(
|
||||
quarterTurns: -1,
|
||||
child: Center(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "$cardDisplayName ",
|
||||
style: TextStyle(color: Colors.white, fontSize: 14.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
501
lib/cart/views/my_postcard_cart_page_view.dart
Normal file
@@ -0,0 +1,501 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../common_bloc/bottom_navigation_bloc.dart';
|
||||
import '../../common_packages/custom_filled_button.dart';
|
||||
import '../../common_packages/custom_text.dart';
|
||||
import '../../login/view/login_email_bottomsheet.dart';
|
||||
import '../../postcard/blocs/edit_postcard/edit_postcard_bloc.dart';
|
||||
import '../../postcard/blocs/myPostCards/my_postcard_bloc.dart';
|
||||
import '../../postcard/blocs/myPostCards/my_postcard_event.dart';
|
||||
import '../../postcard/blocs/pick_images/pick_images_bloc.dart';
|
||||
import '../../postcard/blocs/postcardCheckout/postcard_checkout_bloc.dart';
|
||||
import '../../postcard/models/my_postcard_model.dart';
|
||||
import '../../postcard/repository/postcard_checkout_repository.dart';
|
||||
import '../../postcard/views/edit_postcard_view.dart';
|
||||
import '../../postcard/views/postcard_checkout_page_view.dart';
|
||||
import '../blocs/myPostcardsCart/my_postcards_cart_bloc.dart';
|
||||
import '../blocs/myPostcardsCart/my_postcards_cart_state.dart';
|
||||
import '../model/my_postcards_cart_model.dart';
|
||||
import '../widget/ticket_card_view.dart';
|
||||
|
||||
class MyPostCardsCartPage extends StatelessWidget {
|
||||
const MyPostCardsCartPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MyPostCardsCartBloc, MyPostCardsCartState>(
|
||||
builder: (context, state) {
|
||||
if (state is MyPostCardsCartLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xffF95F62)),
|
||||
);
|
||||
}
|
||||
if (state is MyPostCardsCartNotLoggedIn) {
|
||||
return _NotLoggedInScreen(onLoginTap: () {});
|
||||
}
|
||||
if (state is MyPostCardsCartEmpty) {
|
||||
return _EmptyCartScreen(
|
||||
onRefresh: () =>
|
||||
context.read<MyPostCardsCartBloc>().add(CheckLoginAndFetchPostcardsCart()),
|
||||
);
|
||||
}
|
||||
if (state is MyPostCardsCartError) {
|
||||
return _ErrorScreen(
|
||||
message: state.message,
|
||||
onRetry: () =>
|
||||
context.read<MyPostCardsCartBloc>().add(CheckLoginAndFetchPostcardsCart()),
|
||||
);
|
||||
}
|
||||
if (state is MyPostCardsCartLoaded) {
|
||||
return _CartLoadedScreen(cartData: state.cartData);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// CART LOADED
|
||||
// ─────────────────────────────────────────────────────────
|
||||
class _CartLoadedScreen extends StatefulWidget {
|
||||
final MyPostCardsCartModel cartData;
|
||||
const _CartLoadedScreen({required this.cartData});
|
||||
|
||||
@override
|
||||
State<_CartLoadedScreen> createState() => _CartLoadedScreenState();
|
||||
}
|
||||
|
||||
class _CartLoadedScreenState extends State<_CartLoadedScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// Height of one card slot (card height + bottom padding).
|
||||
// 330h card + 20h gap = 350. Adjust if your device renders differently.
|
||||
static const double _cardItemHeight = 350.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final offset = _scrollController.offset;
|
||||
final newIndex = (offset / _cardItemHeight).round();
|
||||
final clamped = newIndex.clamp(0, widget.cartData.cartItems.length - 1);
|
||||
if (clamped != _selectedIndex) {
|
||||
setState(() => _selectedIndex = clamped);
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToCheckout(CartItem item) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BlocProvider(
|
||||
create: (_) =>
|
||||
PostcardCheckoutBloc(repository: CreatePostCardRepository()),
|
||||
child: PostcardCheckoutPageView(
|
||||
countryName: item.countryName,
|
||||
cityName: item.cityName,
|
||||
stateName: item.stateName,
|
||||
zipCode: item.zipCode,
|
||||
address1: item.address1,
|
||||
address2: item.address2 ?? '',
|
||||
pcTitle: item.pcTitle,
|
||||
pcNumber: item.pcNumber,
|
||||
fullname: item.fullname,
|
||||
emailAddress: item.emailAddress,
|
||||
mobileNumber: item.mobileNumber,
|
||||
isdCode: item.isdCode.isNotEmpty ? item.isdCode : '+91',
|
||||
isForSelf: true,
|
||||
baseAmount: item.baseAmount.toDouble(),
|
||||
totalTaxAmount: item.totalTaxAmount.toDouble(),
|
||||
totalAmount: item.totalAmount.toDouble(),
|
||||
postcardId: item.id,
|
||||
pcImage: item.pcImagePath,
|
||||
pcContent: item.pcContent,
|
||||
isEditMode: true,
|
||||
senderName: item.senderFullName,
|
||||
senderCity: item.senderCityName,
|
||||
senderCountry: item.senderCountryName,
|
||||
isCartMode: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = widget.cartData.cartItems;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// ── Info Banner ──────────────────────────────────────
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffF95F62).withValues(alpha: 0.1),
|
||||
border: Border.all(color: const Color(0xffF95F62), width: 1),
|
||||
borderRadius: BorderRadius.circular(15.r),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28.w,
|
||||
height: 28.w,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xffF95F62),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Colors.white,
|
||||
size: 16.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You can purchase one postcard at a time',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF212121),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Scrollable list ──────────────────────────────────
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Actual pixel height of the visible list area
|
||||
final listViewHeight = constraints.maxHeight;
|
||||
|
||||
// KEY FIX: Add trailing bottom padding equal to
|
||||
// (listHeight - one card slot) so the last card can scroll
|
||||
// all the way to the top and become "selected".
|
||||
final trailingPadding = (listViewHeight - _cardItemHeight).clamp(
|
||||
0.0,
|
||||
double.infinity,
|
||||
);
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, trailingPadding),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isSelected = index == _selectedIndex;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 20.h),
|
||||
child: AnimatedOpacity(
|
||||
opacity: isSelected ? 1.0 : 0.4,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: AnimatedScale(
|
||||
scale: isSelected ? 1.0 : 0.95,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Stack(
|
||||
children: [
|
||||
TicketCard(
|
||||
cartItem: items[index],
|
||||
onEditDraft: () async {
|
||||
final result = await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
EditPostcardBloc(),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => PickImagesBloc(),
|
||||
),
|
||||
],
|
||||
|
||||
child: EditPostcardView(
|
||||
myPostCard: MyPostCard(
|
||||
id: items[index].id,
|
||||
pcTitle: items[index].pcTitle,
|
||||
pcNumber: items[index].pcNumber,
|
||||
pcImagePath: items[index].pcImagePath,
|
||||
pcContent: items[index].pcContent,
|
||||
fullname: items[index].fullname,
|
||||
emailAddress: items[index].emailAddress,
|
||||
mobileNumber: items[index].mobileNumber,
|
||||
isdCode: items[index].isdCode.isNotEmpty ? items[index].isdCode : '+91',
|
||||
address1: items[index].address1,
|
||||
address2: items[index].address2 ?? '',
|
||||
cityName: items[index].cityName,
|
||||
stateName: items[index].stateName,
|
||||
countryName: items[index].countryName,
|
||||
zipCode: items[index].zipCode,
|
||||
baseAmount: items[index].baseAmount.toDouble(),
|
||||
totalTaxAmount: items[index].totalTaxAmount.toDouble(),
|
||||
totalAmount: items[index].totalAmount.toDouble(),
|
||||
isForSelf: items[index].isForSelf,
|
||||
senderCityName: items[index].senderCityName,
|
||||
senderCountryName: items[index].senderCountryName,
|
||||
senderFullName: items[index].senderFullName,
|
||||
userXid: 0,
|
||||
pcDatetime: DateTime.now(),
|
||||
orderStatus: '',
|
||||
isPaid: false,
|
||||
paymentMode: '',
|
||||
paymentStatus: '',
|
||||
isDraft: false,
|
||||
isAddedToCart: true,
|
||||
isActive: true,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
), isCartMode: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<MyPostCardBloc>().add(
|
||||
const RefreshDraftPostCards(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// ── Selected badge ──
|
||||
// if (isSelected)
|
||||
// Positioned(
|
||||
// top: 12.h,
|
||||
// right: 20.w,
|
||||
// child: Container(
|
||||
// padding: EdgeInsets.symmetric(
|
||||
// horizontal: 10.w, vertical: 4.h),
|
||||
// decoration: BoxDecoration(
|
||||
// color: const Color(0xffF95F62),
|
||||
// borderRadius: BorderRadius.circular(20.r),
|
||||
// ),
|
||||
// child: Text(
|
||||
// 'Selected',
|
||||
// style: GoogleFonts.poppins(
|
||||
// color: Colors.white,
|
||||
// fontSize: 10.sp,
|
||||
// fontWeight: FontWeight.w600,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 14.h),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: CustomFilledButton(
|
||||
width: double.infinity,
|
||||
onTap: () {
|
||||
// Navigator.pop(context);
|
||||
_navigateToCheckout(items[_selectedIndex]);
|
||||
},
|
||||
label: "Proceed to Checkout",
|
||||
),
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// NOT LOGGED IN
|
||||
// ─────────────────────────────────────────────────────────
|
||||
class _NotLoggedInScreen extends StatelessWidget {
|
||||
final VoidCallback onLoginTap;
|
||||
const _NotLoggedInScreen({required this.onLoginTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Center(
|
||||
child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset("assets/gif/empty_cart.gif", width: 250.w),
|
||||
CustomText(
|
||||
text: "You are not logged in yet!",
|
||||
size: 22.sp,
|
||||
color: const Color(0xFFF95F62),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
"To access my postcards cart please login",
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF656565),
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.white,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12.r)),
|
||||
),
|
||||
builder: (_) => const LoginEmailBottomsheet(),
|
||||
);
|
||||
},
|
||||
label: "Login to Checkout",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// EMPTY CART
|
||||
// ─────────────────────────────────────────────────────────
|
||||
class _EmptyCartScreen extends StatelessWidget {
|
||||
final VoidCallback onRefresh;
|
||||
const _EmptyCartScreen({required this.onRefresh});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset('assets/gif/empty_post_card.gif', width: 200.w),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
'You do not have any postcards',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xffF95F62),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
"You do not possess any postcards yet nor have you sent to anyone",
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF656565),
|
||||
),
|
||||
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
label: "Design my postcard",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// ERROR
|
||||
// ─────────────────────────────────────────────────────────
|
||||
class _ErrorScreen extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
const _ErrorScreen({required this.message, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.w),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: 64.sp,
|
||||
color: const Color(0xffF95F62),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF212121),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13.sp,
|
||||
color: const Color(0xFF656565),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
OutlinedButton(
|
||||
onPressed: onRetry,
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xffF95F62)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 12.h),
|
||||
),
|
||||
child: Text(
|
||||
'Retry',
|
||||
style: TextStyle(
|
||||
color: const Color(0xffF95F62),
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import 'package:citycards_customer/cart/widget/ticket_card_view.dart';
|
||||
import 'package:citycards_customer/checkout/widget/all_coupons_bottomsheet.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../../login/view/login_email_bottomsheet.dart';
|
||||
import '../blocs/postcard_bloc.dart';
|
||||
|
||||
class MyPostCardsPage extends StatelessWidget {
|
||||
const MyPostCardsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PostCardBloc, PostCardState>(
|
||||
builder: (context, state) {
|
||||
if (state is PostCardLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is PostCardLoaded) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child:
|
||||
Column(
|
||||
children: [
|
||||
TicketCard(),
|
||||
SizedBox(height: 40.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5F5),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: Color(0xFFBB474A).withOpacity(0.4),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Get 10% off on your first trip",
|
||||
color: Color(0xFF262626),
|
||||
size: 14.sp,
|
||||
),
|
||||
SizedBox(height: 7.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(12.r),
|
||||
),
|
||||
),
|
||||
builder: (_) => AllCouponsBottomsheet(),
|
||||
);
|
||||
},
|
||||
child: CustomText(
|
||||
text: "View all coupons",
|
||||
color: Color(0xFFF95F62),
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 3.w),
|
||||
Icon(Icons.arrow_right, color: Color(0xFFF95F62)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 10.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: CustomText(
|
||||
text: "Apply",
|
||||
color: Color(0xFFF95F62),
|
||||
size: 14.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 15.h),
|
||||
|
||||
Divider(color: Color(0xFFACACAC), thickness: 1.h),
|
||||
SizedBox(height: 10.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Subtotal", size: 14.sp),
|
||||
CustomText(
|
||||
text: "\$49.50",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Discount", size: 14.sp),
|
||||
CustomText(
|
||||
text: "-7.20%",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Divider(color: Color(0xFFACACAC), thickness: 1.h),
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(text: 'Total', size: 14.sp),
|
||||
SizedBox(height: 4.h),
|
||||
CustomText(
|
||||
text: "Including \$2.24 in taxes",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CustomText(
|
||||
text: "\$42.60",
|
||||
size: 24.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 60.h),
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.white,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(12.r),
|
||||
),
|
||||
),
|
||||
builder: (_) => const LoginEmailBottomsheet(),
|
||||
);
|
||||
},
|
||||
width: double.infinity,
|
||||
label: "Proceed to Checkout",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset("assets/gif/empty_post_card.gif", width: 250.w),
|
||||
Text(
|
||||
"You do not have any postcards",
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
color: Color(0xFFF95F62)
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
"You do not possess any postcards yet nor have you sent to anyone",
|
||||
style: TextStyle(color: Color(0xFF656565), fontSize: 14.sp),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import 'package:citycards_customer/common_packages/custom_dashed_line.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../model/my_postcards_cart_model.dart';
|
||||
|
||||
class TicketCard extends StatelessWidget {
|
||||
const TicketCard({super.key});
|
||||
final CartItem cartItem;
|
||||
final VoidCallback onEditDraft;
|
||||
|
||||
const TicketCard({
|
||||
super.key,
|
||||
required this.cartItem,
|
||||
required this.onEditDraft,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -13,43 +22,101 @@ class TicketCard extends StatelessWidget {
|
||||
child: ClipPath(
|
||||
clipper: TicketClipper(),
|
||||
child: Container(
|
||||
width: 270.w,
|
||||
height: 400.h,
|
||||
padding: EdgeInsets.all(16.w),
|
||||
width: 240.w,
|
||||
height: 340.h,
|
||||
padding: EdgeInsets.all(14.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
borderRadius: BorderRadius.circular(24.r), // ← was 12.r
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Postcard Image ──
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
child: Image.asset(
|
||||
'assets/images/card_banner.png',
|
||||
width: 237.w,
|
||||
height: 198.h,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
child: cartItem.pcImagePath.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl:
|
||||
'${ApiUrls.baseUrl}${cartItem.pcImagePath}',
|
||||
width: 210.w,
|
||||
height: 170.h,
|
||||
fit: BoxFit.cover,
|
||||
progressIndicatorBuilder:
|
||||
(context, url, progress) {
|
||||
return Container(
|
||||
width: 210.w,
|
||||
height: 170.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: CircularProgressIndicator(
|
||||
color: const Color(0xffF95F62),
|
||||
value: progress.progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (_, __, ___) => _placeholderImage(),
|
||||
)
|
||||
: _placeholderImage(),
|
||||
),
|
||||
|
||||
SizedBox(height: 25.h),
|
||||
|
||||
// ── Dashed Divider ──
|
||||
// Transform.translate shifts left by container padding (14.w)
|
||||
// so dashes start/end at the notch centers.
|
||||
Transform.translate(
|
||||
offset: Offset(-14.w, 0),
|
||||
child: SizedBox(
|
||||
width: 240.w,
|
||||
height: 14.h,
|
||||
child: CustomPaint(
|
||||
painter: _NotchDashPainter(),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
SizedBox(
|
||||
width: 200.w,
|
||||
child: DashedDivider(
|
||||
color: const Color(0xFFBEBEBE),
|
||||
thickness: 2.h,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
// ── Title ──
|
||||
Text(
|
||||
"Melbourne",
|
||||
cartItem.pcTitle.isNotEmpty ? cartItem.pcTitle : 'No Title',
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF212121),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
// ── Edit Draft Button ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: onEditDraft,
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: Color(0xffF95F62).withValues(alpha: 0.15),
|
||||
side: const BorderSide(color: Color(0xffF95F62), width: 1.5),
|
||||
padding: EdgeInsets.symmetric(vertical: 8.h),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Edit Draft',
|
||||
style: TextStyle(
|
||||
color: const Color(0xffF95F62),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
_infoRow("Postcards :", "5"),
|
||||
_infoRow("Date :", "22/04/2025"),
|
||||
_infoRow("Time :", "12:00PM - 2:00PM"),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -58,104 +125,123 @@ class TicketCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(String title, String value) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: const Color(0xFF808080), fontSize: 12.sp),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 12.sp),
|
||||
),
|
||||
],
|
||||
Widget _placeholderImage() {
|
||||
return Container(
|
||||
width: 210.w,
|
||||
height: 170.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Icon(Icons.image_outlined, size: 42.sp, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TicketPainter extends CustomPainter {
|
||||
// ─────────────────────────────────────────────
|
||||
// Notch Dash Painter
|
||||
// Draws dashes from center of left notch to center of right notch.
|
||||
// notchRadius = 28.r, so startX = 28.w, endX = (240 - 28).w
|
||||
// ─────────────────────────────────────────────
|
||||
class _NotchDashPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final notchRadius = 23.r;
|
||||
final dividerY = 240.h;
|
||||
final paint = Paint()
|
||||
..color = const Color(0xffF95F62)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
final ticketPath = Path()
|
||||
..moveTo(12.w, 0)
|
||||
..lineTo(size.width - 12.w, 0)
|
||||
..arcToPoint(Offset(size.width, 12.h), radius: Radius.circular(12.r))
|
||||
..lineTo(size.width, dividerY - notchRadius)
|
||||
..arcToPoint(
|
||||
Offset(size.width, dividerY + notchRadius),
|
||||
radius: Radius.circular(notchRadius),
|
||||
clockwise: false,
|
||||
)
|
||||
..lineTo(size.width, size.height - 12.h)
|
||||
..arcToPoint(
|
||||
Offset(size.width - 12.w, size.height),
|
||||
radius: Radius.circular(12.r),
|
||||
)
|
||||
..lineTo(12.w, size.height)
|
||||
..arcToPoint(
|
||||
Offset(0, size.height - 12.h),
|
||||
radius: Radius.circular(12.r),
|
||||
)
|
||||
..lineTo(0, dividerY + notchRadius)
|
||||
..arcToPoint(
|
||||
Offset(0, dividerY - notchRadius),
|
||||
radius: Radius.circular(notchRadius),
|
||||
clockwise: false,
|
||||
)
|
||||
..lineTo(0, 12.h)
|
||||
..arcToPoint(Offset(12.w, 0), radius: Radius.circular(12.r))
|
||||
..close();
|
||||
// Dashes from left notch center to right notch center.
|
||||
// Card is 240.w wide, notchRadius = 28.w. We hardcode these because
|
||||
// size.width here is the inner column width (240-2*14=212.w), not the card width.
|
||||
final double startX = 30.w; // 2.w gap from notch edge
|
||||
final double endX = 240.w - 30.w; // 2.w gap from notch edge
|
||||
final double dashH = 6.h;
|
||||
final double dashW = 12.w;
|
||||
final double gap = 5.w;
|
||||
final double top = (size.height - dashH) / 2;
|
||||
final double span = endX - startX;
|
||||
|
||||
final shadowPaint = Paint()
|
||||
..color = Colors.black.withOpacity(0.3)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.outer, 8);
|
||||
// Fit exact number of dashes: n*dashW + (n-1)*gap <= span
|
||||
final int count = ((span + gap) / (dashW + gap)).floor();
|
||||
|
||||
canvas.drawPath(ticketPath, shadowPaint);
|
||||
// Recalculate actual gap to distribute evenly
|
||||
final double actualGap = count > 1 ? (span - count * dashW) / (count - 1) : 0;
|
||||
|
||||
final cardPaint = Paint()
|
||||
..color = const Color(0xFFFAC9CA).withOpacity(0.12)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawPath(ticketPath, cardPaint);
|
||||
double x = startX;
|
||||
for (int i = 0; i < count; i++) {
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(x, top, dashW, dashH),
|
||||
Radius.circular(dashH / 2),
|
||||
),
|
||||
paint,
|
||||
);
|
||||
x += dashW + actualGap;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class TicketClipper extends CustomClipper<Path> {
|
||||
// ─────────────────────────────────────────────
|
||||
// Ticket Painter (shadow + fill)
|
||||
// ─────────────────────────────────────────────
|
||||
class TicketPainter extends CustomPainter {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final notchRadius = 23.r;
|
||||
final dividerY = 240.h;
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final notchRadius = 28.r;
|
||||
final dividerY = 218.h;
|
||||
|
||||
final path = Path()
|
||||
..moveTo(12.w, 0)
|
||||
..lineTo(size.width - 12.w, 0)
|
||||
..arcToPoint(Offset(size.width, 12.h), radius: Radius.circular(12.r))
|
||||
final ticketPath = _buildPath(size, notchRadius, dividerY);
|
||||
|
||||
// Shadow
|
||||
canvas.drawPath(
|
||||
ticketPath,
|
||||
Paint()
|
||||
..color = Colors.black.withOpacity(0.15)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.outer, 8),
|
||||
);
|
||||
|
||||
// Fill
|
||||
canvas.drawPath(
|
||||
ticketPath,
|
||||
Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
|
||||
// Border stroke
|
||||
canvas.drawPath(
|
||||
ticketPath,
|
||||
Paint()
|
||||
..color = const Color(0xffF95F62).withOpacity(0.5)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5,
|
||||
);
|
||||
}
|
||||
|
||||
Path _buildPath(Size size, double notchRadius, double dividerY) {
|
||||
return Path()
|
||||
..moveTo(24.w, 0) // ← was 12.w
|
||||
..lineTo(size.width - 24.w, 0) // ← was 12.w
|
||||
..arcToPoint(Offset(size.width, 24.h), radius: Radius.circular(24.r)) // ← was 12
|
||||
..lineTo(size.width, dividerY - notchRadius)
|
||||
..arcToPoint(
|
||||
Offset(size.width, dividerY + notchRadius),
|
||||
radius: Radius.circular(notchRadius),
|
||||
clockwise: false,
|
||||
)
|
||||
..lineTo(size.width, size.height - 12.h)
|
||||
..lineTo(size.width, size.height - 24.h) // ← was 12.h
|
||||
..arcToPoint(
|
||||
Offset(size.width - 12.w, size.height),
|
||||
radius: Radius.circular(12.r),
|
||||
Offset(size.width - 24.w, size.height), // ← was 12.w
|
||||
radius: Radius.circular(24.r), // ← was 12.r
|
||||
)
|
||||
..lineTo(12.w, size.height)
|
||||
..lineTo(24.w, size.height) // ← was 12.w
|
||||
..arcToPoint(
|
||||
Offset(0, size.height - 12.h),
|
||||
radius: Radius.circular(12.r),
|
||||
Offset(0, size.height - 24.h), // ← was 12.h
|
||||
radius: Radius.circular(24.r), // ← was 12.r
|
||||
)
|
||||
..lineTo(0, dividerY + notchRadius)
|
||||
..arcToPoint(
|
||||
@@ -163,13 +249,55 @@ class TicketClipper extends CustomClipper<Path> {
|
||||
radius: Radius.circular(notchRadius),
|
||||
clockwise: false,
|
||||
)
|
||||
..lineTo(0, 12.h)
|
||||
..arcToPoint(Offset(12.w, 0), radius: Radius.circular(12.r))
|
||||
..lineTo(0, 24.h) // ← was 12.h
|
||||
..arcToPoint(Offset(24.w, 0), radius: Radius.circular(24.r)) // ← was 12
|
||||
..close();
|
||||
}
|
||||
|
||||
return path;
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Ticket Clipper
|
||||
// ─────────────────────────────────────────────
|
||||
class TicketClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final notchRadius = 28.r;
|
||||
final dividerY = 218.h;
|
||||
|
||||
return Path()
|
||||
..moveTo(24.w, 0) // ← was 12.w
|
||||
..lineTo(size.width - 24.w, 0) // ← was 12.w
|
||||
..arcToPoint(Offset(size.width, 24.h), radius: Radius.circular(24.r)) // ← was 12
|
||||
..lineTo(size.width, dividerY - notchRadius)
|
||||
..arcToPoint(
|
||||
Offset(size.width, dividerY + notchRadius),
|
||||
radius: Radius.circular(notchRadius),
|
||||
clockwise: false,
|
||||
)
|
||||
..lineTo(size.width, size.height - 24.h) // ← was 12.h
|
||||
..arcToPoint(
|
||||
Offset(size.width - 24.w, size.height), // ← was 12.w
|
||||
radius: Radius.circular(24.r), // ← was 12.r
|
||||
)
|
||||
..lineTo(24.w, size.height) // ← was 12.w
|
||||
..arcToPoint(
|
||||
Offset(0, size.height - 24.h), // ← was 12.h
|
||||
radius: Radius.circular(24.r), // ← was 12.r
|
||||
)
|
||||
..lineTo(0, dividerY + notchRadius)
|
||||
..arcToPoint(
|
||||
Offset(0, dividerY - notchRadius),
|
||||
radius: Radius.circular(notchRadius),
|
||||
clockwise: false,
|
||||
)
|
||||
..lineTo(0, 24.h) // ← was 12.h
|
||||
..arcToPoint(Offset(24.w, 0), radius: Radius.circular(24.r)) // ← was 12
|
||||
..close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
||||
on<FetchCheckoutCouponsEvent>(_onFetchCheckoutCoupons);
|
||||
on<ApplyCouponEvent>(_onApplyCoupon);
|
||||
on<RemoveCouponEvent>(_onRemoveCoupon);
|
||||
on<ApplyCouponToBackendEvent>(_onApplyCouponToBackend); // 🆕 NEW
|
||||
on<InitiatePaymentEvent>(_onInitiatePayment); // 🆕 NEW
|
||||
on<ConfirmPaymentEvent>(_onConfirmPayment); // 🆕 NEW
|
||||
}
|
||||
@@ -42,13 +43,77 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoveCoupon(
|
||||
Future<void> _onRemoveCoupon(
|
||||
RemoveCouponEvent event,
|
||||
Emitter<CheckoutState> emit,
|
||||
) {
|
||||
) async {
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(clearAppliedCoupon: true));
|
||||
|
||||
// Show loading
|
||||
emit(currentState.copyWith(isApplyingCoupon: true, couponError: null));
|
||||
|
||||
try {
|
||||
// Call API with empty coupon code
|
||||
await checkoutRepository.applyCoupon(
|
||||
bookingId: event.bookingId,
|
||||
couponCode: '', // Empty string to remove coupon
|
||||
);
|
||||
|
||||
// Clear applied coupon from state
|
||||
emit(currentState.copyWith(
|
||||
clearAppliedCoupon: true,
|
||||
isApplyingCoupon: false,
|
||||
couponError: null,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(currentState.copyWith(
|
||||
isApplyingCoupon: false,
|
||||
couponError: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 Apply Coupon to Backend
|
||||
/// Calls the PUT /apply-coupon API
|
||||
Future<void> _onApplyCouponToBackend(
|
||||
ApplyCouponToBackendEvent event,
|
||||
Emitter<CheckoutState> emit,
|
||||
) async {
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
|
||||
// Show loading
|
||||
emit(currentState.copyWith(isApplyingCoupon: true, couponError: null));
|
||||
|
||||
try {
|
||||
// Call API
|
||||
final response = await checkoutRepository.applyCoupon(
|
||||
bookingId: event.bookingId,
|
||||
couponCode: event.couponCode,
|
||||
);
|
||||
|
||||
// Find the coupon from the list
|
||||
final appliedCoupon = currentState.coupons.firstWhere(
|
||||
(c) => c.couponCode == event.couponCode,
|
||||
orElse: () => currentState.coupons.first,
|
||||
);
|
||||
|
||||
// Update state with applied coupon
|
||||
emit(currentState.copyWith(
|
||||
appliedCoupon: appliedCoupon,
|
||||
isApplyingCoupon: false,
|
||||
couponError: null,
|
||||
));
|
||||
|
||||
// Success message will be handled in view
|
||||
} catch (e) {
|
||||
emit(currentState.copyWith(
|
||||
isApplyingCoupon: false,
|
||||
couponError: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +197,15 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
||||
ConfirmPaymentEvent event,
|
||||
Emitter<CheckoutState> emit,
|
||||
) async {
|
||||
// 🔒 GUARD: Prevent duplicate confirmation calls
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
if (currentState.hasConfirmationBeenSent) {
|
||||
print('⚠️ [CHECKOUT BLOC] Payment confirmation already sent. Ignoring duplicate call.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
@@ -139,6 +213,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
||||
isConfirmingPayment: true,
|
||||
confirmationError: null,
|
||||
isPaymentConfirmed: false,
|
||||
hasConfirmationBeenSent: true, // 🔒 Mark as sent
|
||||
));
|
||||
} else {
|
||||
emit(CheckoutPaymentConfirmingState());
|
||||
@@ -174,6 +249,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
||||
isConfirmingPayment: false,
|
||||
isPaymentConfirmed: false,
|
||||
confirmationError: e.toString(),
|
||||
hasConfirmationBeenSent: false, // 🔓 Reset on error to allow retry
|
||||
));
|
||||
} else {
|
||||
emit(CheckoutPaymentConfirmationErrorState(
|
||||
|
||||
@@ -8,8 +8,22 @@ class ApplyCouponEvent extends CheckoutEvent {
|
||||
final AllCouponsModel coupon;
|
||||
ApplyCouponEvent({required this.coupon});
|
||||
}
|
||||
/// 🆕 Apply Coupon to Backend Event
|
||||
class ApplyCouponToBackendEvent extends CheckoutEvent {
|
||||
final int bookingId;
|
||||
final String couponCode;
|
||||
|
||||
class RemoveCouponEvent extends CheckoutEvent {}
|
||||
ApplyCouponToBackendEvent({
|
||||
required this.bookingId,
|
||||
required this.couponCode,
|
||||
});
|
||||
}
|
||||
|
||||
class RemoveCouponEvent extends CheckoutEvent {
|
||||
final int bookingId;
|
||||
|
||||
RemoveCouponEvent({required this.bookingId});
|
||||
}
|
||||
|
||||
/// 🆕 Initiate Payment Event
|
||||
/// Triggered when user clicks "Pay" button
|
||||
|
||||
@@ -10,6 +10,10 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
||||
final List<AllCouponsModel> coupons;
|
||||
final AllCouponsModel? appliedCoupon;
|
||||
|
||||
// 🆕 Coupon application tracking
|
||||
final bool isApplyingCoupon;
|
||||
final String? couponError;
|
||||
|
||||
// 🆕 Payment-related fields
|
||||
final bool isInitiatingPayment;
|
||||
final String? clientSecret; // Stripe client secret
|
||||
@@ -21,10 +25,13 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
||||
final bool isPaymentConfirmed;
|
||||
final String? confirmationError;
|
||||
final Map<String, dynamic>? bookingDetails; // Full booking response after confirmation
|
||||
final bool hasConfirmationBeenSent; // 🔒 Prevent duplicate confirmation calls
|
||||
|
||||
CheckoutCouponsLoadedState({
|
||||
required this.coupons,
|
||||
this.appliedCoupon,
|
||||
this.isApplyingCoupon = false,
|
||||
this.couponError,
|
||||
this.isInitiatingPayment = false,
|
||||
this.clientSecret,
|
||||
this.bookingId,
|
||||
@@ -33,12 +40,15 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
||||
this.isPaymentConfirmed = false,
|
||||
this.confirmationError,
|
||||
this.bookingDetails,
|
||||
this.hasConfirmationBeenSent = false,
|
||||
});
|
||||
|
||||
CheckoutCouponsLoadedState copyWith({
|
||||
List<AllCouponsModel>? coupons,
|
||||
AllCouponsModel? appliedCoupon,
|
||||
bool clearAppliedCoupon = false,
|
||||
bool? isApplyingCoupon,
|
||||
String? couponError,
|
||||
bool? isInitiatingPayment,
|
||||
String? clientSecret,
|
||||
int? bookingId,
|
||||
@@ -48,10 +58,13 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
||||
String? confirmationError,
|
||||
bool clearClientSecret = false,
|
||||
Map<String, dynamic>? bookingDetails,
|
||||
bool? hasConfirmationBeenSent,
|
||||
}) {
|
||||
return CheckoutCouponsLoadedState(
|
||||
coupons: coupons ?? this.coupons,
|
||||
appliedCoupon: clearAppliedCoupon ? null : (appliedCoupon ?? this.appliedCoupon),
|
||||
isApplyingCoupon: isApplyingCoupon ?? this.isApplyingCoupon,
|
||||
couponError: couponError,
|
||||
isInitiatingPayment: isInitiatingPayment ?? this.isInitiatingPayment,
|
||||
bookingId: bookingId ?? this.bookingId,
|
||||
paymentError: paymentError,
|
||||
@@ -60,6 +73,7 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
||||
confirmationError: confirmationError,
|
||||
clientSecret: clearClientSecret ? null : (clientSecret ?? this.clientSecret),
|
||||
bookingDetails: bookingDetails ?? this.bookingDetails,
|
||||
hasConfirmationBeenSent: hasConfirmationBeenSent ?? this.hasConfirmationBeenSent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ class PurchaseDetailsBloc
|
||||
isForSelf: event.isForSelf,
|
||||
recipientFirstName: event.recipientFirstName,
|
||||
recipientLastName: event.recipientLastName,
|
||||
isdCode: event.isdCode,
|
||||
recipientEmail: event.recipientEmail,
|
||||
recipientPhone: event.recipientPhone,
|
||||
city: event.city,
|
||||
|
||||
@@ -19,6 +19,7 @@ class SubmitUserDetailsEvent extends PassPurchaseDetailsEvent {
|
||||
final bool isForSelf;
|
||||
final String? recipientFirstName;
|
||||
final String? recipientLastName;
|
||||
final String? isdCode;
|
||||
final String? recipientEmail;
|
||||
final String? recipientPhone;
|
||||
final String? city;
|
||||
@@ -29,6 +30,7 @@ class SubmitUserDetailsEvent extends PassPurchaseDetailsEvent {
|
||||
required this.isForSelf,
|
||||
this.recipientFirstName,
|
||||
this.recipientLastName,
|
||||
this.isdCode,
|
||||
this.recipientEmail,
|
||||
this.recipientPhone,
|
||||
this.city,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
|
||||
@@ -8,63 +5,34 @@ class PassPurchaseDetailsRepository {
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
/// Submit user details for pass purchase
|
||||
/// POST https://devapi.citycards.betadelivery.com/mobile/passes/{bookingId}/user-details
|
||||
Future<Map<String, dynamic>> submitUserDetails({
|
||||
required int bookingId,
|
||||
required bool isForSelf,
|
||||
String? recipientFirstName,
|
||||
String? recipientLastName,
|
||||
String? isdCode,
|
||||
String? recipientEmail,
|
||||
String? recipientPhone,
|
||||
String? city,
|
||||
String? country,
|
||||
}) async {
|
||||
try {
|
||||
log('🟢 submitUserDetails() called');
|
||||
log('📤 [SUBMIT USER DETAILS] Booking ID: $bookingId');
|
||||
log('📤 [SUBMIT USER DETAILS] Is For Self: $isForSelf');
|
||||
|
||||
// Construct URL with bookingId
|
||||
final url = '${ApiUrls.baseUrl}/mobile/passes/$bookingId/user-details';
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [SUBMIT USER DETAILS] API URL: $url');
|
||||
}
|
||||
|
||||
// Request body
|
||||
final requestBody = {
|
||||
'isForSelf': isForSelf,
|
||||
'recipientName': recipientFirstName ?? '',
|
||||
// 'recipientLastName': recipientLastName ?? '',
|
||||
'recipientEmail': recipientEmail ?? '',
|
||||
'recipientPhone': recipientPhone ?? '',
|
||||
// 'city': city ?? '',
|
||||
// 'country': country ?? '',
|
||||
};
|
||||
|
||||
log('📦 Request Body: $requestBody');
|
||||
|
||||
// Send POST request
|
||||
final response = await _apiServices.putApi(
|
||||
url: url,
|
||||
data: requestBody,
|
||||
url: '${ApiUrls.baseUrl}/mobile/passes/$bookingId/user-details',
|
||||
data: {
|
||||
'isForSelf': isForSelf,
|
||||
'recipientFirstName': recipientFirstName ?? '',
|
||||
'recipientLastName': recipientLastName ?? '',
|
||||
'isdCode': isdCode ?? '',
|
||||
'recipientEmail': recipientEmail ?? '',
|
||||
'recipientPhone': recipientPhone ?? '',
|
||||
'recipientCity': city ?? '',
|
||||
'recipientCountry': country ?? '',
|
||||
},
|
||||
);
|
||||
|
||||
log('✅ [SUBMIT USER DETAILS] Response Status: ${response.statusCode}');
|
||||
log('📥 [SUBMIT USER DETAILS] Response Data: ${response.data}');
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [SUBMIT USER DETAILS] ✅ User details submission successful');
|
||||
print('📤 [SUBMIT USER DETAILS] Full Response: ${response.data}');
|
||||
}
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e, stackTrace) {
|
||||
log(
|
||||
'❌ submitUserDetails FAILED',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to submit user details: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,26 +30,26 @@ class AllCouponsBottomsheet extends StatelessWidget {
|
||||
right: 20.w,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
/// --- Header ---
|
||||
Container(
|
||||
height: 4.h,
|
||||
width: 40.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF2D3134),
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
/// --- Header ---
|
||||
Container(
|
||||
height: 4.h,
|
||||
width: 40.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF2D3134),
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
CustomText(
|
||||
text: "All Coupons", size: 18.sp, weight: FontWeight.w500),
|
||||
SizedBox(height: 22.h),
|
||||
SizedBox(height: 12.h),
|
||||
CustomText(
|
||||
text: "All Coupons", size: 18.sp, weight: FontWeight.w500),
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
/// --- Coupon list ---
|
||||
Flexible(
|
||||
child: BlocBuilder<AllCouponsBloc, AllCouponsState>(
|
||||
/// --- Coupon list ---
|
||||
BlocBuilder<AllCouponsBloc, AllCouponsState>(
|
||||
builder: (context, state) {
|
||||
if (state is CouponsLoadingState) {
|
||||
return Center(
|
||||
@@ -77,7 +77,7 @@ class AllCouponsBottomsheet extends StatelessWidget {
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: state.coupons.length,
|
||||
separatorBuilder: (_, __) => SizedBox(height: 12.h),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -101,14 +101,15 @@ class AllCouponsBottomsheet extends StatelessWidget {
|
||||
MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 220.w,
|
||||
Expanded(
|
||||
child: CustomText(
|
||||
text: "${coupon.discountPercent}% discount on ${coupon.title}",
|
||||
text:
|
||||
"${coupon.discountPercent}% discount on ${coupon.title}",
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// Pass the selected coupon back to checkout view
|
||||
@@ -118,8 +119,9 @@ class AllCouponsBottomsheet extends StatelessWidget {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
width: 110.w,
|
||||
height: 44.h,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62),
|
||||
borderRadius:
|
||||
@@ -141,9 +143,9 @@ class AllCouponsBottomsheet extends StatelessWidget {
|
||||
height: 32.h,
|
||||
width: 83.w,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Color(0xFFF95F62).withOpacity(0.12),
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
color: Color(0xFFF95F62).withOpacity(0.12),
|
||||
border:
|
||||
Border.all(color: Color(0xFFF95F62)),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Center(
|
||||
@@ -165,8 +167,9 @@ class AllCouponsBottomsheet extends StatelessWidget {
|
||||
return SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 16.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -47,12 +47,12 @@ class _PassPurchaseContent extends StatelessWidget {
|
||||
Navigator.of(context).pop('success');
|
||||
|
||||
// Show success message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Details submitted successfully!'),
|
||||
backgroundColor: Color(0xffF95F62),
|
||||
),
|
||||
);
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// const SnackBar(
|
||||
// content: Text('Details submitted successfully!'),
|
||||
// backgroundColor: Color(0xffF95F62),
|
||||
// ),
|
||||
// );
|
||||
}
|
||||
|
||||
// Handle API submission error
|
||||
@@ -275,9 +275,8 @@ class _PassPurchaseContent extends StatelessWidget {
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
child: CircularProgressIndicator(color: Color(0xffF95F62),
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
|
||||
@@ -18,4 +18,4 @@ class NavigationBloc extends Bloc<NavigationEvent, NavigationState> {
|
||||
emit(NavigationState(event.index));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -16,7 +17,6 @@ class CommonAppBar extends StatelessWidget {
|
||||
required this.isProfilePage,
|
||||
this.showCart = true,
|
||||
required this.showDivider,
|
||||
this.imageUrl,
|
||||
this.isSelectCity = false,
|
||||
});
|
||||
|
||||
@@ -24,14 +24,10 @@ class CommonAppBar extends StatelessWidget {
|
||||
final bool isProfilePage;
|
||||
final bool? showCart;
|
||||
final bool showDivider;
|
||||
final String? imageUrl;
|
||||
final bool isSelectCity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isPathIcon =
|
||||
imageUrl != null && imageUrl!.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
@@ -40,31 +36,62 @@ class CommonAppBar extends StatelessWidget {
|
||||
/// LEFT SIDE
|
||||
Row(
|
||||
children: [
|
||||
/// ✅ LOGO / PATH ICON (SIZE CONTROLLED)
|
||||
SizedBox(
|
||||
height: isPathIcon ? 40.h : 32.h, // 🔥 ONLY path icon bigger
|
||||
child: isPathIcon
|
||||
? Image.network(
|
||||
imageUrl!,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
isWhiteLogo
|
||||
? "assets/logo/logo_city_cards_white.png"
|
||||
: "assets/logo/logo_city_cards.png",
|
||||
fit: BoxFit.contain,
|
||||
/// ✅ LOGO (TAP ENABLED ONLY WHEN isSelectCity == true)
|
||||
GestureDetector(
|
||||
onTap: isSelectCity
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const CitySelectionBottomSheet(),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: FutureBuilder<String?>(
|
||||
future: LocalPreference.getSelectedCityLogo(),
|
||||
builder: (context, snapshot) {
|
||||
final String? logoPath = snapshot.data;
|
||||
final bool hasLogo =
|
||||
snapshot.hasData &&
|
||||
logoPath != null &&
|
||||
logoPath.isNotEmpty;
|
||||
|
||||
final String? fullLogoUrl = hasLogo
|
||||
? "${ApiUrls.baseUrl}$logoPath"
|
||||
: null;
|
||||
|
||||
return SizedBox(
|
||||
height: hasLogo ? 40.h : 32.h,
|
||||
child: hasLogo && fullLogoUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: fullLogoUrl,
|
||||
fit: BoxFit.contain,
|
||||
errorWidget: (context, url, error) => Image.asset(
|
||||
isWhiteLogo
|
||||
? "assets/logo/logo_city_cards_white.png"
|
||||
: "assets/logo/logo_city_cards.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
placeholder: (context, url) => Image.asset(
|
||||
isWhiteLogo
|
||||
? "assets/logo/logo_city_cards_white.png"
|
||||
: "assets/logo/logo_city_cards.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: Image.asset(
|
||||
isWhiteLogo
|
||||
? "assets/logo/logo_city_cards_white.png"
|
||||
: "assets/logo/logo_city_cards.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.asset(
|
||||
isWhiteLogo
|
||||
? "assets/logo/logo_city_cards_white.png"
|
||||
: "assets/logo/logo_city_cards.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
/// ✅ CITY DROPDOWN
|
||||
/// ✅ CITY DROPDOWN ICON (UNCHANGED)
|
||||
if (isSelectCity)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
@@ -124,32 +151,26 @@ class CommonAppBar extends StatelessWidget {
|
||||
builder: (context, state) {
|
||||
String? imagePath;
|
||||
|
||||
// ✅ Get image from profile state
|
||||
if (state is ProfileLoaded) {
|
||||
imagePath = state.profile.profileImage;
|
||||
}
|
||||
|
||||
// ✅ Build full image URL
|
||||
final String? imageUrl =
|
||||
(imagePath != null && imagePath.isNotEmpty)
|
||||
(imagePath != null && imagePath.isNotEmpty)
|
||||
? "${ApiUrls.baseUrl}$imagePath"
|
||||
: null;
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 20.r,
|
||||
backgroundColor: const Color(0xffFFDFDF),
|
||||
|
||||
// ✅ Network image only if exists
|
||||
backgroundImage:
|
||||
(imageUrl != null && imageUrl.isNotEmpty)
|
||||
(imageUrl != null && imageUrl.isNotEmpty)
|
||||
? NetworkImage(imageUrl)
|
||||
: null,
|
||||
|
||||
// ✅ Default fallback (unchanged)
|
||||
child: (imageUrl == null || imageUrl.isEmpty)
|
||||
? Image.asset(
|
||||
"assets/images/profile_default_img.png",
|
||||
)
|
||||
"assets/images/profile_default_img.png",
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
@@ -159,6 +180,7 @@ class CommonAppBar extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// DIVIDER
|
||||
if (showDivider)
|
||||
Column(
|
||||
|
||||
@@ -2,23 +2,23 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
Widget backWidget(BuildContext context, String title, Color? textColor){
|
||||
return Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Icon(Icons.arrow_back, size: 24.sp, color: textColor ?? Colors.black),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor ?? Colors.black
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.arrow_back, size: 24.sp, color: textColor ?? Colors.black),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor ?? Colors.black
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
class CommonAppText {
|
||||
static const String selectiveCard = "Selective";
|
||||
static const String selectiveCard = "Flexi";
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffFFF5F5),
|
||||
border: Border.all(color: Color(0xffFDCDCE)),
|
||||
border: Border.all(color: const Color(0xffFDCDCE)),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(24),
|
||||
topRight: Radius.circular(24),
|
||||
@@ -26,10 +26,10 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 16.w),
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 8.w),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_buildNavItem(
|
||||
context,
|
||||
@@ -49,7 +49,7 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
context,
|
||||
index: 2,
|
||||
iconPath: 'assets/icons/pass_icon.png',
|
||||
label: 'My Passes',
|
||||
label: 'My Cards',
|
||||
isActive: state.selectedIndex == 2,
|
||||
),
|
||||
_buildNavItem(
|
||||
@@ -67,45 +67,66 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildNavItem(
|
||||
BuildContext context, {
|
||||
required int index,
|
||||
required String iconPath,
|
||||
required String label,
|
||||
required bool isActive,
|
||||
}) {
|
||||
BuildContext context, {
|
||||
required int index,
|
||||
required String iconPath,
|
||||
required String label,
|
||||
required bool isActive,
|
||||
}) {
|
||||
final color = isActive
|
||||
? const Color(0xFFBB474A)
|
||||
: Color(0xFFBB474A).withOpacity(0.6);
|
||||
: const Color(0xFFBB474A).withOpacity(0.6);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
context.read<NavigationBloc>().add(NavigationTabChanged(index)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isActive)
|
||||
Container(
|
||||
child: SizedBox(
|
||||
width: 80.w,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Always reserve the same height for the indicator bar
|
||||
// so all items stay vertically aligned
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
margin: EdgeInsets.only(bottom: 4.h),
|
||||
height: 4.h,
|
||||
width: 50.w,
|
||||
width: isActive ? 50.w : 0,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
color: isActive ? color : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(2.r),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(height: 7.h),
|
||||
Image.asset(iconPath, scale: 4, color: color),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12.sp,
|
||||
fontWeight: isActive ? FontWeight.w500 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
AnimatedScale(
|
||||
scale: isActive ? 1.1 : 1.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
child: Image.asset(iconPath, scale: 4, color: color),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
lib/common_packages/custom_dash_border_painter.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DashedBorderPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final double gap;
|
||||
final double dashWidth;
|
||||
final double radius;
|
||||
|
||||
DashedBorderPainter({
|
||||
required this.color,
|
||||
this.strokeWidth = 1.5,
|
||||
this.gap = 6,
|
||||
this.dashWidth = 6,
|
||||
this.radius = 16,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final rRect = RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
Radius.circular(radius),
|
||||
);
|
||||
|
||||
final path = Path()..addRRect(rRect);
|
||||
|
||||
final dashPath = Path();
|
||||
for (final metric in path.computeMetrics()) {
|
||||
double distance = 0;
|
||||
while (distance < metric.length) {
|
||||
dashPath.addPath(
|
||||
metric.extractPath(distance, distance + dashWidth),
|
||||
Offset.zero,
|
||||
);
|
||||
distance += dashWidth + gap;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(dashPath, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -1,59 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class DashedDivider extends StatelessWidget {
|
||||
/// The divider's height extent.
|
||||
///
|
||||
/// The divider itself is always drawn as a horizontal line that is centered
|
||||
/// within the height specified by this value.
|
||||
///
|
||||
/// If this is null, then the [DividerThemeData.space] is used. If that is
|
||||
/// also null, then this defaults to 16.0.
|
||||
final double? height;
|
||||
|
||||
/// The thickness of the line drawn within the divider.
|
||||
///
|
||||
/// A divider with a [thickness] of 0.0 is always drawn as a line with a
|
||||
/// height of exactly one device pixel.
|
||||
///
|
||||
/// If this is null, then the [DividerThemeData.thickness] is used. If
|
||||
/// that is also null, then this defaults to 0.0.
|
||||
final double? thickness;
|
||||
|
||||
/// The amount of empty space to the leading edge of the divider.
|
||||
///
|
||||
/// If this is null, then the [DividerThemeData.indent] is used. If that is
|
||||
/// also null, then this defaults to 0.0.
|
||||
final double? indent;
|
||||
|
||||
/// The amount of empty space to the trailing edge of the divider.
|
||||
///
|
||||
/// If this is null, then the [DividerThemeData.endIndent] is used. If that is
|
||||
/// also null, then this defaults to 0.0.
|
||||
final double? endIndent;
|
||||
|
||||
/// The color to use when painting the line.
|
||||
///
|
||||
/// If this is null, then the [DividerThemeData.color] is used. If that is
|
||||
/// also null, then [ThemeData.dividerColor] is used.
|
||||
final Color? color;
|
||||
|
||||
/// The length of each dash in the dashed line.
|
||||
final double dashLength;
|
||||
|
||||
/// The space between each dash in the dashed line.
|
||||
final double dashSpace;
|
||||
|
||||
/// The offset along the main axis for the starting position of the dashes.
|
||||
///
|
||||
/// This value determines how far from the start the first dash will be drawn,
|
||||
/// allowing for fine-tuning the positioning of the dashed line. A positive value
|
||||
/// shifts the dashes forward, while a negative value moves them backward along
|
||||
/// the main axis.
|
||||
///
|
||||
/// The default value is 0.0, meaning the dashes start at the beginning of the line.
|
||||
final double mainAxisOffset;
|
||||
|
||||
/// If true, shows the advanced pill-style dashed divider
|
||||
final bool isAdvanced;
|
||||
|
||||
const DashedDivider({
|
||||
super.key,
|
||||
this.height,
|
||||
@@ -64,6 +23,7 @@ class DashedDivider extends StatelessWidget {
|
||||
this.dashLength = 5,
|
||||
this.dashSpace = 5,
|
||||
this.mainAxisOffset = 0.0,
|
||||
this.isAdvanced = false,
|
||||
}) : assert(height == null || height >= 0.0),
|
||||
assert(thickness == null || thickness >= 0.0),
|
||||
assert(indent == null || indent >= 0.0),
|
||||
@@ -71,6 +31,17 @@ class DashedDivider extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ── Advanced pill-style divider ──
|
||||
if (isAdvanced) {
|
||||
return _AdvancedDashedDivider(
|
||||
color: color ?? const Color(0xFFBEBEBE),
|
||||
height: height ?? 20,
|
||||
indent: indent ?? 0,
|
||||
endIndent: endIndent ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Original dashed divider ──
|
||||
final theme = DividerThemeProvider.of(context).withDefaults(
|
||||
height: height,
|
||||
thickness: thickness,
|
||||
@@ -96,6 +67,72 @@ class DashedDivider extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Advanced Pill-Style Dashed Divider
|
||||
// ─────────────────────────────────────────────
|
||||
class _AdvancedDashedDivider extends StatelessWidget {
|
||||
final Color color;
|
||||
final double height;
|
||||
final double indent;
|
||||
final double endIndent;
|
||||
|
||||
const _AdvancedDashedDivider({
|
||||
required this.color,
|
||||
required this.height,
|
||||
required this.indent,
|
||||
required this.endIndent,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(left: indent, right: endIndent),
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _PillDashedLinePainter(color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PillDashedLinePainter extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
_PillDashedLinePainter({required this.color});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
const pillWidth = 22.0;
|
||||
const pillHeight = 10.0;
|
||||
const gap = 6.0;
|
||||
const radius = pillHeight / 2;
|
||||
|
||||
final centerY = size.height / 2;
|
||||
double x = 0;
|
||||
|
||||
while (x + pillWidth <= size.width) {
|
||||
final rect = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(x, centerY - radius, pillWidth, pillHeight),
|
||||
const Radius.circular(radius),
|
||||
);
|
||||
canvas.drawRRect(rect, paint);
|
||||
x += pillWidth + gap;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Original Painter
|
||||
// ─────────────────────────────────────────────
|
||||
class DashedLinePainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double thickness;
|
||||
@@ -142,31 +179,29 @@ class DashedLinePainter extends CustomPainter {
|
||||
}
|
||||
}
|
||||
|
||||
double _getMainAxisSize(Size size) {
|
||||
return isVertical ? size.height : size.width;
|
||||
}
|
||||
double _getMainAxisSize(Size size) =>
|
||||
isVertical ? size.height : size.width;
|
||||
|
||||
double _getCrossAxisPosition(Size size) {
|
||||
return isVertical ? size.width / 2 : size.height / 2;
|
||||
}
|
||||
double _getCrossAxisPosition(Size size) =>
|
||||
isVertical ? size.width / 2 : size.height / 2;
|
||||
|
||||
Offset _calculateStartOffset(
|
||||
double crossAxisPosition, double currentPosition) {
|
||||
return isVertical
|
||||
? Offset(crossAxisPosition, currentPosition)
|
||||
: Offset(currentPosition, crossAxisPosition);
|
||||
}
|
||||
Offset _calculateStartOffset(double crossAxisPosition, double currentPosition) =>
|
||||
isVertical
|
||||
? Offset(crossAxisPosition, currentPosition)
|
||||
: Offset(currentPosition, crossAxisPosition);
|
||||
|
||||
Offset _calculateEndOffset(double crossAxisPosition, double nextDashEnd) {
|
||||
return isVertical
|
||||
? Offset(crossAxisPosition, nextDashEnd)
|
||||
: Offset(nextDashEnd, crossAxisPosition);
|
||||
}
|
||||
Offset _calculateEndOffset(double crossAxisPosition, double nextDashEnd) =>
|
||||
isVertical
|
||||
? Offset(crossAxisPosition, nextDashEnd)
|
||||
: Offset(nextDashEnd, crossAxisPosition);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Theme Provider (unchanged)
|
||||
// ─────────────────────────────────────────────
|
||||
class DividerThemeProvider {
|
||||
final DividerThemeData _dividerTheme;
|
||||
final ThemeData _theme;
|
||||
@@ -204,35 +239,20 @@ class DividerThemeProvider {
|
||||
_indent = indent ?? _indent;
|
||||
_endIndent = endIndent ?? _endIndent;
|
||||
_color = color ?? _color;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
double get width => _width ?? _dividerTheme.space ?? _defaults.space!;
|
||||
|
||||
double get height => _height ?? _dividerTheme.space ?? _defaults.space!;
|
||||
|
||||
double get thickness =>
|
||||
_thickness ?? _dividerTheme.thickness ?? _defaults.thickness!;
|
||||
|
||||
double get thickness => _thickness ?? _dividerTheme.thickness ?? _defaults.thickness!;
|
||||
double get indent => _indent ?? _dividerTheme.indent ?? _defaults.indent!;
|
||||
|
||||
double get endIndent =>
|
||||
_endIndent ?? _dividerTheme.endIndent ?? _defaults.endIndent!;
|
||||
|
||||
Color get color =>
|
||||
_color ?? _dividerTheme.color ?? _defaults.color ?? _theme.dividerColor;
|
||||
double get endIndent => _endIndent ?? _dividerTheme.endIndent ?? _defaults.endIndent!;
|
||||
Color get color => _color ?? _dividerTheme.color ?? _defaults.color ?? _theme.dividerColor;
|
||||
}
|
||||
|
||||
class _DividerDefaultsM3 extends DividerThemeData {
|
||||
const _DividerDefaultsM3(this.context)
|
||||
: super(
|
||||
space: 16,
|
||||
thickness: 1.0,
|
||||
indent: 0,
|
||||
endIndent: 0,
|
||||
);
|
||||
|
||||
: super(space: 16, thickness: 1.0, indent: 0, endIndent: 0);
|
||||
final BuildContext context;
|
||||
|
||||
@override
|
||||
@@ -241,13 +261,7 @@ class _DividerDefaultsM3 extends DividerThemeData {
|
||||
|
||||
class _DividerDefaultsM2 extends DividerThemeData {
|
||||
const _DividerDefaultsM2(this.context)
|
||||
: super(
|
||||
space: 16,
|
||||
thickness: 0,
|
||||
indent: 0,
|
||||
endIndent: 0,
|
||||
);
|
||||
|
||||
: super(space: 16, thickness: 0, indent: 0, endIndent: 0);
|
||||
final BuildContext context;
|
||||
|
||||
@override
|
||||
|
||||
@@ -6,48 +6,63 @@ class CustomFilledButton extends StatelessWidget {
|
||||
final double? width;
|
||||
final String label;
|
||||
final bool? showArrow;
|
||||
final GestureTapCallback onTap;
|
||||
final GestureTapCallback? onTap; // ✅ Made nullable
|
||||
final double? height;
|
||||
final bool isLoading; // ✅ NEW
|
||||
|
||||
CustomFilledButton({
|
||||
const CustomFilledButton({
|
||||
super.key,
|
||||
this.width = 266,
|
||||
this.width,
|
||||
required this.onTap,
|
||||
required this.label,
|
||||
this.showArrow = false,
|
||||
this.height = 42
|
||||
this.height,
|
||||
this.isLoading = false, // ✅ NEW
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
onTap: isLoading ? null : onTap, // ✅ Disabled when loading
|
||||
child: Container(
|
||||
height: height,
|
||||
width: width,
|
||||
height: height ?? 42.h,
|
||||
width: width ?? 266.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62),
|
||||
color: isLoading
|
||||
? Color(0xFFF95F62).withOpacity(0.6) // ✅ Dimmed when loading
|
||||
: Color(0xFFF95F62),
|
||||
borderRadius: BorderRadius.circular(38.r),
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
height: 20.sp,
|
||||
width: 20.sp,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(
|
||||
text: label,
|
||||
color: Colors.white,
|
||||
size: 16.sp ,
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
|
||||
if(showArrow!)
|
||||
SizedBox(width: 8,),
|
||||
if(showArrow!)
|
||||
Icon(Icons.arrow_forward_ios_rounded,size: 18.sp, color: Colors.white,)
|
||||
if (showArrow!) SizedBox(width: 8),
|
||||
if (showArrow!)
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
size: 18.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
210
lib/common_packages/custom_snackbar.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class CustomSnackbar {
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
IconData? icon,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
if (useOverlay) {
|
||||
_showOverlaySnackbar(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: backgroundColor ?? Colors.black87,
|
||||
textColor: textColor ?? Colors.white,
|
||||
icon: icon,
|
||||
duration: duration,
|
||||
);
|
||||
} else {
|
||||
_showRegularSnackbar(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: backgroundColor ?? Colors.black87,
|
||||
textColor: textColor ?? Colors.white,
|
||||
icon: icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static void _showRegularSnackbar(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
IconData? icon,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: textColor,
|
||||
size: 20.sp,
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: backgroundColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showOverlaySnackbar(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
IconData? icon,
|
||||
required Duration duration,
|
||||
}) {
|
||||
final overlay = Overlay.of(context);
|
||||
final overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 10,
|
||||
left: 20.w,
|
||||
right: 20.w,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, -20 * (1 - value)),
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: textColor,
|
||||
size: 20.sp,
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(overlayEntry);
|
||||
Future.delayed(duration, () {
|
||||
overlayEntry.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Helper methods for common use cases
|
||||
static void showSuccess(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
show(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: Colors.green,
|
||||
textColor: Colors.white,
|
||||
icon: Icons.check_circle,
|
||||
useOverlay: useOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
static void showError(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
show(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: Colors.red,
|
||||
textColor: Colors.white,
|
||||
icon: Icons.error,
|
||||
useOverlay: useOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
static void showWarning(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
show(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: Colors.orange,
|
||||
textColor: Colors.white,
|
||||
icon: Icons.warning,
|
||||
useOverlay: useOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
static void showInfo(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
show(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: Colors.blue,
|
||||
textColor: Colors.white,
|
||||
icon: Icons.info,
|
||||
useOverlay: useOverlay,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ class CustomText extends StatelessWidget {
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextAlign? textAlign;
|
||||
final Color asteriskColor; // ADD THIS
|
||||
|
||||
const CustomText({
|
||||
Key? key,
|
||||
@@ -18,20 +19,50 @@ class CustomText extends StatelessWidget {
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textAlign,
|
||||
this.asteriskColor = Colors.red, // ADD THIS
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ADD THIS BLOCK
|
||||
if (asteriskColor != null && text.contains('*')) {
|
||||
final parts = text.split('*');
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
text: parts[0],
|
||||
style: TextStyle(
|
||||
fontWeight: weight,
|
||||
color: color ?? Colors.black,
|
||||
fontSize: size,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '*',
|
||||
style: TextStyle(
|
||||
color: asteriskColor,
|
||||
fontWeight: weight,
|
||||
fontSize: size,
|
||||
),
|
||||
),
|
||||
if (parts.length > 1) TextSpan(text: parts[1]),
|
||||
],
|
||||
),
|
||||
maxLines: maxLines,
|
||||
overflow: overflow ?? TextOverflow.clip,
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
);
|
||||
}
|
||||
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.lerp(
|
||||
weight,
|
||||
FontWeight.values[
|
||||
(FontWeight.values.indexOf(weight??FontWeight.w400) + 1)
|
||||
.clamp(0, FontWeight.values.length - 1) // prevent overflow
|
||||
(FontWeight.values.indexOf(weight ?? FontWeight.w400) + 1)
|
||||
.clamp(0, FontWeight.values.length - 1)
|
||||
],
|
||||
0.5, // t: pick between 0.0 and 1.0
|
||||
0.5,
|
||||
),
|
||||
color: color,
|
||||
fontSize: size,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CustomTextField extends StatelessWidget {
|
||||
final String label;
|
||||
@@ -8,11 +9,25 @@ class CustomTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final int? maxLines;
|
||||
final bool enabled;
|
||||
final String? Function(String?)? validator; // ✅ NEW: Validator function
|
||||
final TextInputType? keyboardType; // ✅ NEW: Keyboard type
|
||||
final bool obscureText; // ✅ NEW: For password fields
|
||||
final Widget? suffixIcon; // ✅ NEW: For icons like visibility toggle
|
||||
final void Function(String)? onChanged; // ✅ NEW: OnChanged callback
|
||||
final String? Function(String?)? validator;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final Widget? suffixIcon;
|
||||
final Widget? prefixWidget;
|
||||
final void Function(String)? onChanged;
|
||||
|
||||
final int? maxLength;
|
||||
final bool numbersOnly;
|
||||
|
||||
final bool isMobileNumber;
|
||||
final bool isEmail;
|
||||
final bool onlyLetters;
|
||||
final bool noSpace;
|
||||
|
||||
final bool noSpecialCharacters;
|
||||
final bool isFirstLetterCapital;
|
||||
final int mobileLength;
|
||||
final bool isPreview;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
@@ -25,90 +40,402 @@ class CustomTextField extends StatelessWidget {
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.suffixIcon,
|
||||
this.prefixWidget,
|
||||
this.onChanged,
|
||||
this.maxLength,
|
||||
this.numbersOnly = false,
|
||||
this.isMobileNumber = false,
|
||||
this.isEmail = false,
|
||||
this.onlyLetters = false,
|
||||
this.noSpace = false,
|
||||
this.noSpecialCharacters = false,
|
||||
this.isFirstLetterCapital = false,
|
||||
this.mobileLength = 10,
|
||||
this.isPreview = false,
|
||||
});
|
||||
|
||||
void _capitalizeFirstLetter(String value) {
|
||||
if (value.isEmpty) return;
|
||||
final capitalized = value[0].toUpperCase() + value.substring(1);
|
||||
if (capitalized != value) {
|
||||
controller.value = controller.value.copyWith(
|
||||
text: capitalized,
|
||||
selection: TextSelection.collapsed(offset: capitalized.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _internalValidator(String? value) {
|
||||
if (isPreview) return null;
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter $label';
|
||||
}
|
||||
if (isEmail) {
|
||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||
if (!emailRegex.hasMatch(value.trim())) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
}
|
||||
if (isMobileNumber) {
|
||||
if (!RegExp(r'^\d+$').hasMatch(value)) {
|
||||
return 'Only numbers are allowed';
|
||||
}
|
||||
if (value.length != mobileLength) {
|
||||
return 'Mobile number must be $mobileLength digits';
|
||||
}
|
||||
}
|
||||
if (noSpace && value.contains(' ')) {
|
||||
return 'Spaces are not allowed';
|
||||
}
|
||||
if (noSpecialCharacters && !RegExp(r'^[a-zA-Z0-9\s]+$').hasMatch(value)) {
|
||||
return 'Special characters are not allowed';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<TextInputFormatter> inputFormatters = [];
|
||||
|
||||
if (isPreview) {
|
||||
inputFormatters.add(
|
||||
TextInputFormatter.withFunction((oldValue, newValue) => oldValue),
|
||||
);
|
||||
} else {
|
||||
if (isMobileNumber) {
|
||||
inputFormatters.add(FilteringTextInputFormatter.digitsOnly);
|
||||
inputFormatters.add(LengthLimitingTextInputFormatter(mobileLength));
|
||||
} else {
|
||||
if (numbersOnly) {
|
||||
inputFormatters.add(FilteringTextInputFormatter.digitsOnly);
|
||||
}
|
||||
if (onlyLetters) {
|
||||
inputFormatters.add(
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z\s]')),
|
||||
);
|
||||
}
|
||||
if (noSpecialCharacters) {
|
||||
inputFormatters.add(
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9\s]')),
|
||||
);
|
||||
}
|
||||
if (noSpace) {
|
||||
inputFormatters.add(FilteringTextInputFormatter.deny(RegExp(r'\s')));
|
||||
}
|
||||
if (maxLength != null) {
|
||||
inputFormatters.add(LengthLimitingTextInputFormatter(maxLength));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final fillColor = isPreview
|
||||
? Colors.grey.shade100
|
||||
: enabled
|
||||
? const Color(0xFFFFF5F5)
|
||||
: Colors.grey.shade200;
|
||||
|
||||
final borderColor = const Color(0xBBC83B61).withOpacity(0.4);
|
||||
|
||||
// ✅ Full radius used for normal fields
|
||||
// ✅ Only right-side radius when prefix is present (left side is the prefix container)
|
||||
final borderRadius = prefixWidget != null
|
||||
? BorderRadius.only(
|
||||
topRight: Radius.circular(8.r),
|
||||
bottomRight: Radius.circular(8.r),
|
||||
)
|
||||
: BorderRadius.circular(8.r);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h),
|
||||
padding: EdgeInsets.only(bottom: 14.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: label,
|
||||
size: 14.sp,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
SizedBox(
|
||||
height: maxLines == 1 ? 42.h : null,
|
||||
child: TextFormField( // ✅ Changed from TextField to TextFormField
|
||||
// Label
|
||||
if (label.isNotEmpty) ...[
|
||||
CustomText(text: label, size: 14.sp),
|
||||
SizedBox(height: 6.h),
|
||||
],
|
||||
|
||||
if (prefixWidget != null)
|
||||
// ✅ THE CORE FIX:
|
||||
// We split the phone field into two parts:
|
||||
// 1. The input ROW (prefix + text field) — wrapped in IntrinsicHeight
|
||||
// so both sides match height perfectly
|
||||
// 2. The error text — rendered OUTSIDE and BELOW the row
|
||||
// so IntrinsicHeight is never affected by error text height
|
||||
_PrefixFieldWithError(
|
||||
prefixWidget: prefixWidget!,
|
||||
fillColor: fillColor,
|
||||
borderColor: borderColor,
|
||||
borderRadius: borderRadius,
|
||||
maxLines: maxLines,
|
||||
obscureText: obscureText,
|
||||
enabled: isPreview ? false : enabled,
|
||||
controller: controller,
|
||||
maxLines: obscureText ? 1 : maxLines, // ✅ Password fields always single line
|
||||
enabled: enabled,
|
||||
validator: validator, // ✅ Added validator
|
||||
keyboardType: keyboardType, // ✅ Added keyboard type
|
||||
obscureText: obscureText, // ✅ Added obscure text
|
||||
onChanged: onChanged, // ✅ Added onChanged
|
||||
validator: validator ?? _internalValidator,
|
||||
keyboardType: keyboardType ?? TextInputType.phone,
|
||||
inputFormatters: inputFormatters,
|
||||
hint: hint,
|
||||
onChanged: (value) {
|
||||
if (isFirstLetterCapital) _capitalizeFirstLetter(value);
|
||||
if (onChanged != null) onChanged!(value);
|
||||
},
|
||||
suffixIcon: suffixIcon,
|
||||
)
|
||||
else
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
maxLines: obscureText ? 1 : maxLines,
|
||||
enabled: isPreview ? false : enabled,
|
||||
obscureText: obscureText,
|
||||
validator: validator ?? _internalValidator,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
keyboardType: keyboardType ??
|
||||
(isMobileNumber
|
||||
? TextInputType.phone
|
||||
: isEmail
|
||||
? TextInputType.emailAddress
|
||||
: TextInputType.name),
|
||||
inputFormatters: inputFormatters,
|
||||
onChanged: (value) {
|
||||
if (isFirstLetterCapital) _capitalizeFirstLetter(value);
|
||||
if (onChanged != null) onChanged!(value);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
counterText: "",
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF8E8E8E),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: enabled
|
||||
? const Color(0xFFFFF5F5)
|
||||
: Colors.grey.shade200,
|
||||
fillColor: fillColor,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24.w,
|
||||
vertical: maxLines != null && maxLines! > 1 ? 12.h : 0, // ✅ Better padding for multiline
|
||||
vertical: maxLines != null && maxLines! > 1 ? 12.h : 10.h,
|
||||
),
|
||||
suffixIcon: suffixIcon, // ✅ Added suffix icon
|
||||
suffixIcon: suffixIcon,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: const Color(0xBBC83B61).withOpacity(0.4),
|
||||
width: .4.w,
|
||||
),
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: borderColor, width: .4.w),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(
|
||||
color: const Color(0xFFF95F62),
|
||||
width: 1.w,
|
||||
),
|
||||
color: const Color(0xFFF95F62), width: 1.w),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.shade400,
|
||||
width: .4.w,
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: Colors.red, width: 1.w),
|
||||
),
|
||||
errorBorder: OutlineInputBorder( // ✅ NEW: Error state border
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.red,
|
||||
width: 1.w,
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: Colors.red, width: 1.5.w),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder( // ✅ NEW: Focused error state
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.red,
|
||||
width: 1.5.w,
|
||||
),
|
||||
),
|
||||
errorStyle: TextStyle( // ✅ NEW: Error text style
|
||||
errorStyle: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
color: Colors.red,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ✅ Separate StatefulWidget for the prefix + field combo.
|
||||
/// It manually manages validation state and renders error text
|
||||
/// OUTSIDE the IntrinsicHeight row — this is the key to perfect alignment.
|
||||
class _PrefixFieldWithError extends StatefulWidget {
|
||||
final Widget prefixWidget;
|
||||
final Color fillColor;
|
||||
final Color borderColor;
|
||||
final BorderRadius borderRadius;
|
||||
final int? maxLines;
|
||||
final bool obscureText;
|
||||
final bool enabled;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?)? validator;
|
||||
final TextInputType keyboardType;
|
||||
final List<TextInputFormatter> inputFormatters;
|
||||
final String hint;
|
||||
final void Function(String) onChanged;
|
||||
final Widget? suffixIcon;
|
||||
|
||||
const _PrefixFieldWithError({
|
||||
required this.prefixWidget,
|
||||
required this.fillColor,
|
||||
required this.borderColor,
|
||||
required this.borderRadius,
|
||||
required this.maxLines,
|
||||
required this.obscureText,
|
||||
required this.enabled,
|
||||
required this.controller,
|
||||
required this.validator,
|
||||
required this.keyboardType,
|
||||
required this.inputFormatters,
|
||||
required this.hint,
|
||||
required this.onChanged,
|
||||
required this.suffixIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PrefixFieldWithError> createState() => _PrefixFieldWithErrorState();
|
||||
}
|
||||
|
||||
class _PrefixFieldWithErrorState extends State<_PrefixFieldWithError> {
|
||||
String? _errorText;
|
||||
bool _hasInteracted = false;
|
||||
|
||||
void _validate(String value) {
|
||||
if (!_hasInteracted) return;
|
||||
setState(() {
|
||||
_errorText = widget.validator?.call(value);
|
||||
});
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
setState(() => _hasInteracted = true);
|
||||
_validate(value);
|
||||
widget.onChanged(value);
|
||||
}
|
||||
|
||||
// Called by Form.validate() via FormField
|
||||
String? _formValidator(String? value) {
|
||||
setState(() => _hasInteracted = true);
|
||||
final error = widget.validator?.call(value);
|
||||
// Update error text after frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _errorText = error);
|
||||
});
|
||||
return error;
|
||||
}
|
||||
|
||||
bool get _hasError => _errorText != null && _errorText!.isNotEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderColor = widget.borderColor;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ✅ IntrinsicHeight ONLY wraps the input row (prefix + field)
|
||||
// Error text is outside this, so IntrinsicHeight height is never affected by it
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Prefix container — matches field height perfectly via IntrinsicHeight
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.fillColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r),
|
||||
),
|
||||
// ✅ No right border — avoids double border line
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: _hasError ? Colors.red : borderColor,
|
||||
width: _hasError ? 1.w : 0.4.w,
|
||||
),
|
||||
bottom: BorderSide(
|
||||
color: _hasError ? Colors.red : borderColor,
|
||||
width: _hasError ? 1.w : 0.4.w,
|
||||
),
|
||||
left: BorderSide(
|
||||
color: _hasError ? Colors.red : borderColor,
|
||||
width: _hasError ? 1.w : 0.4.w,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: widget.prefixWidget,
|
||||
),
|
||||
|
||||
// Text field — takes remaining width
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: widget.controller,
|
||||
maxLines: widget.obscureText ? 1 : widget.maxLines,
|
||||
enabled: widget.enabled,
|
||||
obscureText: widget.obscureText,
|
||||
validator: _formValidator,
|
||||
// ✅ No autovalidateMode here — we handle it manually
|
||||
// so we can show error text outside the row
|
||||
autovalidateMode: AutovalidateMode.disabled,
|
||||
keyboardType: widget.keyboardType,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
onChanged: _onChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
counterText: "",
|
||||
// ✅ errorText: null always — we render error ourselves below
|
||||
errorText: null,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF8E8E8E),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: widget.fillColor,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 10.h,
|
||||
),
|
||||
suffixIcon: widget.suffixIcon,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: widget.borderRadius,
|
||||
borderSide: BorderSide(
|
||||
color: _hasError ? Colors.red : borderColor,
|
||||
width: _hasError ? 1.w : 0.4.w,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: widget.borderRadius,
|
||||
borderSide: BorderSide(
|
||||
color: _hasError
|
||||
? Colors.red
|
||||
: const Color(0xFFF95F62),
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: widget.borderRadius,
|
||||
borderSide:
|
||||
BorderSide(color: Colors.red, width: 1.w),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: widget.borderRadius,
|
||||
borderSide:
|
||||
BorderSide(color: Colors.red, width: 1.5.w),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ✅ Error text rendered OUTSIDE the IntrinsicHeight row
|
||||
// This is why the prefix box never grows when error appears
|
||||
if (_hasError) ...[
|
||||
SizedBox(height: 4.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 4.w),
|
||||
child: Text(
|
||||
_errorText!,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
color: Colors.red,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||