Compare commits
3 Commits
raj
...
8f7a68edbc
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f7a68edbc | |||
| eb9ca9299e | |||
| e15a979c0c |
@@ -1,7 +1,7 @@
|
||||
# citycards_customer
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
<application
|
||||
android:label="CityCard Customer"
|
||||
android:name="${applicationName}"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 991 B |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 749 KiB |
|
Before Width: | Height: | Size: 53 KiB |
@@ -1,141 +0,0 @@
|
||||
[{
|
||||
"version": "1.0",
|
||||
"image": {
|
||||
"name": "frames/frame002.png",
|
||||
"baseName": "frame002.png",
|
||||
"permissions": 664,
|
||||
"format": "PNG",
|
||||
"formatDescription": "Portable Network Graphics",
|
||||
"mimeType": "image/png",
|
||||
"class": "DirectClass",
|
||||
"geometry": {
|
||||
"width": 1868,
|
||||
"height": 3840,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"resolution": {
|
||||
"x": 370753,
|
||||
"y": 370798
|
||||
},
|
||||
"printSize": {
|
||||
"x": 0.00503839,
|
||||
"y": 0.010356
|
||||
},
|
||||
"units": "Undefined",
|
||||
"type": "TrueColor",
|
||||
"endianness": "Undefined",
|
||||
"colorspace": "sRGB",
|
||||
"depth": 8,
|
||||
"baseDepth": 8,
|
||||
"channelDepth": {
|
||||
"red": 8,
|
||||
"green": 8,
|
||||
"blue": 1
|
||||
},
|
||||
"pixels": 7173120,
|
||||
"imageStatistics": {
|
||||
"Overall": {
|
||||
"min": 67,
|
||||
"max": 255,
|
||||
"mean": 142.829,
|
||||
"median": 140,
|
||||
"standardDeviation": 17.1849,
|
||||
"kurtosis": 37.2771,
|
||||
"skewness": 4.24387,
|
||||
"entropy": 0.291301
|
||||
}
|
||||
},
|
||||
"channelStatistics": {
|
||||
"red": {
|
||||
"min": 174,
|
||||
"max": 255,
|
||||
"mean": 237.888,
|
||||
"median": 238,
|
||||
"standardDeviation": 2.65253,
|
||||
"kurtosis": 41.5763,
|
||||
"skewness": 0.61346,
|
||||
"entropy": 0.338084
|
||||
},
|
||||
"green": {
|
||||
"min": 73,
|
||||
"max": 255,
|
||||
"mean": 94.2729,
|
||||
"median": 90,
|
||||
"standardDeviation": 24.5069,
|
||||
"kurtosis": 35.19,
|
||||
"skewness": 6.06676,
|
||||
"entropy": 0.237928
|
||||
},
|
||||
"blue": {
|
||||
"min": 67,
|
||||
"max": 255,
|
||||
"mean": 96.325,
|
||||
"median": 92,
|
||||
"standardDeviation": 24.3954,
|
||||
"kurtosis": 35.0649,
|
||||
"skewness": 6.05138,
|
||||
"entropy": 0.297891
|
||||
}
|
||||
},
|
||||
"renderingIntent": "Perceptual",
|
||||
"gamma": 0.454545,
|
||||
"chromaticity": {
|
||||
"redPrimary": {
|
||||
"x": 0.64,
|
||||
"y": 0.33
|
||||
},
|
||||
"greenPrimary": {
|
||||
"x": 0.3,
|
||||
"y": 0.6
|
||||
},
|
||||
"bluePrimary": {
|
||||
"x": 0.15,
|
||||
"y": 0.06
|
||||
},
|
||||
"whitePrimary": {
|
||||
"x": 0.3127,
|
||||
"y": 0.329
|
||||
}
|
||||
},
|
||||
"matteColor": "#BDBDBDBDBDBD",
|
||||
"backgroundColor": "#FFFFFFFFFFFF",
|
||||
"borderColor": "#DFDFDFDFDFDF",
|
||||
"transparentColor": "#000000000000",
|
||||
"interlace": "None",
|
||||
"intensity": "Undefined",
|
||||
"compose": "Over",
|
||||
"pageGeometry": {
|
||||
"width": 1868,
|
||||
"height": 3840,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"dispose": "Undefined",
|
||||
"iterations": 0,
|
||||
"scene": 1,
|
||||
"scenes": 2,
|
||||
"compression": "Zip",
|
||||
"orientation": "Undefined",
|
||||
"properties": {
|
||||
"date:create": "2026-02-18T13:36:29+00:00",
|
||||
"date:modify": "2026-02-18T13:36:29+00:00",
|
||||
"date:timestamp": "2026-02-18T13:36:29+00:00",
|
||||
"png:IHDR.bit-depth-orig": "8",
|
||||
"png:IHDR.bit_depth": "8",
|
||||
"png:IHDR.color-type-orig": "2",
|
||||
"png:IHDR.color_type": "2 (Truecolor)",
|
||||
"png:IHDR.interlace_method": "0 (Not interlaced)",
|
||||
"png:IHDR.width,height": "1868, 3840",
|
||||
"png:pHYs": "x_res=370753, y_res=370798, units=0",
|
||||
"signature": "7fb181e6439aa51f6eb134a4991711167b5850e80e40ae5cb0c67cf29c118dfe"
|
||||
},
|
||||
"tainted": false,
|
||||
"filesize": "3422B",
|
||||
"numberPixels": "7.17312M",
|
||||
"pixelsPerSecond": "2.74974MB",
|
||||
"userTime": "2.880u",
|
||||
"elapsedTime": "0:03.608",
|
||||
"version": "ImageMagick 7.1.1-41 Q16-HDRI x86_64 22504 https://imagemagick.org"
|
||||
}
|
||||
}]
|
||||
@@ -1,6 +1,6 @@
|
||||
# flutter pub run flutter_launcher_icons
|
||||
flutter_launcher_icons:
|
||||
image_path: "assets/icons/citycards_customer_logo.jpg"
|
||||
image_path: "assets/logo/logo_city_cards.png"
|
||||
|
||||
android: "launcher_icon"
|
||||
# image_path_android: "assets/icon/icon.png"
|
||||
|
||||
@@ -20,5 +20,7 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
PODS:
|
||||
- app_links (7.0.0):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_angle (0.3.8):
|
||||
- Flutter
|
||||
@@ -9,8 +7,6 @@ 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
|
||||
@@ -25,15 +21,11 @@ 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
|
||||
@@ -97,30 +89,23 @@ PODS:
|
||||
- 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:
|
||||
@@ -138,30 +123,22 @@ SPEC REPOS:
|
||||
- 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:
|
||||
@@ -170,27 +147,21 @@ EXTERNAL SOURCES:
|
||||
: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: fc44e198cea1f07e1a5919bad1484049fab65c96
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
FlutterAngle: c810891af800750361b1d0e7cc944f2338d5ae18
|
||||
geocoding_ios: eafacae6ad11a1eb56681f7d11df602a5fd49416
|
||||
geolocator_apple: 66b711889fd333205763b83c9dcf0a57a28c7afd
|
||||
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
|
||||
google_maps_flutter_ios: e31555a04d1986ab130f2b9f24b6cdc861acc6d3
|
||||
GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438
|
||||
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
|
||||
@@ -203,7 +174,6 @@ SPEC CHECKSUMS:
|
||||
StripePaymentsUI: 626726a01255a6458c35436f7f6431dacee82684
|
||||
StripeUICore: 30f8352fd7a5cf1541b7777a57b3ad1133bf6763
|
||||
three_js_sensors: ab5f24fbeb97ab5c5ce2978c3e63a25d67a076f5
|
||||
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
||||
video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86
|
||||
|
||||
PODFILE CHECKSUM: 1857a7cdb7dfafe45f2b0e9a9af44644190f7506
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
00C1AB7B0C8F1922F3F1AE65 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54C8901E9D1856D980DFFE46 /* Pods_Runner.framework */; };
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
94B491F6EAAA79D2947A02BD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BA7A98D7E1CD160163E28329 /* Pods_RunnerTests.framework */; };
|
||||
81D638B66EB4658C8192CA0D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 445696AB37183A7C63CB7E98 /* Pods_RunnerTests.framework */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
B7B14C5E8DB2459D45E2AD2E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 75864C28F633B337B6CD7995 /* Pods_Runner.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -46,14 +46,13 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -62,9 +61,10 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -72,7 +72,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B7B14C5E8DB2459D45E2AD2E /* Pods_Runner.framework in Frameworks */,
|
||||
00C1AB7B0C8F1922F3F1AE65 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -80,7 +80,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
94B491F6EAAA79D2947A02BD /* Pods_RunnerTests.framework in Frameworks */,
|
||||
81D638B66EB4658C8192CA0D /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -95,15 +95,24 @@
|
||||
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 = (
|
||||
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 */,
|
||||
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 */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -127,7 +136,7 @@
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
6D4A73F1E55857ADBD000C6A /* Pods */,
|
||||
F3A521C4EE6E75D0D8A88556 /* Frameworks */,
|
||||
5D45FB84C63476582408C414 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -155,15 +164,6 @@
|
||||
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 = (
|
||||
42DBF8C3008CA78F0E130EA1 /* [CP] Check Pods Manifest.lock */,
|
||||
BC66FA7BADCD3982DC87655E /* [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 = (
|
||||
46DBB6E51DCB00168B7FED03 /* [CP] Check Pods Manifest.lock */,
|
||||
3825EC0F330C0B58EA2A8981 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
E0E7566711BD38D2F6C5330A /* [CP] Embed Pods Frameworks */,
|
||||
5BB9E9D50E854F4D876D849A /* [CP] Copy Pods Resources */,
|
||||
41FC0A605EBADE26C841287E /* [CP] Embed Pods Frameworks */,
|
||||
D10E98BB568B7005161E1ABD /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -270,45 +270,7 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
42DBF8C3008CA78F0E130EA1 /* [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-RunnerTests-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;
|
||||
};
|
||||
46DBB6E51DCB00168B7FED03 /* [CP] Check Pods Manifest.lock */ = {
|
||||
3825EC0F330C0B58EA2A8981 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -330,21 +292,37 @@
|
||||
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 */ = {
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
41FC0A605EBADE26C841287E /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
"${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-resources.sh\"\n";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
@@ -362,21 +340,43 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
E0E7566711BD38D2F6C5330A /* [CP] Embed Pods Frameworks */ = {
|
||||
BC66FA7BADCD3982DC87655E /* [CP] Check Pods Manifest.lock */ = {
|
||||
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";
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
D10E98BB568B7005161E1ABD /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
@@ -515,7 +515,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 62ED1D923084D6092BECB5AC /* Pods-RunnerTests.debug.xcconfig */;
|
||||
baseConfigurationReference = E2E6DC2B6718F55E3BF165E7 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -533,7 +533,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = AB77C0F975F5B780954288AA /* Pods-RunnerTests.release.xcconfig */;
|
||||
baseConfigurationReference = 626B072D1717B50A277DA3C7 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -549,7 +549,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = AE2DC54B7F4682B91B6259C6 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
baseConfigurationReference = C1FCB3EF88270ED76DFA3FBD /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -2,15 +2,12 @@ import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 555 B After Width: | Height: | Size: 678 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 857 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 7.1 KiB |
@@ -6,6 +6,10 @@
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scan your card to add it automatically</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>To scan cards</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Citycards Customer</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
@@ -24,37 +28,18 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string></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>
|
||||
<string>To scan cards</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Citycard customer needs your location to find the closest place you can visit.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Citycard customer needs your location to find the closest place you can visit.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need access to your camera for taking photos for profile and to build a postcard.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<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>
|
||||
@@ -66,10 +51,15 @@
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
arb-dir: lib/l10n
|
||||
template-arb-file: app_en.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: AppLocalizations
|
||||
nullable-getter: false
|
||||
@@ -9,34 +9,21 @@ 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(
|
||||
message: 'Creating payment intent...',
|
||||
));
|
||||
emit(const StripePaymentLoading());
|
||||
|
||||
/// Stripe expects smallest currency unit
|
||||
/// USD → cents, INR → paise
|
||||
@@ -48,57 +35,45 @@ 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 - Mark as completed
|
||||
_paymentCompleted = true;
|
||||
// ✅ SUCCESS
|
||||
emit(const StripePaymentSuccess());
|
||||
} on StripeException catch (e) {
|
||||
_handleStripeException(e, emit);
|
||||
// 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',
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(StripePaymentFailure(
|
||||
error: 'An unexpected error occurred: ${e.toString()}',
|
||||
isRetryable: true,
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle payment with clientSecret directly from backend
|
||||
/// 🆕 NEW: 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(
|
||||
message: 'Initializing payment...',
|
||||
));
|
||||
emit(const StripePaymentLoading());
|
||||
|
||||
// 1️⃣ Init Payment Sheet with clientSecret from backend
|
||||
await Stripe.instance.initPaymentSheet(
|
||||
@@ -106,132 +81,36 @@ 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 - Mark as completed
|
||||
_paymentCompleted = true;
|
||||
// ✅ SUCCESS
|
||||
emit(const StripePaymentSuccess());
|
||||
} on StripeException catch (e) {
|
||||
_handleStripeException(e, emit);
|
||||
// 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',
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
emit(StripePaymentFailure(
|
||||
error: 'An unexpected error occurred: ${e.toString()}',
|
||||
isRetryable: true,
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle payment cancellation
|
||||
void _onCancelPayment(
|
||||
CancelPaymentEvent event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
// Only emit cancelled if not already completed
|
||||
if (!_paymentCompleted) {
|
||||
emit(const StripePaymentCancelled(
|
||||
message: 'Payment cancelled by user',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle payment retry
|
||||
Future<void> _onRetryPayment(
|
||||
RetryPaymentEvent event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) async {
|
||||
// 🔄 Reset completion flag for retry
|
||||
_paymentCompleted = false;
|
||||
|
||||
// Reset state first
|
||||
emit(const StripePaymentInitial());
|
||||
|
||||
// Then initiate payment again
|
||||
add(InitiatePaymentWithClientSecret(
|
||||
clientSecret: event.clientSecret,
|
||||
));
|
||||
}
|
||||
|
||||
/// Reset payment state back to initial
|
||||
void _onResetPaymentState(
|
||||
ResetPaymentState event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
// 🔄 Reset completion flag
|
||||
_paymentCompleted = false;
|
||||
emit(const StripePaymentInitial());
|
||||
}
|
||||
|
||||
/// Centralized Stripe exception handling
|
||||
void _handleStripeException(
|
||||
StripeException e,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
final errorCode = e.error.code;
|
||||
final errorMessage = e.error.localizedMessage ?? 'Payment failed';
|
||||
|
||||
// Handle cancellation separately
|
||||
if (errorCode == FailureCode.Canceled) {
|
||||
emit(StripePaymentCancelled(
|
||||
message: errorMessage,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different error types
|
||||
switch (errorCode) {
|
||||
case FailureCode.Failed:
|
||||
emit(StripePaymentFailure(
|
||||
error: errorMessage,
|
||||
errorCode: errorCode.toString(),
|
||||
isRetryable: true,
|
||||
));
|
||||
break;
|
||||
|
||||
case FailureCode.Timeout:
|
||||
emit(const StripePaymentFailure(
|
||||
error: 'Payment timed out. Please try again.',
|
||||
errorCode: 'timeout',
|
||||
isRetryable: true,
|
||||
));
|
||||
break;
|
||||
|
||||
default:
|
||||
emit(StripePaymentFailure(
|
||||
error: errorMessage,
|
||||
errorCode: errorCode?.toString(),
|
||||
isRetryable: _isRetryableError(errorCode),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if an error is retryable
|
||||
bool _isRetryableError(FailureCode? errorCode) {
|
||||
if (errorCode == null) return true;
|
||||
|
||||
// Non-retryable errors
|
||||
const nonRetryableErrors = [
|
||||
// Add specific non-retryable error codes here if needed
|
||||
];
|
||||
|
||||
return !nonRetryableErrors.contains(errorCode);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
// Reset flag on bloc disposal
|
||||
_paymentCompleted = false;
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class InitiatePayment extends StripePaymentEvent {
|
||||
List<Object?> get props => [amount, currency];
|
||||
}
|
||||
|
||||
/// Event to initiate payment with clientSecret from backend
|
||||
/// 🆕 NEW: Event to initiate payment with clientSecret from backend
|
||||
class InitiatePaymentWithClientSecret extends StripePaymentEvent {
|
||||
final String clientSecret;
|
||||
|
||||
@@ -32,24 +32,6 @@ class InitiatePaymentWithClientSecret extends StripePaymentEvent {
|
||||
List<Object?> get props => [clientSecret];
|
||||
}
|
||||
|
||||
/// Event to cancel ongoing payment
|
||||
class CancelPaymentEvent extends StripePaymentEvent {
|
||||
const CancelPaymentEvent();
|
||||
}
|
||||
|
||||
/// Event to reset payment state back to initial
|
||||
class ResetPaymentState extends StripePaymentEvent {
|
||||
const ResetPaymentState();
|
||||
}
|
||||
|
||||
/// Event to retry failed payment
|
||||
class RetryPaymentEvent extends StripePaymentEvent {
|
||||
final String clientSecret;
|
||||
|
||||
const RetryPaymentEvent({
|
||||
required this.clientSecret,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [clientSecret];
|
||||
}
|
||||
@@ -7,59 +7,36 @@ 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 {
|
||||
final String? message;
|
||||
const StripePaymentLoading();
|
||||
}
|
||||
|
||||
const StripePaymentLoading({
|
||||
this.message,
|
||||
class StripePaymentSuccess extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentSuccess({
|
||||
this.message = 'Payment Successful',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// Payment sheet is initialized and ready to be presented
|
||||
class StripePaymentSheetReady extends StripePaymentState {
|
||||
const StripePaymentSheetReady();
|
||||
}
|
||||
|
||||
/// 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 => [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];
|
||||
List<Object?> get props => [error];
|
||||
}
|
||||
|
||||
/// Payment was cancelled by user
|
||||
class StripePaymentCancelled extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
@@ -67,30 +44,6 @@ class StripePaymentCancelled extends StripePaymentState {
|
||||
this.message = 'Payment Cancelled',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// Payment requires additional authentication (3D Secure, etc.)
|
||||
class StripePaymentRequiresAction extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentRequiresAction({
|
||||
this.message = 'Additional authentication required',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// Payment is processing on the backend
|
||||
class StripePaymentProcessing extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentProcessing({
|
||||
this.message = 'Payment is being processed...',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -1,475 +1,230 @@
|
||||
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';
|
||||
|
||||
/// 🎯 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;
|
||||
class StripePaymentView extends StatelessWidget {
|
||||
const StripePaymentView({super.key});
|
||||
|
||||
/// Amount to display (optional)
|
||||
final double? amount;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final args =
|
||||
ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
||||
|
||||
/// Currency symbol (default: \$)
|
||||
final String currencySymbol;
|
||||
final double amount = args['amount'];
|
||||
final String currency = args['currency'];
|
||||
|
||||
/// Custom title for the payment screen
|
||||
final String? title;
|
||||
|
||||
/// Custom loading message
|
||||
final String loadingMessage;
|
||||
|
||||
/// 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.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,
|
||||
});
|
||||
|
||||
/// 🚀 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
return BlocProvider(
|
||||
create: (context) => StripePaymentBloc(
|
||||
stripeService: StripeService(),
|
||||
),
|
||||
child: StripePaymentViewContent(
|
||||
amount: amount,
|
||||
currency: 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
class StripePaymentViewContent extends StatefulWidget {
|
||||
final double amount;
|
||||
final String currency;
|
||||
|
||||
const StripePaymentViewContent({
|
||||
super.key,
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StripePaymentViewContent> createState() =>
|
||||
_StripePaymentViewContentState();
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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;
|
||||
},
|
||||
return BlocListener<StripePaymentBloc, StripePaymentState>(
|
||||
listener: (context, state) {
|
||||
if (state is StripePaymentSuccess) {
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
} else if (state is StripePaymentFailure) {
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
} else if (state is StripePaymentCancelled) {
|
||||
debugPrint('🚫 Payment Cancelled');
|
||||
onPaymentCancelled?.call();
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
},
|
||||
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),
|
||||
// Show cancellation message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
),
|
||||
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,
|
||||
);
|
||||
// Go back to checkout on cancellation
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
Navigator.pop(context, 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: 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),
|
||||
const Text(
|
||||
"Preparing secure payment...",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Payment status
|
||||
_buildPaymentStatus(context, state),
|
||||
|
||||
// Custom footer widget
|
||||
if (footerWidget != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
footerWidget!,
|
||||
],
|
||||
),
|
||||
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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Close button (only show when allowed)
|
||||
if (_shouldShowCloseButton(state))
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
if (state is StripePaymentLoading) {
|
||||
// Cancel payment if loading
|
||||
context
|
||||
.read<StripePaymentBloc>()
|
||||
.add(CancelPaymentEvent());
|
||||
} else {
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Colors.grey[600],
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build payment status widget based on state
|
||||
Widget _buildPaymentStatus(BuildContext context, StripePaymentState state) {
|
||||
if (state is StripePaymentLoading) {
|
||||
return Column(
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: Color(0xffF95F62),
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
loadingMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentSuccess) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: successColor,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
successMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentFailure) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error,
|
||||
color: errorColor,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
failureMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.error,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Retry payment
|
||||
context.read<StripePaymentBloc>().add(
|
||||
RetryPaymentEvent(
|
||||
clientSecret: clientSecret,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Retry Payment',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentCancelled) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cancel,
|
||||
color: Colors.orange,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Payment Cancelled',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// Determine if close button should be shown
|
||||
bool _shouldShowCloseButton(StripePaymentState state) {
|
||||
if (state is StripePaymentLoading) {
|
||||
return showCloseButtonDuringLoading;
|
||||
}
|
||||
// Show for failure and cancelled states
|
||||
return state is StripePaymentFailure || state is StripePaymentCancelled;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,9 @@ 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';
|
||||
@@ -28,87 +25,49 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
final TextEditingController cityController = TextEditingController();
|
||||
final TextEditingController countryController = TextEditingController();
|
||||
|
||||
String _selectedIsdCode = '+61'; // ✅ NEW: tracks selected country dial code
|
||||
String? selectedCountry;
|
||||
|
||||
@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 ||
|
||||
countryController.text.isEmpty) {
|
||||
selectedCountry == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
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)),
|
||||
const SnackBar(
|
||||
content: Text('Please fill all fields'),
|
||||
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: countryController.text,
|
||||
country: selectedCountry!,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -119,14 +78,25 @@ 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 ?? AppLocalizations.of(context)!.failedToSubmitDetails),
|
||||
content: Text(state.errorMessage ?? 'Failed to submit details'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -159,7 +129,7 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.addDetailsTitle,
|
||||
"Add details",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -171,7 +141,7 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
text: AppLocalizations.of(context)!.aboutRecipient,
|
||||
text: "Tell us about the recipient",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
@@ -181,103 +151,109 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: AppLocalizations.of(context)!.firstNameLabelWithStar,
|
||||
hint: AppLocalizations.of(context)!.firstNameHint,
|
||||
label: "First Name",
|
||||
hint: "Enter recipient's first name",
|
||||
controller: firstNameController,
|
||||
onlyLetters: true,
|
||||
maxLength: 50,
|
||||
noSpace: true,
|
||||
isFirstLetterCapital: true,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: AppLocalizations.of(context)!.lastNameLabelWithStar,
|
||||
hint: AppLocalizations.of(context)!.lastNameHint,
|
||||
label: "Last Name",
|
||||
hint: "Enter recipient's last name",
|
||||
controller: lastNameController,
|
||||
onlyLetters: true,
|
||||
maxLength: 50,
|
||||
noSpace: true,
|
||||
isFirstLetterCapital: true,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: AppLocalizations.of(context)!.emailLabelWithStar,
|
||||
hint: AppLocalizations.of(context)!.emailHint,
|
||||
label: "Email",
|
||||
hint: "Enter recipient's email address",
|
||||
controller: emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
),
|
||||
|
||||
// ✅ NEW: Phone field with CountryCodePicker (replaces plain CustomTextField)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: AppLocalizations.of(context)!.phoneNumberLabelWithStar,
|
||||
hint: AppLocalizations.of(context)!.phoneNumberHint,
|
||||
label: "Phone Number",
|
||||
hint: "Enter recipient's phone number",
|
||||
controller: phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 12,
|
||||
numbersOnly: true,
|
||||
prefixWidget: CountryCodePicker(
|
||||
onChanged: (country) {
|
||||
setState(() => _selectedIsdCode = country.dialCode!);
|
||||
},
|
||||
initialSelection: 'AU',
|
||||
favorite: const ['+61', '+1', '+44', '+91'],
|
||||
showCountryOnly: false,
|
||||
showOnlyCountryWhenClosed: false,
|
||||
alignLeft: false,
|
||||
flagWidth: 24.w,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: const Color(0xFF2D3134),
|
||||
),
|
||||
dialogTextStyle: TextStyle(fontSize: 14.sp),
|
||||
searchDecoration: 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: AppLocalizations.of(context)!.cityLabelWithStar,
|
||||
hint: AppLocalizations.of(context)!.cityHint,
|
||||
label: "City",
|
||||
hint: "Enter the name of the city",
|
||||
controller: cityController,
|
||||
maxLength: 50,
|
||||
onlyLetters: true,
|
||||
isFirstLetterCapital: true,
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
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,
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
// Option 1: Pass empty function when disabled (doesn't change button appearance)
|
||||
CustomFilledButton(
|
||||
onTap: () => _handleSubmit(context, isSubmitting),
|
||||
label: isSubmitting ? AppLocalizations.of(context)!.submittingLabel : AppLocalizations.of(context)!.continueTitle,
|
||||
label: isSubmitting ? "Submitting..." : "Continue",
|
||||
width: double.infinity,
|
||||
),
|
||||
|
||||
|
||||
@@ -11,39 +11,6 @@ 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(
|
||||
|
||||
@@ -17,19 +17,3 @@ 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];
|
||||
}
|
||||
|
||||
@@ -15,33 +15,13 @@ 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, isExpanded, galleryIndex, fullScreenGalleryIndex];
|
||||
List<Object?> get props => [attractionDetails];
|
||||
}
|
||||
|
||||
class AttractionDetailsError extends AttractionDetailsState {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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';
|
||||
@@ -9,10 +6,7 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../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';
|
||||
@@ -39,7 +33,7 @@ class AttractionDetailsView extends StatelessWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(color: Color(0xffF95F62)),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -72,156 +66,104 @@ 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: [
|
||||
// ── Hero image ──────────────────────────────────────
|
||||
CachedNetworkImage(
|
||||
imageUrl: coverImage,
|
||||
height: 280.h,
|
||||
Image.network(
|
||||
coverImage,
|
||||
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,
|
||||
),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
'assets/images/koh_rong_samloem_banner.png',
|
||||
height: 377.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: 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: 16.sp, // 🔽 smaller icon
|
||||
color: Colors.black,
|
||||
),
|
||||
SizedBox(width: 6.w), // 🔽 smaller spacing
|
||||
Text(
|
||||
'Back to attractions',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp, // 🔽 slightly smaller
|
||||
fontWeight: FontWeight.w600, // ✅ bold
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
Icons.arrow_back,
|
||||
size: 24.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
attraction.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Bottom-left: attraction title (smaller, over fade) ─
|
||||
Positioned(
|
||||
bottom: 48.h,
|
||||
left: 14.w,
|
||||
right: 60.w,
|
||||
bottom: 31.h,
|
||||
left: 12.w,
|
||||
right: 60.w, // Add this - leaves space for share button
|
||||
child: Text(
|
||||
attraction.title,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.25,
|
||||
fontSize: 44.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Bottom-right: share button ───────────────────────
|
||||
Positioned(
|
||||
bottom: 48.h,
|
||||
right: 14.w,
|
||||
bottom: 31.h,
|
||||
right: 17.w,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Share.share(
|
||||
'www.google.com',
|
||||
subject: AppLocalizations.of(context)!.checkThisOut,
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) =>
|
||||
const ShareBottomSheet(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 42.h,
|
||||
width: 42.w,
|
||||
height: 36.h,
|
||||
width: 36.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(21.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
@@ -236,106 +178,29 @@ 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(
|
||||
AppLocalizations.of(context)!.aboutTitle,
|
||||
"About",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 12.32.h),
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
Text(
|
||||
attraction.description,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF262626),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 14.sp,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -505,10 +370,10 @@ class AttractionDetailsView extends StatelessWidget {
|
||||
Divider(color: Colors.black.withOpacity(0.2)),
|
||||
SizedBox(height: 30.h),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.whatIsIncluded,
|
||||
"What is included",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
@@ -530,17 +395,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(
|
||||
AppLocalizations.of(context)!.exactLocation,
|
||||
"Exact Location",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: AppLocalizations.of(context)!.viewOnMap,
|
||||
text: "View the location on map",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(.6),
|
||||
),
|
||||
@@ -600,29 +465,28 @@ class AttractionDetailsView extends StatelessWidget {
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
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,
|
||||
),
|
||||
Divider(color: Colors.black.withOpacity(0.2)),
|
||||
SizedBox(height: 30.h),
|
||||
Text(
|
||||
"People frequently ask",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
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(),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -637,7 +501,7 @@ class AttractionDetailsView extends StatelessWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: Text(AppLocalizations.of(context)!.somethingWentWrong),
|
||||
child: Text("Something went wrong"),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -728,276 +592,4 @@ 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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,18 +26,15 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// drag handle
|
||||
Container(
|
||||
height: 4.h,
|
||||
width: 47.w,
|
||||
margin: EdgeInsets.only(bottom: 16.h),
|
||||
margin: EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF222222),
|
||||
color: Color(0xFF222222),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
|
||||
// link field
|
||||
TextField(
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(
|
||||
@@ -54,10 +51,7 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// grid
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -73,16 +67,7 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// FIXED SIZE ICON CONTAINER
|
||||
Container(
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
item['icon']!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
Image.asset(item['icon']!, width: 55.w),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
item['title']!,
|
||||
@@ -93,32 +78,26 @@ 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
|
||||
? const Color(0xFF676363)
|
||||
: Colors.white,
|
||||
border: Border.all(color: const Color(0xFF676363)),
|
||||
color: index == 0 ? Color(0xFF676363) : Colors.white,
|
||||
border: Border.all(color: Color(0xFF676363)),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 10.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import 'attractions_state.dart';
|
||||
class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
|
||||
final AttractionsRepository repository;
|
||||
|
||||
AttractionsBloc({required this.repository}) : super(AttractionsInitial()) {
|
||||
AttractionsBloc({required this.repository})
|
||||
: super(AttractionsInitial()) {
|
||||
on<FetchAttractionsByCategory>(_onFetchAttractionsByCategory);
|
||||
on<SearchAttractions>(_onSearchAttractions);
|
||||
}
|
||||
|
||||
Future<void> _onFetchAttractionsByCategory(
|
||||
@@ -21,50 +21,22 @@ class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
|
||||
try {
|
||||
final AttractionsResponse response =
|
||||
await repository.fetchAttractionsByCategory(
|
||||
categoryXid: event.categoryXid,
|
||||
categoryXid: event.categoryXid, // Can be null now
|
||||
);
|
||||
|
||||
final allAttractions = response.attractions ?? [];
|
||||
|
||||
emit(
|
||||
AttractionsLoaded(
|
||||
attractions: allAttractions,
|
||||
allAttractions: allAttractions,
|
||||
attractions: response.attractions ?? [],
|
||||
categories: response.categories ?? [],
|
||||
selectedCategoryId: event.categoryXid,
|
||||
searchQuery: '',
|
||||
selectedCategoryId: event.categoryXid, // Can be null
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(AttractionsError(e.toString()));
|
||||
emit(
|
||||
AttractionsError(
|
||||
e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchAttractions(
|
||||
SearchAttractions event,
|
||||
Emitter<AttractionsState> emit,
|
||||
) {
|
||||
final currentState = state;
|
||||
if (currentState is! AttractionsLoaded) return;
|
||||
|
||||
final query = event.query.trim().toLowerCase();
|
||||
|
||||
final filtered = query.isEmpty
|
||||
? currentState.allAttractions
|
||||
: currentState.allAttractions.where((attraction) {
|
||||
final name = (attraction.title ?? '').toLowerCase();
|
||||
final description = (attraction.description ?? '').toLowerCase();
|
||||
return name.contains(query) || description.contains(query);
|
||||
}).toList();
|
||||
|
||||
emit(
|
||||
AttractionsLoaded(
|
||||
attractions: filtered,
|
||||
allAttractions: currentState.allAttractions,
|
||||
categories: currentState.categories,
|
||||
selectedCategoryId: currentState.selectedCategoryId,
|
||||
searchQuery: event.query,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,19 +8,10 @@ abstract class AttractionsEvent extends Equatable {
|
||||
}
|
||||
|
||||
class FetchAttractionsByCategory extends AttractionsEvent {
|
||||
final int? categoryXid;
|
||||
final int? categoryXid; // Make it nullable
|
||||
|
||||
const FetchAttractionsByCategory({this.categoryXid});
|
||||
const FetchAttractionsByCategory({this.categoryXid}); // Remove required
|
||||
|
||||
@override
|
||||
List<Object?> get props => [categoryXid];
|
||||
}
|
||||
|
||||
class SearchAttractions extends AttractionsEvent {
|
||||
final String query;
|
||||
|
||||
const SearchAttractions(this.query);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [query];
|
||||
}
|
||||
@@ -14,27 +14,17 @@ 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;
|
||||
final String searchQuery;
|
||||
final int? selectedCategoryId; // Make it nullable
|
||||
|
||||
const AttractionsLoaded({
|
||||
required this.attractions,
|
||||
required this.allAttractions,
|
||||
required this.categories,
|
||||
this.selectedCategoryId,
|
||||
this.searchQuery = '',
|
||||
this.selectedCategoryId, // Remove required
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
attractions,
|
||||
allAttractions,
|
||||
categories,
|
||||
selectedCategoryId,
|
||||
searchQuery,
|
||||
];
|
||||
List<Object?> get props => [attractions, categories, selectedCategoryId];
|
||||
}
|
||||
|
||||
class AttractionsError extends AttractionsState {
|
||||
|
||||
@@ -37,9 +37,9 @@ class Attraction {
|
||||
final String title;
|
||||
final String description;
|
||||
final String urlSlug;
|
||||
final num cityXid;
|
||||
final num cardTypeXid;
|
||||
final num partnerXid;
|
||||
final int cityXid;
|
||||
final int cardTypeXid;
|
||||
final int partnerXid;
|
||||
final String productCode;
|
||||
|
||||
final bool isBookingRequired;
|
||||
@@ -47,14 +47,14 @@ class Attraction {
|
||||
final String bookingEmail;
|
||||
final String bookingPhoneNumber;
|
||||
|
||||
final num latitudeCoordinate;
|
||||
final num longitudeCoordinate;
|
||||
final double latitudeCoordinate;
|
||||
final double longitudeCoordinate;
|
||||
final String address;
|
||||
|
||||
final num? ticketPriceAdult;
|
||||
final num? ticketPriceChild;
|
||||
final num durations;
|
||||
final num groupSize;
|
||||
final double? ticketPriceAdult;
|
||||
final double? ticketPriceChild;
|
||||
final int durations;
|
||||
final int groupSize;
|
||||
final String ageRange;
|
||||
|
||||
final String seoTitle;
|
||||
@@ -115,11 +115,13 @@ class Attraction {
|
||||
isPartnerAccess: json['isPartnerAccess'] ?? false,
|
||||
bookingEmail: json['bookingEmail'] ?? '',
|
||||
bookingPhoneNumber: json['bookingPhonenumber'] ?? '',
|
||||
latitudeCoordinate: (json['latitudeCoordinate'] as num?) ?? 0,
|
||||
longitudeCoordinate: (json['longitudeCoordinate'] as num?) ?? 0,
|
||||
latitudeCoordinate:
|
||||
(json['latitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
|
||||
longitudeCoordinate:
|
||||
(json['longitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
|
||||
address: json['address'] ?? '',
|
||||
ticketPriceAdult: json['ticketPriceAdult'] as num?,
|
||||
ticketPriceChild: json['ticketPriceChild'] as num?,
|
||||
ticketPriceAdult: (json['ticketPriceAdult'] as num?)?.toDouble(),
|
||||
ticketPriceChild: (json['ticketPriceChild'] as num?)?.toDouble(),
|
||||
durations: json['durations'] ?? 0,
|
||||
groupSize: json['groupSize'] ?? 0,
|
||||
ageRange: json['ageRange'] ?? '',
|
||||
@@ -195,9 +197,9 @@ class Attraction {
|
||||
class CardModel {
|
||||
final int id;
|
||||
final String title;
|
||||
final num cardTypeXid;
|
||||
final num adultPrice;
|
||||
final num childPrice;
|
||||
final int cardTypeXid;
|
||||
final int adultPrice;
|
||||
final int childPrice;
|
||||
final String cardStatus;
|
||||
|
||||
CardModel({
|
||||
@@ -232,6 +234,7 @@ class CardModel {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* -------------------- GALLERY -------------------- */
|
||||
|
||||
class Gallery {
|
||||
@@ -272,6 +275,7 @@ class Gallery {
|
||||
bool get hasImage => filePathUrl.isNotEmpty;
|
||||
}
|
||||
|
||||
|
||||
/* -------------------- CATEGORY -------------------- */
|
||||
|
||||
class Category {
|
||||
@@ -296,4 +300,5 @@ class Category {
|
||||
'categoryName': categoryName,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -38,125 +37,117 @@ class AttractionsPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
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,
|
||||
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(),
|
||||
),
|
||||
),
|
||||
backWidget(context, AppLocalizations.of(context)!.yourAttractionTitle, Colors.black),
|
||||
const SizedBox(height: 20),
|
||||
// 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: () {}),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// 🔍 Search field (UI kept, logic disabled)
|
||||
CommonSearchField(
|
||||
hint: AppLocalizations.of(context)!.searchAttractionsHint,
|
||||
hintColor: Colors.grey.shade500,
|
||||
onChanged: (value) {
|
||||
bloc.add(SearchAttractions(value));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
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(),
|
||||
// 🙏️ 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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)),
|
||||
)
|
||||
: Column(
|
||||
children: state.attractions
|
||||
.map(
|
||||
(attraction) => AttractionCard(
|
||||
attraction: attraction,
|
||||
),
|
||||
)
|
||||
else if (state is AttractionsLoaded)
|
||||
state.attractions.isEmpty
|
||||
? Center(
|
||||
.toList(),
|
||||
)
|
||||
else if (state is AttractionsError)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.noAttractionsFound,
|
||||
state.message,
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
color: Colors.red,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: 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(),
|
||||
],
|
||||
),
|
||||
else
|
||||
const SizedBox(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -27,7 +24,7 @@ class AttractionCard extends StatelessWidget {
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionDetails,
|
||||
arguments: attraction.id,
|
||||
arguments: attraction,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
@@ -45,17 +42,12 @@ class AttractionCard extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: imageUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
height: 94.h,
|
||||
width: 94.w,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => SkeletonWidget(
|
||||
width: 94.w,
|
||||
height: 94.h,
|
||||
borderRadius: 8.r,
|
||||
),
|
||||
errorWidget: (_, __, ___) => _imageFallback(),
|
||||
errorBuilder: (_, __, ___) => _imageFallback(),
|
||||
)
|
||||
: _imageFallback(),
|
||||
),
|
||||
@@ -69,26 +61,22 @@ 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,
|
||||
// maxLines: 1,
|
||||
// overflow: TextOverflow.ellipsis,
|
||||
// style: GoogleFonts.poppins(
|
||||
// fontSize: 12.sp,
|
||||
// fontWeight: FontWeight.w400,
|
||||
// color: const Color(0xff464646),
|
||||
// ),
|
||||
// ),
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
Text(
|
||||
attraction.address,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xff464646),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
@@ -96,7 +84,7 @@ class AttractionCard extends StatelessWidget {
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "\$${attraction.ticketPriceAdult}",
|
||||
text: "from \$${attraction.ticketPriceAdult}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -104,7 +92,7 @@ class AttractionCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)!.perPersonSuffix,
|
||||
text: "/person",
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
color: Colors.black,
|
||||
@@ -116,8 +104,10 @@ class AttractionCard extends StatelessWidget {
|
||||
),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
/// TAGS (CARD TITLES)
|
||||
Wrap(
|
||||
attraction.isBookingRequired == false
|
||||
? Wrap(
|
||||
spacing: 6.w,
|
||||
runSpacing: 6.h,
|
||||
children: tags
|
||||
@@ -155,6 +145,27 @@ class AttractionCard extends StatelessWidget {
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 10.w,
|
||||
vertical: 4.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffC1D2F8),
|
||||
border: Border.all(
|
||||
color: const Color(0xff2563EB),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
"Booking Required",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11.sp,
|
||||
color: const Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,18 +20,7 @@ class BuyPassBloc extends Bloc<BuyPassEvent, BuyPassState> {
|
||||
on<UpdateChildCount>(_onUpdateChildCount);
|
||||
|
||||
/// Handle update validity duration event
|
||||
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
|
||||
on<UpdateValidityDuration>(_onUpdateValidityDuration); // ✅ Added
|
||||
}
|
||||
|
||||
/// Fetch buy pass data from repository
|
||||
|
||||
@@ -29,6 +29,4 @@ class UpdateValidityDuration extends BuyPassEvent {
|
||||
final int duration;
|
||||
|
||||
UpdateValidityDuration(this.duration);
|
||||
}
|
||||
class AddToCartLoading extends BuyPassEvent {}
|
||||
class AddToCartDone extends BuyPassEvent {}
|
||||
}
|
||||
@@ -14,17 +14,15 @@ class BuyPassLoaded extends BuyPassState {
|
||||
final int selectedCardIndex;
|
||||
final int adultCount;
|
||||
final int childCount;
|
||||
final int validityDuration;
|
||||
final bool isAddingToCart;
|
||||
final int validityDuration; // ✅ Added
|
||||
|
||||
BuyPassLoaded({
|
||||
required this.data,
|
||||
this.selectedCardIndex = 0,
|
||||
this.adultCount = 1,
|
||||
this.childCount = 1,
|
||||
int? validityDuration,
|
||||
this.isAddingToCart = false, // ✅ default false, NOT required
|
||||
}) : validityDuration = validityDuration ?? data.cards[selectedCardIndex].minNumber;
|
||||
int? validityDuration, // ✅ Added as optional parameter
|
||||
}) : validityDuration = validityDuration ?? data.cards[selectedCardIndex].minNumber; // ✅ Initialize with minNumber
|
||||
|
||||
/// Method to copy state with updated values
|
||||
BuyPassLoaded copyWith({
|
||||
@@ -32,16 +30,14 @@ class BuyPassLoaded extends BuyPassState {
|
||||
int? selectedCardIndex,
|
||||
int? adultCount,
|
||||
int? childCount,
|
||||
int? validityDuration,
|
||||
bool? isAddingToCart,
|
||||
int? validityDuration, // ✅ Added
|
||||
}) {
|
||||
return BuyPassLoaded(
|
||||
data: data ?? this.data,
|
||||
selectedCardIndex: selectedCardIndex ?? this.selectedCardIndex,
|
||||
adultCount: adultCount ?? this.adultCount,
|
||||
childCount: childCount ?? this.childCount,
|
||||
validityDuration: validityDuration ?? this.validityDuration,
|
||||
isAddingToCart: isAddingToCart ?? this.isAddingToCart,
|
||||
validityDuration: validityDuration ?? this.validityDuration, // ✅ Added
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,8 +47,7 @@ class BuyPassLoaded extends BuyPassState {
|
||||
/// Calculate total price
|
||||
double get totalPrice {
|
||||
final card = selectedCard;
|
||||
return ((card.adultPrice * adultCount) + (card.childPrice * childCount)) *
|
||||
validityDuration.toDouble();
|
||||
return ((card.adultPrice * adultCount) + (card.childPrice * childCount)) * validityDuration.toDouble(); // ✅ Multiply by validityDuration
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ String buyPassModelToJson(BuyPassModel data) =>
|
||||
json.encode(data.toJson());
|
||||
|
||||
class BuyPassModel {
|
||||
City city;
|
||||
List<Offer> offers;
|
||||
List<CardPass> cards;
|
||||
List<Attraction> attractions;
|
||||
final City city;
|
||||
final List<Offer> offers;
|
||||
final List<CardPass> cards;
|
||||
final List<Attraction> attractions;
|
||||
|
||||
BuyPassModel({
|
||||
required this.city,
|
||||
@@ -20,49 +20,41 @@ class BuyPassModel {
|
||||
required this.attractions,
|
||||
});
|
||||
|
||||
factory BuyPassModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
factory BuyPassModel.fromJson(Map<String, dynamic> json) {
|
||||
return BuyPassModel(
|
||||
city: City.fromJson(json['city']),
|
||||
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(),
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"city": city.toJson(),
|
||||
"offers": offers.map((e) => e.toJson()).toList(),
|
||||
"cards": cards.map((e) => e.toJson()).toList(),
|
||||
"attractions": attractions.map((e) => e.toJson()).toList(),
|
||||
"offers": offers.map((x) => x.toJson()).toList(),
|
||||
"cards": cards.map((x) => x.toJson()).toList(),
|
||||
"attractions": attractions.map((x) => x.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CITY ----------
|
||||
class City {
|
||||
int id;
|
||||
String name;
|
||||
String slug;
|
||||
String tagLine;
|
||||
String description;
|
||||
String bestTimeToVisit;
|
||||
String priceRange;
|
||||
num individualTicketAmount;
|
||||
num cityCardTicketAmount;
|
||||
HeroBanner heroBanner;
|
||||
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;
|
||||
|
||||
City({
|
||||
required this.id,
|
||||
@@ -77,19 +69,17 @@ class City {
|
||||
required this.heroBanner,
|
||||
});
|
||||
|
||||
factory City.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
factory City.fromJson(Map<String, dynamic> json) {
|
||||
return City(
|
||||
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,
|
||||
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'],
|
||||
heroBanner: HeroBanner.fromJson(json['heroBanner']),
|
||||
);
|
||||
}
|
||||
@@ -110,20 +100,18 @@ class City {
|
||||
|
||||
/// ---------- HERO BANNER ----------
|
||||
class HeroBanner {
|
||||
String title;
|
||||
String image;
|
||||
final String title;
|
||||
final String image;
|
||||
|
||||
HeroBanner({
|
||||
required this.title,
|
||||
required this.image,
|
||||
});
|
||||
|
||||
factory HeroBanner.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
factory HeroBanner.fromJson(Map<String, dynamic> json) {
|
||||
return HeroBanner(
|
||||
title: json['title']?.toString() ?? "",
|
||||
image: json['image']?.toString() ?? "",
|
||||
title: json['title'],
|
||||
image: json['image'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,25 +123,25 @@ class HeroBanner {
|
||||
|
||||
/// ---------- OFFER ----------
|
||||
class Offer {
|
||||
int id;
|
||||
String title;
|
||||
String offerCode;
|
||||
String description;
|
||||
String redemptionLink;
|
||||
String websiteBannerImage;
|
||||
String mobileBannerImage;
|
||||
String passType;
|
||||
DateTime startDateTime;
|
||||
DateTime endDateTime;
|
||||
String offerStatus;
|
||||
bool applyToPasses;
|
||||
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;
|
||||
|
||||
Offer({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.offerCode,
|
||||
required this.description,
|
||||
required this.redemptionLink,
|
||||
this.description,
|
||||
this.redemptionLink,
|
||||
required this.websiteBannerImage,
|
||||
required this.mobileBannerImage,
|
||||
required this.passType,
|
||||
@@ -163,24 +151,20 @@ class Offer {
|
||||
required this.applyToPasses,
|
||||
});
|
||||
|
||||
factory Offer.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
factory Offer.fromJson(Map<String, dynamic> json) {
|
||||
return Offer(
|
||||
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,
|
||||
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'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,16 +186,16 @@ class Offer {
|
||||
|
||||
/// ---------- CARD PASS ----------
|
||||
class CardPass {
|
||||
int id;
|
||||
String title;
|
||||
String description;
|
||||
int validityDuration;
|
||||
num adultPrice;
|
||||
num childPrice;
|
||||
int minNumber;
|
||||
int maxNumber;
|
||||
CardType cardType;
|
||||
List<Offer> offers;
|
||||
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;
|
||||
|
||||
CardPass({
|
||||
required this.id,
|
||||
@@ -226,24 +210,20 @@ class CardPass {
|
||||
required this.offers,
|
||||
});
|
||||
|
||||
factory CardPass.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
factory CardPass.fromJson(Map<String, dynamic> json) {
|
||||
return CardPass(
|
||||
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,
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
validityDuration: json['validityDuration'],
|
||||
adultPrice: json['adultPrice'],
|
||||
childPrice: json['childPrice'],
|
||||
minNumber: json['minNumber'], // ✅
|
||||
maxNumber: json['maxNumber'], // ✅
|
||||
cardType: CardType.fromJson(json['cardType']),
|
||||
offers: json['offers'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['offers'])
|
||||
.map((e) => Offer.fromJson(e))
|
||||
.toList(),
|
||||
offers: List<Offer>.from(
|
||||
json['offers'].map((x) => Offer.fromJson(x)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -257,15 +237,15 @@ class CardPass {
|
||||
"minNumber": minNumber,
|
||||
"maxNumber": maxNumber,
|
||||
"cardType": cardType.toJson(),
|
||||
"offers": offers.map((e) => e.toJson()).toList(),
|
||||
"offers": offers.map((x) => x.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CARD TYPE ----------
|
||||
class CardType {
|
||||
int id;
|
||||
String name;
|
||||
String displayName;
|
||||
final int id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
|
||||
CardType({
|
||||
required this.id,
|
||||
@@ -273,13 +253,11 @@ class CardType {
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
factory CardType.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
factory CardType.fromJson(Map<String, dynamic> json) {
|
||||
return CardType(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? "",
|
||||
displayName: json['displayName']?.toString() ?? "",
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
displayName: json['displayName'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,29 +270,27 @@ class CardType {
|
||||
|
||||
/// ---------- ATTRACTION ----------
|
||||
class Attraction {
|
||||
int id;
|
||||
String title;
|
||||
String slug;
|
||||
String thumbnail;
|
||||
num startingFrom;
|
||||
final int id;
|
||||
final String title;
|
||||
final String slug;
|
||||
final String thumbnail;
|
||||
final num? startingFrom; // Changed from int? to num?
|
||||
|
||||
Attraction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.slug,
|
||||
required this.thumbnail,
|
||||
required this.startingFrom,
|
||||
this.startingFrom,
|
||||
});
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic> json) {
|
||||
return Attraction(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
title: json['title']?.toString() ?? "",
|
||||
slug: json['slug']?.toString() ?? "",
|
||||
thumbnail: json['thumbnail']?.toString() ?? "",
|
||||
startingFrom: json['startingFrom'] ?? 0,
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
slug: json['slug'],
|
||||
thumbnail: json['thumbnail'],
|
||||
startingFrom: json['startingFrom'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -325,4 +301,4 @@ class Attraction {
|
||||
"thumbnail": thumbnail,
|
||||
"startingFrom": startingFrom,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,10 @@ class BuyPassRepository {
|
||||
required int totalChild,
|
||||
required int noOfAttractions,
|
||||
required int noOfDays,
|
||||
required double baseAmount,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiService.postApi(
|
||||
url: ApiUrls.addToCartPasses,
|
||||
url: ApiUrls.addToCartPasses, // add this key in ApiUrls
|
||||
data: {
|
||||
"cityXid": cityXid,
|
||||
"cardTypeXid": cardTypeXid,
|
||||
@@ -39,8 +38,6 @@ class BuyPassRepository {
|
||||
"cardMode": cardMode,
|
||||
"totalAdult": totalAdult,
|
||||
"totalChild": totalChild,
|
||||
"baseAmount": baseAmount,
|
||||
"taxAmount": 2, // Fixed tax amount
|
||||
"noOfAttractions": noOfAttractions,
|
||||
"noOfDays": noOfDays,
|
||||
},
|
||||
@@ -51,4 +48,4 @@ class BuyPassRepository {
|
||||
throw Exception('Failed to add passes to cart: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,11 @@ 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});
|
||||
@@ -21,34 +19,16 @@ 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 StatefulWidget {
|
||||
class BuyPassContent extends StatelessWidget {
|
||||
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(
|
||||
@@ -58,7 +38,9 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
builder: (context, state) {
|
||||
if (state is BuyPassLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFF95F62)),
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +52,7 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
Icon(Icons.error_outline, size: 60.sp, color: Colors.red),
|
||||
SizedBox(height: 16.h),
|
||||
CustomText(
|
||||
text: AppLocalizations.of(context)!.errorLoadingDataTitle,
|
||||
text: "Error loading data",
|
||||
size: 16.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
@@ -85,9 +67,7 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
onPressed: () {
|
||||
context.read<BuyPassBloc>().add(FetchBuyPassData());
|
||||
},
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.retryButtonLabel,
|
||||
),
|
||||
child: const Text("Retry"),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -111,86 +91,77 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
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,
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: Row(
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(text: "Buy a Pass", size: 12.sp),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
SizedBox(height: 16.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),
|
||||
|
||||
// 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:
|
||||
selectedCard.cardType.name == "selective_pass"
|
||||
? Color(0xFFF95FAF) // pink for flexi/selective pass
|
||||
: Color(0xFFF95F62),
|
||||
themeColor: state.selectedCardIndex == 0
|
||||
? Color(0xFFF97316)
|
||||
: Color(0xFF1E8AF6),
|
||||
adultPrice: selectedCard.adultPrice.toDouble(),
|
||||
childPrice: selectedCard.childPrice.toDouble(),
|
||||
adults: state.adultCount,
|
||||
@@ -238,21 +209,14 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(
|
||||
text: AppLocalizations.of(
|
||||
context,
|
||||
)!.memberPrivilegesTitle,
|
||||
size: 18.sp,
|
||||
),
|
||||
CustomText(text: "Card Offers", size: 18.sp),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
RouteConstants.searchOffer,
|
||||
);
|
||||
context, RouteConstants.searchOffer);
|
||||
},
|
||||
child: CustomText(
|
||||
text: AppLocalizations.of(context)!.viewAll,
|
||||
text: "View All",
|
||||
size: 14.sp,
|
||||
color: Color(0xFFFF5757),
|
||||
),
|
||||
@@ -269,13 +233,12 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
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,
|
||||
@@ -283,12 +246,12 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
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,
|
||||
@@ -296,9 +259,7 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: const Color(
|
||||
0xFFF95F62,
|
||||
).withOpacity(.24),
|
||||
color: const Color(0xFFF95F62).withOpacity(.24),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.sp),
|
||||
),
|
||||
@@ -308,75 +269,62 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
/// 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: 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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
@@ -393,10 +341,10 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
|
||||
/// Offer Code
|
||||
CustomText(
|
||||
text: offer.description ?? "N/A",
|
||||
text: offer.description??"N/A",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
maxLines: 3,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
@@ -411,7 +359,7 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
height: 100.h,
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: AppLocalizations.of(context)!.noOffersAvailable,
|
||||
text: "No offers available",
|
||||
size: 14.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
@@ -428,11 +376,7 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CustomText(
|
||||
text: AppLocalizations.of(
|
||||
context,
|
||||
)!.availableAttractionsTitle,
|
||||
size: 18.sp,
|
||||
),
|
||||
text: "Available Attractions", size: 18.sp),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
@@ -453,72 +397,45 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
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.id,
|
||||
);
|
||||
// Navigator.of(context).pushNamed(
|
||||
// RouteConstants.attractionDetails,
|
||||
// arguments: attraction,
|
||||
// );
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
8.r,
|
||||
),
|
||||
child:
|
||||
attraction.thumbnail != null &&
|
||||
attraction
|
||||
.thumbnail!
|
||||
.isNotEmpty
|
||||
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],
|
||||
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],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -544,9 +461,7 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
height: 100.h,
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: AppLocalizations.of(
|
||||
context,
|
||||
)!.noAttractionsAvailable,
|
||||
text: "No attractions available",
|
||||
size: 14.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
@@ -563,7 +478,7 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: AppLocalizations.of(context)!.viewAll,
|
||||
text: "View All",
|
||||
size: 12.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
@@ -581,4 +496,4 @@ class _BuyPassContentState extends State<BuyPassContent> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(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),
|
||||
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),
|
||||
];
|
||||
|
||||
return Center(
|
||||
@@ -44,7 +44,7 @@ class FeatureTable extends StatelessWidget {
|
||||
},
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
children: [
|
||||
_buildHeaderRow(context),
|
||||
_buildHeaderRow(),
|
||||
...features.map(_buildFeatureRow).toList(),
|
||||
],
|
||||
),
|
||||
@@ -54,13 +54,13 @@ class FeatureTable extends StatelessWidget {
|
||||
}
|
||||
|
||||
// HEADER ROW
|
||||
TableRow _buildHeaderRow(BuildContext context) {
|
||||
TableRow _buildHeaderRow() {
|
||||
return TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.featuresTitle,
|
||||
'Features',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15.sp,
|
||||
@@ -68,7 +68,7 @@ class FeatureTable extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
_buildHeaderText(CommonAppText.selectiveCard),
|
||||
_buildHeaderText(AppLocalizations.of(context)!.unlimitedTitle),
|
||||
_buildHeaderText('Unlimited'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
final String? heroImage; // ✅ heroBanner.image from API
|
||||
final num? adultPrice;
|
||||
final num? childPrice;
|
||||
final String? cardType;
|
||||
@@ -31,142 +31,140 @@ class PassCardView extends StatelessWidget {
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: (themeColor ?? const Color(0xFFF95FAF)).withOpacity(0.24),
|
||||
width: 1,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
/// -------- 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(),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: _fallbackIcon(),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 6.66.w),
|
||||
SizedBox(width: 6.66.w),
|
||||
|
||||
/// CARD DETAILS
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
/// -------- CARD DETAILS --------
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(
|
||||
text: city ?? "City",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
|
||||
/// Adult Price
|
||||
Row(
|
||||
children: [
|
||||
CustomText(
|
||||
text: city ?? "City",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
Text(
|
||||
"From ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
|
||||
/// 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(
|
||||
"\$${adultPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
|
||||
/// 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,
|
||||
Text(
|
||||
" /Adult",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// Child Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"and ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${childPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// -------- RIGHT: CARD TYPE LABEL --------
|
||||
/// -------- CARD TYPE LABEL --------
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 140.h,
|
||||
@@ -196,7 +194,7 @@ class PassCardView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// FALLBACK ICON
|
||||
/// -------- FALLBACK ICON --------
|
||||
Widget _fallbackIcon() {
|
||||
return Icon(
|
||||
Icons.card_travel,
|
||||
@@ -204,4 +202,4 @@ class PassCardView extends StatelessWidget {
|
||||
color: Colors.grey[400],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||