35 Commits

Author SHA1 Message Date
Raj.Ghag
496287716a bugs solved and more fixes 2026-04-27 13:35:17 +05:30
Raj.Ghag
d0ecd48407 added pinput package for better otp enter experience 2026-04-27 12:35:46 +05:30
Raj.Ghag
092fa1215f chnages and bug fixes 2026-04-24 10:35:13 +05:30
Raj.Ghag
3ca76d0c26 send otp snackbar updated 2026-04-20 15:26:13 +05:30
Raj.Ghag
54f9a4b2ad sprint three chnagees done 2026-04-15 19:06:30 +05:30
Raj.Ghag
b37bb3bf2b apply filte delay solved and refeesh token logic updated 2026-04-01 11:50:00 +05:30
b78c83cc4a translator removed 2026-03-25 18:03:50 +05:30
c4e28decb9 horizonal rotate disable and progressbar added in create intinerary and more fixes 2026-03-25 18:00:42 +05:30
6038d450e4 horizonal rotate disable and progressbar added in create intinerary and more fixes 2026-03-25 17:59:22 +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
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
340 changed files with 45200 additions and 9561 deletions

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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

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: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 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: 141 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,6 +1,6 @@
# flutter pub run flutter_launcher_icons
flutter_launcher_icons:
image_path: "assets/logo/logo_city_cards.png"
image_path: "assets/icons/citycards_customer_logo.jpg"
android: "launcher_icon"
# image_path_android: "assets/icon/icon.png"

View File

