33 Commits

Author SHA1 Message Date
9d962b3bde first commit 2026-03-27 11:29:45 +05:30
c12464e89a Merge remote-tracking branch 'origin/raj' into Anuj
# Conflicts:
#	ios/Podfile.lock
#	ios/Runner/Info.plist
2026-03-24 17:03:07 +05:30
aeeb1c27e0 first commit 2026-03-24 16:56:07 +05:30
c06c844210 bug fixes done 2026-03-24 13:21:37 +05:30
9d27389bf2 bug fixes done 2026-03-23 19:25:32 +05:30
d1038e846e bug fixes done 2026-03-20 18:08:52 +05:30
177f891a31 added check in and Qr scaner and bookng with api and more fixes and changes 2026-03-17 17:07:14 +05:30
adc737a6af magic itinerary api intigration and more fixes 2026-03-13 11:11:53 +05:30
265bddc784 added create magic itinerary with api and more and bug fixes 2026-03-05 19:02:22 +05:30
60486e737a bug fixes 2026-02-26 15:54:57 +05:30
77aba2f1a0 added fixes of city catds word 2026-02-26 11:29:06 +05:30
06e60cfd57 my cart with postcads added and more fixes. 2026-02-26 10:20:34 +05:30
f59b14bec7 bug fixes and ui updates and my passses cart updated. 2026-02-20 18:50:28 +05:30
cbe03f21b4 pull taken from shreeyash 2026-02-17 15:24:10 +05:30
a80a0ac790 bug fixes and more 2026-02-17 15:18:45 +05:30
Shreeyash Thorat
cdfb9c74ca upload, edit postcard image with filter 2026-02-17 15:15:21 +05:30
80b724d6d4 bug fixes and more 2026-02-16 19:13:08 +05:30
0abdd2b796 validations added 2026-02-16 13:43:24 +05:30
dd1991da09 search added in my drafts and y orders 2026-02-16 12:58:17 +05:30
46906b04f4 Merge remote-tracking branch 'origin/raj' into Anuj 2026-02-13 20:06:48 +05:30
8f7a68edbc first commit 2026-02-13 20:06:04 +05:30
mystery012728
48fd7037ea snack bar bug solved 2026-02-13 18:34:00 +05:30
mystery012728
40f0ed3a52 pull taken from shree branch and conflict fixes 2026-02-13 17:13:22 +05:30
mystery012728
b08e2699e9 added my passes and more chnages 2026-02-13 15:27:14 +05:30
Shreeyash Thorat
53264619a8 postcard edit 2026-02-13 15:25:05 +05:30
mystery012728
5d08e07de3 added pass details screen new and updated create account page and more changes... 2026-02-10 19:05:42 +05:30
Shreeyash Thorat
68c3f28d76 itnerary 2026-02-10 15:05:38 +05:30
mystery012728
3a08830cce updated iternary api and updated buy pass flow 2026-02-10 13:58:58 +05:30
mystery012728
0c663bdec7 pull taken of shreeyash and conflict solved 2026-02-10 10:44:19 +05:30
mystery012728
e91d24becc pull taken of shreeyash and conflict solved 2026-02-09 10:55:36 +05:30
Shreeyash Thorat
09726eb4e6 API Integration 2026-02-06 19:34:34 +05:30
eb9ca9299e Merge remote-tracking branch 'origin/raj' into Anuj 2026-02-06 19:11:29 +05:30
e15a979c0c first commit 2026-02-06 19:11:09 +05:30
281 changed files with 25597 additions and 8299 deletions

View File

@@ -1,7 +1,7 @@
# citycards_customer
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.

View File

@@ -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"

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
assets/icons/calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
assets/icons/downlaod.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
assets/icons/love_them.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/icons/maybe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
assets/icons/no_kids.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
assets/icons/person.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
assets/icons/refresh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

BIN
assets/icons/time.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 10 KiB

BIN
assets/images/card_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View 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"
}
}]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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;
}
}

View File

@@ -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",

View File

@@ -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 {
),
),
),
),
)
],
),

View File

@@ -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),
],
),
);
}
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -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];
}

View File

@@ -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 {

View File

@@ -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,
};
}
}
}

View File

@@ -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)

View File

@@ -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,
),
),
),
],
),
),

View File

@@ -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

View File

@@ -29,4 +29,6 @@ class UpdateValidityDuration extends BuyPassEvent {
final int duration;
UpdateValidityDuration(this.duration);
}
}
class AddToCartLoading extends BuyPassEvent {}
class AddToCartDone extends BuyPassEvent {}

View File

@@ -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();
}
}

View File

@@ -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,
};
}
}

View File

@@ -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');
}
}
}
}

View File

@@ -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),
),
);
},

View File

@@ -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],
);
}
}
}

View File

@@ -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),

View File

@@ -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

View File

@@ -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();

View File

@@ -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();

View 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()));
}
}
}

View File

@@ -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 {}

View 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});
}

View File

@@ -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));
// });
// }
// }

View 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,
};
}

View 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(),
};
}
}

View File

@@ -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,
// });
// }

View File

@@ -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;
}
}
}

View 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;
}
}
}

View File

@@ -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> {
),
);
}
}
}

View File

@@ -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),
),
],
),
),
),
),
),
],
),
);
}
}

View 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,
),
),
),
],
),
),
);
}
}

View File

@@ -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,
),
],
),
);
},
);
}
}

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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,
);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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),
],
),
),
),
);

View File

@@ -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(

View File

@@ -18,4 +18,4 @@ class NavigationBloc extends Bloc<NavigationEvent, NavigationState> {
emit(NavigationState(event.index));
});
}
}
}

View File

@@ -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(

View File

@@ -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
),
),
),
],
],
),
);
}

View File

@@ -1,3 +1,3 @@
class CommonAppText {
static const String selectiveCard = "Selective";
static const String selectiveCard = "Flexi";
}

View File

@@ -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,
),
),
],
),
),
);
}
}
}

View 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;
}

View File

@@ -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

View File

@@ -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,
),
],
),
),
),
);
}
}
}

View 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,
);
}
}

View File

@@ -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,

View File

@@ -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,
),
),
),
],
],
);
}
}

Some files were not shown because too many files have changed in this diff Show More