@@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

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
@@ -21,31 +25,102 @@ PODS:
- GoogleMaps/Maps (9.4.0)
- image_picker_ios (0.0.1):
- Flutter
- open_filex (0.0.2):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- 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`)
- 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:
@@ -53,46 +128,83 @@ SPEC REPOS:
- FlutterAngle
- Google-Maps-iOS-Utils
- GoogleMaps
- 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"
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
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
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
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 */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
81D638B66EB4658C8192CA0D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 445696AB37183A7C63CB7E98 /* Pods_RunnerTests.framework */; };
94B491F6EAAA79D2947A02BD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BA7A98D7E1CD160163E28329 /* 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 */; };
B7B14C5E8DB2459D45E2AD2E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 75864C28F633B337B6CD7995 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -46,13 +46,14 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; 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; };
369614DBDD277BF9018C34BC /* 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>"; };
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>"; };
62ED1D923084D6092BECB5AC /* 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>"; };
6997591091A0E8DA4E4776AA /* 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>"; };
6BD7534B4533D500F969D46C /* 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>"; };
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>"; };
75864C28F633B337B6CD7995 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@@ -61,10 +62,9 @@
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>"; };
AB77C0F975F5B780954288AA /* 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>"; };
AE2DC54B7F4682B91B6259C6 /* 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>"; };
BA7A98D7E1CD160163E28329 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -72,7 +72,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
00C1AB7B0C8F1922F3F1AE65 /* Pods_Runner.framework in Frameworks */,
B7B14C5E8DB2459D45E2AD2E /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -80,7 +80,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
81D638B66EB4658C8192CA0D /* Pods_RunnerTests.framework in Frameworks */,
94B491F6EAAA79D2947A02BD /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -95,24 +95,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 */,
369614DBDD277BF9018C34BC /* Pods-Runner.debug.xcconfig */,
6BD7534B4533D500F969D46C /* Pods-Runner.release.xcconfig */,
6997591091A0E8DA4E4776AA /* Pods-Runner.profile.xcconfig */,
62ED1D923084D6092BECB5AC /* Pods-RunnerTests.debug.xcconfig */,
AB77C0F975F5B780954288AA /* Pods-RunnerTests.release.xcconfig */,
AE2DC54B7F4682B91B6259C6 /* Pods-RunnerTests.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -136,7 +127,7 @@
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
6D4A73F1E55857ADBD000C6A /* Pods */,
5D45FB84C63476582408C414 /* Frameworks */,
F3A521C4EE6E75D0D8A88556 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -164,6 +155,15 @@
path = Runner;
sourceTree = "<group>";
};
F3A521C4EE6E75D0D8A88556 /* Frameworks */ = {
isa = PBXGroup;
children = (
75864C28F633B337B6CD7995 /* Pods_Runner.framework */,
BA7A98D7E1CD160163E28329 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -171,7 +171,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
BC66FA7BADCD3982DC87655E /* [CP] Check Pods Manifest.lock */,
42DBF8C3008CA78F0E130EA1 /* [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 */,
46DBB6E51DCB00168B7FED03 /* [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 */,
E0E7566711BD38D2F6C5330A /* [CP] Embed Pods Frameworks */,
5BB9E9D50E854F4D876D849A /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -270,28 +270,6 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3825EC0F330C0B58EA2A8981 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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 */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -308,39 +286,7 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
41FC0A605EBADE26C841287E /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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 */ = {
42DBF8C3008CA78F0E130EA1 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -362,7 +308,29 @@
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 */ = {
46DBB6E51DCB00168B7FED03 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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;
};
5BB9E9D50E854F4D876D849A /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -379,6 +347,38 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.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";
};
E0E7566711BD38D2F6C5330A /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.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 = 62ED1D923084D6092BECB5AC /* 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 = AB77C0F975F5B780954288AA /* 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 = AE2DC54B7F4682B91B6259C6 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;

View File

@@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 B

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1,59 +1,75 @@
<?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>UIApplicationSceneManifest</key>
<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>
<key>UIApplicationSupportsMultipleScenes</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>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<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>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>

5
l10n.yaml Normal file
View File

@@ -0,0 +1,5 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false

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,12 @@ 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 '../l10n/app_localizations.dart';
import '../checkout/bloc/pass_purchase_details_bloc.dart';
import '../checkout/bloc/pass_purchase_details_event.dart';
@@ -25,49 +28,87 @@ 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'),
SnackBar(
content: Text(AppLocalizations.of(context)!.pleaseFillAllFields),
backgroundColor: Colors.red,
),
);
return;
}
if (!_isValidEmail(emailController.text)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.enterValidEmail),
backgroundColor: Colors.red,
),
);
return;
}
// ✅ UPDATED: error message now shows the selected ISD code
if (!_isValidPhone(phoneController.text)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.enterValidPhoneForIsd(_selectedIsdCode)),
backgroundColor: Colors.red,
),
);
return;
}
// Submit gift details
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,25 +119,14 @@ 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(
content: Text(state.errorMessage ?? 'Failed to submit details'),
content: Text(state.errorMessage ?? AppLocalizations.of(context)!.failedToSubmitDetails),
backgroundColor: Colors.red,
),
);
@@ -129,7 +159,7 @@ class _AddDetailsViewState extends State<AddDetailsView> {
),
SizedBox(width: 8.w),
Text(
"Add details",
AppLocalizations.of(context)!.addDetailsTitle,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
@@ -141,7 +171,7 @@ class _AddDetailsViewState extends State<AddDetailsView> {
Align(
alignment: Alignment.centerLeft,
child: CustomText(
text: "Tell us about the recipient",
text: AppLocalizations.of(context)!.aboutRecipient,
size: 18.sp,
weight: FontWeight.w500,
),
@@ -151,109 +181,103 @@ class _AddDetailsViewState extends State<AddDetailsView> {
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "First Name",
hint: "Enter recipient's first name",
label: AppLocalizations.of(context)!.firstNameLabelWithStar,
hint: AppLocalizations.of(context)!.firstNameHint,
controller: firstNameController,
onlyLetters: true,
maxLength: 50,
noSpace: true,
isFirstLetterCapital: true,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Last Name",
hint: "Enter recipient's last name",
label: AppLocalizations.of(context)!.lastNameLabelWithStar,
hint: AppLocalizations.of(context)!.lastNameHint,
controller: lastNameController,
onlyLetters: true,
maxLength: 50,
noSpace: true,
isFirstLetterCapital: true,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Email",
hint: "Enter recipient's email address",
label: AppLocalizations.of(context)!.emailLabelWithStar,
hint: AppLocalizations.of(context)!.emailHint,
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: AppLocalizations.of(context)!.phoneNumberLabelWithStar,
hint: AppLocalizations.of(context)!.phoneNumberHint,
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: InputDecoration(
hintText: AppLocalizations.of(context)!.searchCountryHint,
prefixIcon: const Icon(Icons.search),
),
),
),
),
// ✅ END of new phone field
SizedBox(height: 8.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "City",
hint: "Enter the name of the city",
label: AppLocalizations.of(context)!.cityLabelWithStar,
hint: AppLocalizations.of(context)!.cityHint,
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: AppLocalizations.of(context)!.countryLabelWithStar,
hint: AppLocalizations.of(context)!.countryHint,
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",
label: isSubmitting ? AppLocalizations.of(context)!.submittingLabel : AppLocalizations.of(context)!.continueTitle,
width: double.infinity,
),

View File

@@ -11,6 +11,39 @@ class AttractionDetailsBloc
required this.repository,
}) : super(AttractionDetailsInitial()) {
on<FetchAttractionDetails>(_onFetchAttractionDetails);
on<ToggleDescriptionExpanded>(_onToggleDescriptionExpanded);
on<UpdateGalleryIndex>(_onUpdateGalleryIndex);
on<UpdateFullScreenGalleryIndex>(_onUpdateFullScreenGalleryIndex);
}
void _onToggleDescriptionExpanded(
ToggleDescriptionExpanded event,
Emitter<AttractionDetailsState> emit,
) {
if (state is AttractionDetailsLoaded) {
final currentState = state as AttractionDetailsLoaded;
emit(currentState.copyWith(isExpanded: !currentState.isExpanded));
}
}
void _onUpdateGalleryIndex(
UpdateGalleryIndex event,
Emitter<AttractionDetailsState> emit,
) {
if (state is AttractionDetailsLoaded) {
final currentState = state as AttractionDetailsLoaded;
emit(currentState.copyWith(galleryIndex: event.index));
}
}
void _onUpdateFullScreenGalleryIndex(
UpdateFullScreenGalleryIndex event,
Emitter<AttractionDetailsState> emit,
) {
if (state is AttractionDetailsLoaded) {
final currentState = state as AttractionDetailsLoaded;
emit(currentState.copyWith(fullScreenGalleryIndex: event.index));
}
}
Future<void> _onFetchAttractionDetails(

View File

@@ -17,3 +17,19 @@ class FetchAttractionDetails extends AttractionDetailsEvent {
@override
List<Object?> get props => [attractionId];
}
class ToggleDescriptionExpanded extends AttractionDetailsEvent {}
class UpdateGalleryIndex extends AttractionDetailsEvent {
final int index;
const UpdateGalleryIndex({required this.index});
@override
List<Object?> get props => [index];
}
class UpdateFullScreenGalleryIndex extends AttractionDetailsEvent {
final int index;
const UpdateFullScreenGalleryIndex({required this.index});
@override
List<Object?> get props => [index];
}

View File

@@ -15,13 +15,33 @@ class AttractionDetailsLoading extends AttractionDetailsState {}
class AttractionDetailsLoaded extends AttractionDetailsState {
final AttractionDetailsModel attractionDetails;
final bool isExpanded;
final int galleryIndex;
final int fullScreenGalleryIndex;
const AttractionDetailsLoaded({
required this.attractionDetails,
this.isExpanded = false,
this.galleryIndex = 0,
this.fullScreenGalleryIndex = 0,
});
AttractionDetailsLoaded copyWith({
AttractionDetailsModel? attractionDetails,
bool? isExpanded,
int? galleryIndex,
int? fullScreenGalleryIndex,
}) {
return AttractionDetailsLoaded(
attractionDetails: attractionDetails ?? this.attractionDetails,
isExpanded: isExpanded ?? this.isExpanded,
galleryIndex: galleryIndex ?? this.galleryIndex,
fullScreenGalleryIndex: fullScreenGalleryIndex ?? this.fullScreenGalleryIndex,
);
}
@override
List<Object?> get props => [attractionDetails];
List<Object?> get props => [attractionDetails, isExpanded, galleryIndex, fullScreenGalleryIndex];
}
class AttractionDetailsError extends AttractionDetailsState {

View File

@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:citycards_customer/attraction_details/widgets/share_bottomsheet.dart';
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
@@ -6,7 +9,10 @@ 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 '../../l10n/app_localizations.dart';
import '../../common_packages/shimmer_animation.dart';
import '../../core/route_constants.dart';
import '../bloc/attraction_details_bloc.dart';
import '../bloc/attraction_details_event.dart';
@@ -33,7 +39,7 @@ class AttractionDetailsView extends StatelessWidget {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: CircularProgressIndicator(),
child: CircularProgressIndicator(color: Color(0xffF95F62)),
),
);
}
@@ -66,104 +72,156 @@ class AttractionDetailsView extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── White app bar above the image ───────────────────────
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: false,
),
),
SizedBox(
height: 20.h,
),
Stack(
children: [
Image.network(
coverImage,
height: 377.h,
// ── Hero image ──────────────────────────────────────
CachedNetworkImage(
imageUrl: coverImage,
height: 280.h,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
'assets/images/koh_rong_samloem_banner.png',
height: 377.h,
width: double.infinity,
fit: BoxFit.cover,
);
},
placeholder: (context, url) => SkeletonWidget(
width: double.infinity,
height: 280.h,
borderRadius: 0,
),
errorWidget: (context, url, error) => Image.asset(
'assets/images/koh_rong_samloem_banner.png',
height: 280.h,
width: double.infinity,
fit: BoxFit.cover,
),
),
// ── Bottom fade gradient ─────────────────────────────
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
height: 180.h,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Color(0xCC000000), // ~80% black at bottom
Colors.transparent,
],
),
),
),
),
// ── Top: pill-style back button (no AppBar) ──────────
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 20.w, vertical: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: true,
isProfilePage: false,
showDivider: true,
),
SizedBox(height: 10.h),
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), // 🔽 reduced
child: Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w, // 🔽 smaller
vertical: 8.h, // 🔽 smaller
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24.r), // 🔽 slightly smaller
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.10), // 🔽 lighter shadow
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.arrow_back,
size: 24.sp,
color: Colors.white,
size: 16.sp, // 🔽 smaller icon
color: Colors.black,
),
),
SizedBox(width: 8.w),
Expanded(
child: Text(
attraction.title,
SizedBox(width: 6.w), // 🔽 smaller spacing
Text(
'Back to attractions',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
fontSize: 13.sp, // 🔽 slightly smaller
fontWeight: FontWeight.w600, // ✅ bold
color: Colors.black,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
],
),
),
),
),
),
// ── Bottom-left: attraction title (smaller, over fade) ─
Positioned(
bottom: 31.h,
left: 12.w,
right: 60.w, // Add this - leaves space for share button
bottom: 48.h,
left: 14.w,
right: 60.w,
child: Text(
attraction.title,
style: TextStyle(
color: Colors.white,
fontSize: 44.sp,
fontWeight: FontWeight.w500,
height: 1.2,
fontSize: 28.sp,
fontWeight: FontWeight.w600,
height: 1.25,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// ── Bottom-right: share button ───────────────────────
Positioned(
bottom: 31.h,
right: 17.w,
bottom: 48.h,
right: 14.w,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) =>
const ShareBottomSheet(),
Share.share(
'www.google.com',
subject: AppLocalizations.of(context)!.checkThisOut,
);
},
child: Container(
height: 36.h,
width: 36.w,
height: 42.h,
width: 42.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20.r),
borderRadius: BorderRadius.circular(21.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: Icon(
@@ -178,29 +236,106 @@ class AttractionDetailsView extends StatelessWidget {
],
),
// ── Gallery Section (All Images) ──────────────────────────
if (attraction.attractionGalleries.length > 1)
Padding(
padding: EdgeInsets.only(top: 20.h, left: 16.w, right: 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gallery',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 12.h),
_GalleryStrip(
galleries: attraction.attractionGalleries,
currentIndex: state.galleryIndex,
onTap: (index) => showFullScreenGallery(
context,
attraction.attractionGalleries
.map((g) => g.filePathUrl)
.toList(),
index,
),
),
],
),
),
// About Section
Padding(
padding:
EdgeInsets.only(left: 16.w, right: 16.w, top: 20.h),
padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"About",
AppLocalizations.of(context)!.aboutTitle,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 12.32.h),
Text(
attraction.description,
style: TextStyle(
color: Color(0xFF262626),
fontWeight: FontWeight.w400,
fontSize: 14.sp,
height: 1.5,
),
LayoutBuilder(
builder: (context, constraints) {
final textSpan = TextSpan(
text: attraction.description,
style: TextStyle(
color: Color(0xFF262626),
fontWeight: FontWeight.w400,
fontSize: 14.sp,
height: 1.5,
),
);
final textPainter = TextPainter(
text: textSpan,
maxLines: 3,
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: constraints.maxWidth);
final isTextOverflowing = textPainter.didExceedMaxLines;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
attraction.description,
maxLines: state.isExpanded ? null : (isTextOverflowing ? 3 : null),
overflow: state.isExpanded ? TextOverflow.visible : (isTextOverflowing ? TextOverflow.ellipsis : TextOverflow.visible),
style: TextStyle(
color: Color(0xFF262626),
fontWeight: FontWeight.w400,
fontSize: 14.sp,
height: 1.5,
),
),
if (isTextOverflowing) ...[
SizedBox(height: 6.h),
GestureDetector(
onTap: () {
context.read<AttractionDetailsBloc>().add(ToggleDescriptionExpanded());
},
child: Text(
state.isExpanded ? "See less" : "See more",
style: TextStyle(
color: Color(0xFFF95F62), // your theme color
fontSize: 13.sp,
fontWeight: FontWeight.w500,
),
),
),
],
],
);
},
),
],
),
@@ -370,10 +505,10 @@ class AttractionDetailsView extends StatelessWidget {
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"What is included",
AppLocalizations.of(context)!.whatIsIncluded,
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w500,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 4.h),
@@ -395,17 +530,17 @@ class AttractionDetailsView extends StatelessWidget {
),
SizedBox(height: 30.h),
// Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
// SizedBox(height: 30.h),
Text(
"Exact Location",
AppLocalizations.of(context)!.exactLocation,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 8.h),
CustomText(
text: "View the location on map",
text: AppLocalizations.of(context)!.viewOnMap,
size: 12.sp,
color: Colors.black.withOpacity(.6),
),
@@ -465,28 +600,29 @@ class AttractionDetailsView extends StatelessWidget {
color: Colors.black.withOpacity(0.6),
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"People frequently ask",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
if (attraction.attractionFaqs.isNotEmpty) ...[
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
AppLocalizations.of(context)!.peopleFrequentlyAsk,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(height: 15.h),
Column(
children: attraction.attractionFaqs.map((faq) {
return Padding(
padding: EdgeInsets.only(bottom: 15.h),
child: faqBox(
title: faq.faqQuestion,
desc: faq.faqAnswer,
),
);
}).toList(),
),
SizedBox(height: 15.h),
Column(
children: attraction.attractionFaqs.map((faq) {
return Padding(
padding: EdgeInsets.only(bottom: 15.h),
child: faqBox(
title: faq.faqQuestion,
desc: faq.faqAnswer,
),
);
}).toList(),
),
],
],
),
),
@@ -501,7 +637,7 @@ class AttractionDetailsView extends StatelessWidget {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Text("Something went wrong"),
child: Text(AppLocalizations.of(context)!.somethingWentWrong),
),
);
},
@@ -592,4 +728,276 @@ class AttractionDetailsView extends StatelessWidget {
),
);
}
// ── Full Screen Swipeable Gallery Viewer ────────────────────────────
void showFullScreenGallery(BuildContext context, List<String> imageUrls, int initialIndex) {
final bloc = context.read<AttractionDetailsBloc>();
bloc.add(UpdateFullScreenGalleryIndex(index: initialIndex));
showDialog(
context: context,
barrierColor: Colors.black,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: _FullScreenGallery(
imageUrls: imageUrls,
initialIndex: initialIndex,
),
),
);
}
}
// ── Auto-scroll Gallery Strip with Dot Indicators ───────────────────
class _GalleryStrip extends StatefulWidget {
final List galleries;
final int currentIndex;
final void Function(int index) onTap;
const _GalleryStrip({required this.galleries, required this.currentIndex, required this.onTap});
@override
State<_GalleryStrip> createState() => _GalleryStripState();
}
class _GalleryStripState extends State<_GalleryStrip> {
late final PageController _pageController;
Timer? _timer;
late int _currentPage;
@override
void initState() {
super.initState();
// Start at a high number divisible by length to allow infinite scrolling in both directions
int initialPage = widget.galleries.isNotEmpty ? widget.galleries.length * 1000 : 0;
_currentPage = initialPage;
_pageController = PageController(
viewportFraction: 0.38, // shows partial next/prev image
initialPage: initialPage,
);
_startAutoScroll();
}
void _startAutoScroll() {
_timer = Timer.periodic(const Duration(seconds: 3), (_) {
if (!mounted) return;
_pageController.nextPage(
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
});
}
@override
void dispose() {
_timer?.cancel();
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.galleries.isEmpty) return const SizedBox.shrink();
return Column(
children: [
SizedBox(
height: 120.h,
child: PageView.builder(
controller: _pageController,
// No itemCount to allow infinite scrolling
onPageChanged: (i) {
_currentPage = i;
context.read<AttractionDetailsBloc>().add(UpdateGalleryIndex(index: i % widget.galleries.length));
},
itemBuilder: (context, index) {
final realIndex = index % widget.galleries.length;
final gallery = widget.galleries[realIndex];
return Padding(
padding: EdgeInsets.only(right: 12.w),
child: GestureDetector(
onTap: () => widget.onTap(realIndex),
child: ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: CachedNetworkImage(
imageUrl: gallery.filePathUrl,
width: 120.w,
height: 120.h,
fit: BoxFit.cover,
placeholder: (context, url) => SkeletonWidget(
width: 120.w,
height: 120.h,
borderRadius: 12.r,
),
errorWidget: (context, url, error) => Container(
width: 120.w,
height: 120.h,
color: Colors.grey[300],
child: const Icon(Icons.broken_image, color: Colors.grey),
),
),
),
),
);
},
),
),
SizedBox(height: 10.h),
// Dot indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.galleries.length, (index) {
final isActive = index == widget.currentIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: EdgeInsets.symmetric(horizontal: 3.w),
width: isActive ? 18.w : 6.w,
height: 6.h,
decoration: BoxDecoration(
color: isActive
? const Color(0xFFF95F62)
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4.r),
),
);
}),
),
],
);
}
}
// ── Full Screen Swipeable Gallery ───────────────────────────────────
class _FullScreenGallery extends StatefulWidget {
final List<String> imageUrls;
final int initialIndex;
const _FullScreenGallery({
required this.imageUrls,
required this.initialIndex,
});
@override
State<_FullScreenGallery> createState() => _FullScreenGalleryState();
}
class _FullScreenGalleryState extends State<_FullScreenGallery> {
late final PageController _controller;
@override
void initState() {
super.initState();
_controller = PageController(initialPage: widget.initialIndex);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AttractionDetailsBloc, AttractionDetailsState>(
builder: (context, state) {
int currentIndex = widget.initialIndex;
if (state is AttractionDetailsLoaded) {
currentIndex = state.fullScreenGalleryIndex;
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// Swipeable images
PageView.builder(
controller: _controller,
itemCount: widget.imageUrls.length,
onPageChanged: (i) => context.read<AttractionDetailsBloc>().add(UpdateFullScreenGalleryIndex(index: i)),
itemBuilder: (context, index) {
return InteractiveViewer(
child: Center(
child: CachedNetworkImage(
imageUrl: widget.imageUrls[index],
fit: BoxFit.contain,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(color: Color(0xffF95F62)),
),
errorWidget: (context, url, error) => const Icon(
Icons.broken_image,
color: Colors.white,
size: 50,
),
),
),
);
},
),
// Close button + counter
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
padding: EdgeInsets.all(8.w),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.25),
borderRadius: BorderRadius.circular(20.r),
),
child: Icon(Icons.close, color: Colors.white, size: 24.sp),
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
'${currentIndex + 1} / ${widget.imageUrls.length}',
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
),
],
),
),
),
),
// Bottom dot indicators
Positioned(
bottom: 30.h,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.imageUrls.length, (index) {
final isActive = index == currentIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: EdgeInsets.symmetric(horizontal: 3.w),
width: isActive ? 18.w : 6.w,
height: 6.h,
decoration: BoxDecoration(
color: isActive ? const Color(0xFFF95F62) : Colors.white54,
borderRadius: BorderRadius.circular(4.r),
),
);
}),
),
),
],
),
);
},
);
}
}

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

@@ -3,6 +3,7 @@ import 'package:citycards_customer/common_packages/back_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../l10n/app_localizations.dart';
import '../../common_packages/custom_search_field.dart';
import '../blocs/attractions_bloc.dart';
@@ -37,117 +38,125 @@ class AttractionsPage extends StatelessWidget {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App bar
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
backWidget(context, "Your Attraction", Colors.black),
const SizedBox(height: 20),
// 🔍 Search field (UI kept, logic disabled)
CommonSearchField(
hint: "Search attractions...",
hintColor: Colors.grey.shade500,
onChanged: (value) {
// ❌ Search logic intentionally disabled
// UI only, no API call
},
),
const SizedBox(height: 16),
// 🏖️ Category chips row - DYNAMIC
if (state is AttractionsLoaded)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: state.categories
.map(
(category) => buildCategoryChip(
category.categoryName ?? '',
isSelected: state.selectedCategoryId == category.id,
onTap: () {
bloc.add(
FetchAttractionsByCategory(
categoryXid: category.id,
),
);
},
),
)
.toList(),
),
child: RefreshIndicator(
color: Color(0xffF95F62),
onRefresh: () async {
bloc.add(
const FetchAttractionsByCategory(),
);
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App bar
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
// else
// // Show placeholder chips while loading
// SingleChildScrollView(
// scrollDirection: Axis.horizontal,
// child: Row(
// children: [
// buildCategoryChip("Beach", isSelected: true, onTap: () {}),
// buildCategoryChip("Hike", isSelected: false, onTap: () {}),
// buildCategoryChip("Adventure", isSelected: false, onTap: () {}),
// buildCategoryChip("Best in Summer", isSelected: false, onTap: () {}),
// ],
// ),
// ),
backWidget(context, AppLocalizations.of(context)!.yourAttractionTitle, Colors.black),
const SizedBox(height: 20),
const SizedBox(height: 10),
// 🔍 Search field (UI kept, logic disabled)
CommonSearchField(
hint: AppLocalizations.of(context)!.searchAttractionsHint,
hintColor: Colors.grey.shade500,
onChanged: (value) {
bloc.add(SearchAttractions(value));
},
),
// 🙏️ Attraction list
if (state is AttractionsLoading)
const Center(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: CircularProgressIndicator(),
),
)
else if (state is AttractionsLoaded)
state.attractions.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Text(
"No attractions found",
style: TextStyle(
color: Colors.grey,
fontSize: 14.sp,
),
const SizedBox(height: 16),
// 🏖️ Category chips row - DYNAMIC
if (state is AttractionsLoaded)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: state.categories
.map(
(category) => buildCategoryChip(
category.categoryName ?? '',
isSelected: state.selectedCategoryId == category.id,
onTap: () {
bloc.add(
FetchAttractionsByCategory(
categoryXid: category.id,
),
);
},
),
)
.toList(),
),
),
)
: Column(
children: state.attractions
.map(
(attraction) => AttractionCard(
attraction: attraction,
// else
// // Show placeholder chips while loading
// SingleChildScrollView(
// scrollDirection: Axis.horizontal,
// child: Row(
// children: [
// buildCategoryChip("Beach", isSelected: true, onTap: () {}),
// buildCategoryChip("Hike", isSelected: false, onTap: () {}),
// buildCategoryChip("Adventure", isSelected: false, onTap: () {}),
// buildCategoryChip("Best in Summer", isSelected: false, onTap: () {}),
// ],
// ),
// ),
const SizedBox(height: 10),
// 🙏️ Attraction list
if (state is AttractionsLoading)
const Center(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: CircularProgressIndicator(color: Color(0xffF95F62)),
),
)
.toList(),
)
else if (state is AttractionsError)
Center(
else if (state is AttractionsLoaded)
state.attractions.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Text(
state.message,
AppLocalizations.of(context)!.noAttractionsFound,
style: TextStyle(
color: Colors.red,
color: Colors.grey,
fontSize: 14.sp,
),
),
),
)
else
const SizedBox(),
],
: Column(
children: state.attractions
.map(
(attraction) => AttractionCard(
attraction: attraction,
),
)
.toList(),
)
else if (state is AttractionsError)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Text(
state.message,
style: TextStyle(
color: Colors.red,
fontSize: 14.sp,
),
),
),
)
else
const SizedBox(),
],
),
),
),
),

View File

@@ -1,7 +1,10 @@
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';
import '../../common_packages/common_app_texts.dart';
import '../../common_packages/shimmer_animation.dart';
import '../../l10n/app_localizations.dart';
import '../../core/route_constants.dart';
import '../models/attraction_model.dart';
@@ -24,7 +27,7 @@ class AttractionCard extends StatelessWidget {
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.attractionDetails,
arguments: attraction,
arguments: attraction.id,
);
},
child: Container(
@@ -42,12 +45,17 @@ 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) => SkeletonWidget(
width: 94.w,
height: 94.h,
borderRadius: 8.r,
),
errorWidget: (_, __, ___) => _imageFallback(),
)
: _imageFallback(),
),
@@ -61,22 +69,26 @@ class AttractionCard extends StatelessWidget {
children: [
Text(
attraction.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 6.h),
Text(
attraction.address,
style: GoogleFonts.poppins(
fontSize: 12.sp,
fontWeight: FontWeight.w400,
color: const Color(0xff464646),
),
),
// SizedBox(height: 6.h),
//
// Text(
// attraction.address,
// maxLines: 1,
// overflow: TextOverflow.ellipsis,
// style: GoogleFonts.poppins(
// fontSize: 12.sp,
// fontWeight: FontWeight.w400,
// color: const Color(0xff464646),
// ),
// ),
SizedBox(height: 6.h),
@@ -84,7 +96,7 @@ class AttractionCard extends StatelessWidget {
TextSpan(
children: [
TextSpan(
text: "from \$${attraction.ticketPriceAdult}",
text: "\$${attraction.ticketPriceAdult}",
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
@@ -92,7 +104,7 @@ class AttractionCard extends StatelessWidget {
),
),
TextSpan(
text: "/person",
text: AppLocalizations.of(context)!.perPersonSuffix,
style: TextStyle(
fontSize: 10.sp,
color: Colors.black,
@@ -104,10 +116,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 +155,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

@@ -7,11 +7,13 @@ import 'package:citycards_customer/core/route_constants.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../common_packages/back_widget.dart';
import '../../networkApiServices/api_urls.dart';
import '../bloc/buy_pass_bloc.dart';
import '../bloc/buy_pass_event.dart';
import '../bloc/buy_pass_state.dart';
import '../repository/buy_pass_repository.dart';
import '../../l10n/app_localizations.dart';
class BuyPassView extends StatelessWidget {
const BuyPassView({super.key});
@@ -19,16 +21,34 @@ class BuyPassView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => BuyPassBloc(repository: BuyPassRepository())
..add(FetchBuyPassData()),
create: (context) =>
BuyPassBloc(repository: BuyPassRepository())..add(FetchBuyPassData()),
child: const BuyPassContent(),
);
}
}
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(
@@ -38,9 +58,7 @@ class BuyPassContent extends StatelessWidget {
builder: (context, state) {
if (state is BuyPassLoading) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFFF95F62),
),
child: CircularProgressIndicator(color: Color(0xFFF95F62)),
);
}
@@ -52,7 +70,7 @@ class BuyPassContent extends StatelessWidget {
Icon(Icons.error_outline, size: 60.sp, color: Colors.red),
SizedBox(height: 16.h),
CustomText(
text: "Error loading data",
text: AppLocalizations.of(context)!.errorLoadingDataTitle,
size: 16.sp,
color: Colors.red,
),
@@ -67,7 +85,9 @@ class BuyPassContent extends StatelessWidget {
onPressed: () {
context.read<BuyPassBloc>().add(FetchBuyPassData());
},
child: const Text("Retry"),
child: Text(
AppLocalizations.of(context)!.retryButtonLabel,
),
),
],
),
@@ -91,77 +111,86 @@ class BuyPassContent extends StatelessWidget {
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
child: Row(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: backWidget(
context,
AppLocalizations.of(context)!.buyACardTitle,
Colors.black,
),
),
SizedBox(height: 20.h),
// Pass Cards Horizontal List — with next-card peek + scroll hint
SizedBox(
height: 140.h,
child: PageView.builder(
controller: PageController(viewportFraction: 0.85),
clipBehavior: Clip.none,
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,
),
);
},
),
),
// "Scroll to reveal more" hint — only visible when there are multiple cards
if (data.cards.length > 1) ...[
SizedBox(height: 14.h),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: const Icon(Icons.arrow_back),
Icon(
Icons.arrow_forward,
size: 18.sp,
color: const Color(0xFFF95F62),
),
SizedBox(width: 6.w),
Text(
'Scroll to reveal more',
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFFF95F62),
fontWeight: FontWeight.w500,
),
),
SizedBox(width: 8.w),
CustomText(text: "Buy a Pass", 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,
),
),
);
},
),
),
),
),
SizedBox(height: 30.h),
SizedBox(height: 16.h),
// Payment Card
// ✅ UPDATED PAYMENT CARD SECTION IN buy_pass_view.dart
// Replace the existing PaymentCard widget (around line 154) with this:
// Replace the existing PaymentCard widget (around line 154) with this:
Center(
child: PaymentCard(
city: data.city.name,
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,14 +238,21 @@ class BuyPassContent extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Card Offers", size: 18.sp),
CustomText(
text: AppLocalizations.of(
context,
)!.memberPrivilegesTitle,
size: 18.sp,
),
GestureDetector(
onTap: () {
Navigator.pushNamed(
context, RouteConstants.searchOffer);
context,
RouteConstants.searchOffer,
);
},
child: CustomText(
text: "View All",
text: AppLocalizations.of(context)!.viewAll,
size: 14.sp,
color: Color(0xFFFF5757),
),
@@ -233,12 +269,13 @@ class BuyPassContent extends StatelessWidget {
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.w,
mainAxisSpacing: 22.h,
childAspectRatio: 0.65,
),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.w,
mainAxisSpacing: 22.h,
childAspectRatio: 0.65,
),
itemCount: selectedCard.offers.length > 2
? 2
: selectedCard.offers.length,
@@ -246,12 +283,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,
@@ -259,7 +296,9 @@ class BuyPassContent extends StatelessWidget {
),
decoration: BoxDecoration(
border: Border.all(
color: const Color(0xFFF95F62).withOpacity(.24),
color: const Color(
0xFFF95F62,
).withOpacity(.24),
),
borderRadius: BorderRadius.circular(12.sp),
),
@@ -269,62 +308,75 @@ class BuyPassContent extends StatelessWidget {
/// Image
ClipRRect(
borderRadius: BorderRadius.circular(8.sp),
child: offer.mobileBannerImage != null &&
offer.mobileBannerImage!.isNotEmpty
child:
offer.mobileBannerImage != null &&
offer
.mobileBannerImage!
.isNotEmpty
? Image.network(
'${ApiUrls.baseUrl}/${offer.mobileBannerImage}',
width: double.infinity,
height: 120.5.h,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: double.infinity,
height: 120.5.h,
color: const Color(0xFFFEE7E7),
child: Icon(
Icons.local_offer,
size: 40.sp,
color:
const Color(0xFFF95F62).withOpacity(.6),
),
);
},
loadingBuilder:
(context, child, loadingProgress) {
if (loadingProgress == null) return child;
'${ApiUrls.baseUrl}/${offer.mobileBannerImage}',
width: double.infinity,
height: 120.5.h,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) {
return Container(
width: double.infinity,
height: 120.5.h,
color: const Color(
0xFFFEE7E7,
),
child: Icon(
Icons.local_offer,
size: 40.sp,
color: const Color(
0xFFF95F62,
).withOpacity(.6),
),
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null)
return child;
return Container(
width: double.infinity,
height: 120.5.h,
color: const Color(0xFFFEE7E7),
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: const Color(0xFFF95F62),
value: loadingProgress
.expectedTotalBytes !=
null
? loadingProgress
.cumulativeBytesLoaded /
loadingProgress
.expectedTotalBytes!
: null,
return Container(
width: double.infinity,
height: 120.5.h,
color: const Color(
0xFFFEE7E7,
),
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: const Color(
0xFFF95F62,
),
value:
loadingProgress
.expectedTotalBytes !=
null
? loadingProgress
.cumulativeBytesLoaded /
loadingProgress
.expectedTotalBytes!
: null,
),
),
);
},
)
: Container(
width: double.infinity,
height: 120.5.h,
color: const Color(0xFFFEE7E7),
child: Icon(
Icons.local_offer,
size: 40.sp,
color: const Color(
0xFFF95F62,
).withOpacity(.6),
),
),
);
},
)
: Container(
width: double.infinity,
height: 120.5.h,
color: const Color(0xFFFEE7E7),
child: Icon(
Icons.local_offer,
size: 40.sp,
color:
const Color(0xFFF95F62).withOpacity(.6),
),
),
),
SizedBox(height: 8.h),
@@ -341,10 +393,10 @@ class BuyPassContent extends StatelessWidget {
/// Offer Code
CustomText(
text: offer.description??"N/A",
text: offer.description ?? "N/A",
color: Colors.black.withOpacity(.6),
size: 12.sp,
maxLines: 2,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
@@ -359,7 +411,7 @@ class BuyPassContent extends StatelessWidget {
height: 100.h,
alignment: Alignment.center,
child: CustomText(
text: "No offers available",
text: AppLocalizations.of(context)!.noOffersAvailable,
size: 14.sp,
color: Colors.grey,
),
@@ -376,7 +428,11 @@ class BuyPassContent extends StatelessWidget {
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
child: CustomText(
text: "Available Attractions", size: 18.sp),
text: AppLocalizations.of(
context,
)!.availableAttractionsTitle,
size: 18.sp,
),
),
SizedBox(height: 12.h),
@@ -397,45 +453,72 @@ class BuyPassContent extends StatelessWidget {
width: 104.w,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8.r),
borderRadius: BorderRadius.circular(
8.r,
),
),
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),
child: attraction.thumbnail != null &&
attraction.thumbnail!.isNotEmpty
? Image.network(
attraction.thumbnail!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.location_on,
size: 40.sp,
color: Colors.grey[400],
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: SizedBox(
width: 20.w,
height: 20.w,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
},
)
: Icon(
Icons.location_on,
size: 40.sp,
color: Colors.grey[400],
borderRadius: BorderRadius.circular(
8.r,
),
child:
attraction.thumbnail != null &&
attraction
.thumbnail!
.isNotEmpty
? Image.network(
attraction.thumbnail!,
fit: BoxFit.cover,
errorBuilder:
(
context,
error,
stackTrace,
) {
return Icon(
Icons.location_on,
size: 40.sp,
color:
Colors.grey[400],
);
},
loadingBuilder:
(
context,
child,
loadingProgress,
) {
if (loadingProgress ==
null)
return child;
return Center(
child: SizedBox(
width: 20.w,
height: 20.w,
child:
CircularProgressIndicator(
color: Color(
0xffF95F62,
),
strokeWidth:
2,
),
),
);
},
)
: Icon(
Icons.location_on,
size: 40.sp,
color: Colors.grey[400],
),
),
),
),
@@ -461,7 +544,9 @@ class BuyPassContent extends StatelessWidget {
height: 100.h,
alignment: Alignment.center,
child: CustomText(
text: "No attractions available",
text: AppLocalizations.of(
context,
)!.noAttractionsAvailable,
size: 14.sp,
color: Colors.grey,
),
@@ -478,7 +563,7 @@ class BuyPassContent extends StatelessWidget {
child: Align(
alignment: Alignment.center,
child: CustomText(
text: "View All",
text: AppLocalizations.of(context)!.viewAll,
size: 12.sp,
color: Color(0xFFF95F62),
),
@@ -496,4 +581,4 @@ class BuyPassContent extends StatelessWidget {
),
);
}
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../common_packages/common_app_texts.dart';
import '../../l10n/app_localizations.dart';
class FeatureTable extends StatelessWidget {
const FeatureTable({super.key});
@@ -9,15 +9,15 @@ class FeatureTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
final features = [
FeatureModel('Access to attractions', true, true),
FeatureModel('Entry to attractions', true, true),
FeatureModel('Access to experiences', true, true),
FeatureModel('Entry to sites', false, true),
FeatureModel('Access to venues', true, true),
FeatureModel('Entry to events', true, true),
FeatureModel('Access to experiences', false, true),
FeatureModel('Access to Itinerary creation', false, true),
FeatureModel('Access to postcard creation', false, true),
FeatureModel(AppLocalizations.of(context)!.featureAccessToAttractions, true, true),
FeatureModel(AppLocalizations.of(context)!.featureEntryToAttractions, true, true),
FeatureModel(AppLocalizations.of(context)!.featureAccessToExperiences, true, true),
FeatureModel(AppLocalizations.of(context)!.featureEntryToSites, false, true),
FeatureModel(AppLocalizations.of(context)!.featureAccessToVenues, true, true),
FeatureModel(AppLocalizations.of(context)!.featureEntryToEvents, true, true),
FeatureModel(AppLocalizations.of(context)!.featureAccessToExperiences, false, true),
FeatureModel(AppLocalizations.of(context)!.featureAccessToItineraryCreation, false, true),
FeatureModel(AppLocalizations.of(context)!.featureAccessToPostcardCreation, false, true),
];
return Center(
@@ -44,7 +44,7 @@ class FeatureTable extends StatelessWidget {
},
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: [
_buildHeaderRow(),
_buildHeaderRow(context),
...features.map(_buildFeatureRow).toList(),
],
),
@@ -54,13 +54,13 @@ class FeatureTable extends StatelessWidget {
}
// HEADER ROW
TableRow _buildHeaderRow() {
TableRow _buildHeaderRow(BuildContext context) {
return TableRow(
children: [
Padding(
padding: EdgeInsets.only(bottom: 12.h),
child: Text(
'Features',
AppLocalizations.of(context)!.featuresTitle,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15.sp,
@@ -68,7 +68,7 @@ class FeatureTable extends StatelessWidget {
),
),
_buildHeaderText(CommonAppText.selectiveCard),
_buildHeaderText('Unlimited'),
_buildHeaderText(AppLocalizations.of(context)!.unlimitedTitle),
],
);
}

View File

@@ -1,11 +1,11 @@
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../l10n/app_localizations.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,142 @@ 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(
AppLocalizations.of(context)!.fromPrefix,
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(
AppLocalizations.of(context)!.perAdultSuffix,
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(
AppLocalizations.of(context)!.andPrefix,
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(
AppLocalizations.of(context)!.perChildSuffix,
style: TextStyle(
color: Colors.black.withOpacity(0.8),
fontSize: 11.sp,
fontWeight: FontWeight.w400,
),
),
],
),
/// Description
CustomText(
text: description ?? AppLocalizations.of(context)!.diveIntoSelection,
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 +196,7 @@ class PassCardView extends StatelessWidget {
);
}
/// -------- FALLBACK ICON --------
/// FALLBACK ICON
Widget _fallbackIcon() {
return Icon(
Icons.card_travel,
@@ -202,4 +204,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 {
import '../repository/buy_pass_repository.dart';
import '../../l10n/app_localizations.dart';
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,44 +100,44 @@ 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(AppLocalizations.of(context)!.noOfAdultsLabel, widget.adults, widget.onAdultChanged, context, minValue: 1),
SizedBox(height: 10.h),
_buildCounterRow("No. of Children", children, onChildChanged),
_buildCounterRow(AppLocalizations.of(context)!.noOfChildrenLabel, widget.children, widget.onChildChanged, context),
SizedBox(height: 10.h),
if (isUnlimitedCard)
_buildDropdownRow(
label: "No. of Days",
value: selectedValue,
onChanged: onValidityChanged,
label: AppLocalizations.of(context)!.noOfDaysLabel,
value: widget.selectedValue,
onChanged: widget.onValidityChanged,
)
else if (isSelectivePass)
_buildDropdownRow(
label: "No. of Attractions",
value: selectedValue,
onChanged: onValidityChanged,
label: AppLocalizations.of(context)!.noOfAttractionsLabel,
value: widget.selectedValue,
onChanged: widget.onValidityChanged,
),
Divider(height: 30.h, thickness: 1),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(
text: "You Pay",
text: AppLocalizations.of(context)!.youPayLabel,
size: 16.sp,
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,112 @@ 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) {
String errorMessage = e.toString().replaceFirst('Exception: ', '');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
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 ? AppLocalizations.of(context)!.pleaseWaitLabel : AppLocalizations.of(context)!.proceedToPayLabel,
);
},
label: "Proceed to Pay",
),
],
),
@@ -243,8 +264,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 +325,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 +335,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
? AppLocalizations.of(context)!.atLeastOneAdultRequired
: AppLocalizations.of(context)!.cannotGoBelowZero,
),
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();

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