30 Commits

Author SHA1 Message Date
mystery012728
40f0ed3a52 pull taken from shree branch and conflict fixes 2026-02-13 17:13:22 +05:30
mystery012728
b08e2699e9 added my passes and more chnages 2026-02-13 15:27:14 +05:30
Shreeyash Thorat
53264619a8 postcard edit 2026-02-13 15:25:05 +05:30
mystery012728
5d08e07de3 added pass details screen new and updated create account page and more changes... 2026-02-10 19:05:42 +05:30
Shreeyash Thorat
68c3f28d76 itnerary 2026-02-10 15:05:38 +05:30
mystery012728
3a08830cce updated iternary api and updated buy pass flow 2026-02-10 13:58:58 +05:30
mystery012728
0c663bdec7 pull taken of shreeyash and conflict solved 2026-02-10 10:44:19 +05:30
mystery012728
e91d24becc pull taken of shreeyash and conflict solved 2026-02-09 10:55:36 +05:30
Shreeyash Thorat
09726eb4e6 API Integration 2026-02-06 19:34:34 +05:30
mystery012728
10eae3577f added payment api for passes and more 2026-02-06 19:01:49 +05:30
mystery012728
460f553aee added payment api for passes and more 2026-02-06 18:58:58 +05:30
mystery012728
a7548ccebd added apply coupon with api intigration and more fixes 2026-02-05 19:35:01 +05:30
mystery012728
c2ffc9d9a7 my Post Cards added with get api and more changes 2026-02-05 12:07:33 +05:30
mystery012728
082bb9b74a razer pay added and more chnages added 2026-01-30 19:27:06 +05:30
mystery012728
fa4f78bceb added offers , offer details and pass details api and more chnages 2026-01-29 19:32:11 +05:30
mystery012728
0434b16bde added userdetails api get and put and more changes 2026-01-28 19:28:37 +05:30
mystery012728
1cb344738e refresh token api integreted and isLogin created in local storages 2026-01-27 18:47:15 +05:30
mystery012728
f5782f6da1 api integrtaion send Otp ,verify otp and faq , Terms ,Policy 2026-01-23 19:00:55 +05:30
mystery012728
bbb96512d1 added local preferance for selectCityID and more fixes 2026-01-21 19:02:23 +05:30
mystery012728
a55510a482 Api Integrated in regitsered home page and in attarction and attraction details pages and there are chnages are there from backend they are pending. 2026-01-19 19:10:14 +05:30
mystery012728
d3abf4053a Added api of upcoming cities and cities and selection cities 2026-01-16 19:18:42 +05:30
mystery012728
aac65c57be city_list_model added 2026-01-16 12:32:24 +05:30
840a81f09e Update: Started API integration 2026-01-16 12:27:15 +05:30
mystery012728
c62c725410 added retry hit api after timeouts 2026-01-12 12:48:59 +05:30
mystery012728
a2bad0f139 Added NetworkApiService File 2026-01-08 17:07:53 +05:30
mystery012728
22f2de1bbe Flexi rename into Selective and mad taht dynamic and bug fixes 2026-01-07 18:27:24 +05:30
mystery012728
b77bcea769 Bug fixes and error solved 2026-01-07 14:45:24 +05:30
ada6040514 Fix :- Fixed the grey screen bug in get a pass and cart section. 2026-01-06 15:34:37 +05:30
3c05534262 Merge remote-tracking branch 'origin/vinayak' into dinesh
# Conflicts:
#	android/app/src/main/res/drawable-hdpi/android12splash.png
#	android/app/src/main/res/drawable-mdpi/android12splash.png
#	android/app/src/main/res/drawable-night-hdpi/android12splash.png
#	android/app/src/main/res/drawable-night-mdpi/android12splash.png
#	android/app/src/main/res/drawable-night-xhdpi/android12splash.png
#	android/app/src/main/res/drawable-night-xxhdpi/android12splash.png
#	android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png
#	android/app/src/main/res/drawable-v21/launch_background.xml
#	android/app/src/main/res/drawable-xhdpi/android12splash.png
#	android/app/src/main/res/drawable-xxhdpi/android12splash.png
#	android/app/src/main/res/drawable-xxxhdpi/android12splash.png
#	android/app/src/main/res/drawable/launch_background.xml
#	android/app/src/main/res/values-night-v31/styles.xml
#	android/app/src/main/res/values-v31/styles.xml
#	lib/core/route_constants.dart
#	pubspec.yaml
2025-11-14 11:21:36 +05:30
3caa52d9f8 vinayak's pull merged 2025-11-11 11:14:22 +05:30
275 changed files with 30174 additions and 6917 deletions

View File

@@ -12,7 +12,7 @@ A few resources to get you started if this is your first Flutter project:
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
[online documentation](https://docs.flutter.dev/),which offers tutorials,
samples, guidance on mobile development, and a full API reference.
<h1>Figma Link</h1>

View File

@@ -21,7 +21,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.citycards_customer.citycards_customer"
applicationId = "com.citycard.customer"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@@ -35,10 +35,16 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
flutter {
source = "../.."
}
}

15
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,15 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# Keep Stripe Push Provisioning classes
-keep class com.stripe.android.pushProvisioning.** { *; }
-dontwarn com.stripe.android.pushProvisioning.**
# Keep Stripe SDK
-keep class com.stripe.android.** { *; }
-dontwarn com.stripe.android.**
# Keep React Native Stripe SDK
-keep class com.reactnativestripesdk.** { *; }
-dontwarn com.reactnativestripesdk.**

View File

@@ -1,5 +1,5 @@
package com.citycards_customer.citycards_customer
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterActivity()
class MainActivity : FlutterFragmentActivity()

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
</layer-list>

Binary file not shown.

BIN
assets/icons/calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 B

BIN
assets/icons/person.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
assets/icons/time.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
assets/images/not_login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
assets/logo/logoframe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
platform :ios, '16.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

99
ios/Podfile.lock Normal file
View File

@@ -0,0 +1,99 @@
PODS:
- Flutter (1.0.0)
- flutter_angle (0.3.8):
- Flutter
- FlutterAngle (~> 0.0.8)
- FlutterMacOS
- flutter_native_splash (2.4.3):
- Flutter
- FlutterAngle (0.0.8)
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- Google-Maps-iOS-Utils (6.1.0):
- GoogleMaps (~> 9.0)
- google_maps_flutter_ios (0.0.1):
- Flutter
- Google-Maps-iOS-Utils (< 7.0, >= 5.0)
- GoogleMaps (< 10.0, >= 8.4)
- GoogleMaps (9.4.0):
- GoogleMaps/Maps (= 9.4.0)
- GoogleMaps/Maps (9.4.0)
- image_picker_ios (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- three_js_sensors (0.1.2):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_angle (from `.symlinks/plugins/flutter_angle/darwin`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/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`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- three_js_sensors (from `.symlinks/plugins/three_js_sensors/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
SPEC REPOS:
trunk:
- FlutterAngle
- Google-Maps-iOS-Utils
- GoogleMaps
EXTERNAL SOURCES:
Flutter:
:path: Flutter
flutter_angle:
:path: ".symlinks/plugins/flutter_angle/darwin"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/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"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
three_js_sensors:
:path: ".symlinks/plugins/three_js_sensors/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_angle: 7b1a2b3e733221bf2e0325e42fc3edf95b5d44c4
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
FlutterAngle: c810891af800750361b1d0e7cc944f2338d5ae18
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
google_maps_flutter_ios: 0291eb2aa252298a769b04d075e4a9d747ff7264
GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
three_js_sensors: f516b092803411e05b1e3dc7625efa36acd8f455
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
PODFILE CHECKSUM: 1857a7cdb7dfafe45f2b0e9a9af44644190f7506
COCOAPODS: 1.16.2

View File

@@ -7,10 +7,12 @@
objects = {
/* Begin PBXBuildFile section */
00C1AB7B0C8F1922F3F1AE65 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54C8901E9D1856D980DFFE46 /* Pods_Runner.framework */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
81D638B66EB4658C8192CA0D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 445696AB37183A7C63CB7E98 /* Pods_RunnerTests.framework */; };
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 */; };
@@ -45,6 +47,10 @@
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
445696AB37183A7C63CB7E98 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
4FD33ADDA221C4BBA29FA3D6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
54C8901E9D1856D980DFFE46 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
626B072D1717B50A277DA3C7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -55,6 +61,10 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B691822B373AD22ECA93B798 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
C1FCB3EF88270ED76DFA3FBD /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
D56ABB8F306EF9F6809C0C1E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
E2E6DC2B6718F55E3BF165E7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -62,6 +72,15 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
00C1AB7B0C8F1922F3F1AE65 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CF8A29BE993C0C902CB143AF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
81D638B66EB4658C8192CA0D /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -76,6 +95,28 @@
path = RunnerTests;
sourceTree = "<group>";
};
5D45FB84C63476582408C414 /* Frameworks */ = {
isa = PBXGroup;
children = (
54C8901E9D1856D980DFFE46 /* Pods_Runner.framework */,
445696AB37183A7C63CB7E98 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
6D4A73F1E55857ADBD000C6A /* Pods */ = {
isa = PBXGroup;
children = (
B691822B373AD22ECA93B798 /* Pods-Runner.debug.xcconfig */,
4FD33ADDA221C4BBA29FA3D6 /* Pods-Runner.release.xcconfig */,
D56ABB8F306EF9F6809C0C1E /* Pods-Runner.profile.xcconfig */,
E2E6DC2B6718F55E3BF165E7 /* Pods-RunnerTests.debug.xcconfig */,
626B072D1717B50A277DA3C7 /* Pods-RunnerTests.release.xcconfig */,
C1FCB3EF88270ED76DFA3FBD /* Pods-RunnerTests.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -94,6 +135,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
6D4A73F1E55857ADBD000C6A /* Pods */,
5D45FB84C63476582408C414 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -128,8 +171,10 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
BC66FA7BADCD3982DC87655E /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
CF8A29BE993C0C902CB143AF /* Frameworks */,
);
buildRules = (
);
@@ -145,12 +190,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
3825EC0F330C0B58EA2A8981 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
41FC0A605EBADE26C841287E /* [CP] Embed Pods Frameworks */,
D10E98BB568B7005161E1ABD /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -222,6 +270,28 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3825EC0F330C0B58EA2A8981 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -238,6 +308,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
41FC0A605EBADE26C841287E /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -253,6 +340,45 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
BC66FA7BADCD3982DC87655E /* [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;
};
D10E98BB568B7005161E1ABD /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -361,24 +487,35 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = A89AY6VY4F;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "CityCard Customer";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.citycardscustomer.citycardsCustomer;
PRODUCT_BUNDLE_IDENTIFIER = com.citycard.customer;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = E2E6DC2B6718F55E3BF165E7 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -396,6 +533,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 626B072D1717B50A277DA3C7 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -411,6 +549,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C1FCB3EF88270ED76DFA3FBD /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -475,7 +614,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
ONLY_ACTIVE_ARCH = NO;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -526,6 +665,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -541,19 +681,29 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = A89AY6VY4F;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "CityCard Customer";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.citycardscustomer.citycardsCustomer;
PRODUCT_BUNDLE_IDENTIFIER = com.citycard.customer;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@@ -564,18 +714,28 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = A89AY6VY4F;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "CityCard Customer";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.citycardscustomer.citycardsCustomer;
PRODUCT_BUNDLE_IDENTIFIER = com.citycard.customer;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;

View File

@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -17,17 +19,29 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<string>3</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>We need access to your camera for taking photos for profile and to build a postcard.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Citycard customer needs your location to find the closest place you can visit.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Citycard customer needs your location to find the closest place you can visit.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to your camera for taking photos for profile and to build a postcard.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@@ -41,11 +55,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_stripe/flutter_stripe.dart';
import '../repository/stripe_service.dart';
import 'stripe_payment_event.dart';
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...',
));
/// Stripe expects smallest currency unit
/// USD → cents, INR → paise
final int stripeAmount = (event.amount * 100).toInt();
// 1⃣ Create PaymentIntent from backend
final clientSecret = await _stripeService.createPaymentIntent(
amount: stripeAmount,
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,
),
);
emit(const StripePaymentSheetReady());
emit(const StripePaymentLoading(
message: 'Processing payment...',
));
// 3⃣ Show Payment Sheet
await Stripe.instance.presentPaymentSheet();
// ✅ SUCCESS - Mark as completed
_paymentCompleted = true;
emit(const StripePaymentSuccess());
} on StripeException catch (e) {
_handleStripeException(e, emit);
} catch (e) {
emit(StripePaymentFailure(
error: 'An unexpected error occurred: ${e.toString()}',
isRetryable: true,
));
}
}
/// 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...',
));
// 1⃣ Init Payment Sheet with clientSecret from backend
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: event.clientSecret,
merchantDisplayName: "CityCards",
style: ThemeMode.light,
),
);
emit(const StripePaymentSheetReady());
emit(const StripePaymentLoading(
message: 'Processing payment...',
));
// 2⃣ Show Payment Sheet
await Stripe.instance.presentPaymentSheet();
// ✅ SUCCESS - Mark as completed
_paymentCompleted = true;
emit(const StripePaymentSuccess());
} on StripeException catch (e) {
_handleStripeException(e, emit);
} catch (e) {
emit(StripePaymentFailure(
error: 'An unexpected error occurred: ${e.toString()}',
isRetryable: true,
));
}
}
/// Handle payment cancellation
void _onCancelPayment(
CancelPaymentEvent event,
Emitter<StripePaymentState> emit,
) {
// Only emit cancelled if not already completed
if (!_paymentCompleted) {
emit(const StripePaymentCancelled(
message: 'Payment cancelled by user',
));
}
}
/// Handle payment retry
Future<void> _onRetryPayment(
RetryPaymentEvent event,
Emitter<StripePaymentState> emit,
) async {
// 🔄 Reset completion flag for retry
_paymentCompleted = false;
// Reset state first
emit(const StripePaymentInitial());
// Then initiate payment again
add(InitiatePaymentWithClientSecret(
clientSecret: event.clientSecret,
));
}
/// Reset payment state back to initial
void _onResetPaymentState(
ResetPaymentState event,
Emitter<StripePaymentState> emit,
) {
// 🔄 Reset completion flag
_paymentCompleted = false;
emit(const StripePaymentInitial());
}
/// Centralized Stripe exception handling
void _handleStripeException(
StripeException e,
Emitter<StripePaymentState> emit,
) {
final errorCode = e.error.code;
final errorMessage = e.error.localizedMessage ?? 'Payment failed';
// Handle cancellation separately
if (errorCode == FailureCode.Canceled) {
emit(StripePaymentCancelled(
message: errorMessage,
));
return;
}
// Handle different error types
switch (errorCode) {
case FailureCode.Failed:
emit(StripePaymentFailure(
error: errorMessage,
errorCode: errorCode.toString(),
isRetryable: true,
));
break;
case FailureCode.Timeout:
emit(const StripePaymentFailure(
error: 'Payment timed out. Please try again.',
errorCode: 'timeout',
isRetryable: true,
));
break;
default:
emit(StripePaymentFailure(
error: errorMessage,
errorCode: errorCode?.toString(),
isRetryable: _isRetryableError(errorCode),
));
}
}
/// Determine if an error is retryable
bool _isRetryableError(FailureCode? errorCode) {
if (errorCode == null) return true;
// Non-retryable errors
const nonRetryableErrors = [
// Add specific non-retryable error codes here if needed
];
return !nonRetryableErrors.contains(errorCode);
}
@override
Future<void> close() {
// Reset flag on bloc disposal
_paymentCompleted = false;
return super.close();
}
}

View File

@@ -0,0 +1,55 @@
import 'package:equatable/equatable.dart';
abstract class StripePaymentEvent extends Equatable {
const StripePaymentEvent();
@override
List<Object?> get props => [];
}
class InitiatePayment extends StripePaymentEvent {
final double amount;
final String currency;
const InitiatePayment({
required this.amount,
required this.currency,
});
@override
List<Object?> get props => [amount, currency];
}
/// Event to initiate payment with clientSecret from backend
class InitiatePaymentWithClientSecret extends StripePaymentEvent {
final String clientSecret;
const InitiatePaymentWithClientSecret({
required this.clientSecret,
});
@override
List<Object?> get props => [clientSecret];
}
/// Event to cancel ongoing payment
class CancelPaymentEvent extends StripePaymentEvent {
const CancelPaymentEvent();
}
/// Event to reset payment state back to initial
class ResetPaymentState extends StripePaymentEvent {
const ResetPaymentState();
}
/// Event to retry failed payment
class RetryPaymentEvent extends StripePaymentEvent {
final String clientSecret;
const RetryPaymentEvent({
required this.clientSecret,
});
@override
List<Object?> get props => [clientSecret];
}

View File

@@ -0,0 +1,96 @@
import 'package:equatable/equatable.dart';
abstract class StripePaymentState extends Equatable {
const StripePaymentState();
@override
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({
this.message,
});
@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];
}
/// Payment was cancelled by user
class StripePaymentCancelled extends StripePaymentState {
final String message;
const StripePaymentCancelled({
this.message = 'Payment Cancelled',
});
@override
List<Object?> get props => [message];
}
/// Payment requires additional authentication (3D Secure, etc.)
class StripePaymentRequiresAction extends StripePaymentState {
final String message;
const StripePaymentRequiresAction({
this.message = 'Additional authentication required',
});
@override
List<Object?> get props => [message];
}
/// Payment is processing on the backend
class StripePaymentProcessing extends StripePaymentState {
final String message;
const StripePaymentProcessing({
this.message = 'Payment is being processed...',
});
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,97 @@
import 'package:dio/dio.dart';
class StripeService {
final Dio _dio = Dio(
BaseOptions(
headers: {
"Content-Type": "application/json",
},
),
);
// ⚠️ TEMPORARY FALLBACK - Use secret key directly
// TODO: Remove this and use backend when ready!
final String _stripeSecretKey = ''; // ← ADD YOUR SECRET KEY
Future<String> createPaymentIntent({
required int amount,
required String currency,
}) async {
try {
// 🔥 DIRECT STRIPE API CALL (Temporary fallback)
final response = await _dio.post(
'https://api.stripe.com/v1/payment_intents',
data: {
'amount': amount.toString(),
'currency': currency,
'automatic_payment_methods[enabled]': 'true',
},
options: Options(
headers: {
'Authorization': 'Bearer $_stripeSecretKey',
'Content-Type': 'application/x-www-form-urlencoded',
},
contentType: Headers.formUrlEncodedContentType,
),
);
if (response.data == null || response.data['client_secret'] == null) {
throw Exception('Invalid response from Stripe');
}
return response.data['client_secret'];
} on DioException catch (e) {
if (e.response != null) {
print('Stripe API Error: ${e.response?.data}');
throw Exception('Stripe error: ${e.response?.data['error']?['message'] ?? e.message}');
}
throw Exception('Network error: ${e.message}');
} catch (e) {
print('Payment Intent Error: $e');
throw Exception('Failed to create payment intent: $e');
}
}
}
/*
🔒 PRODUCTION VERSION (Use this when backend is ready):
import 'package:citycards_customer/networkApiServices/api_urls.dart';
import 'package:dio/dio.dart';
class StripeService {
final Dio _dio = Dio(
BaseOptions(
baseUrl: ApiUrls.baseUrl,
headers: {
"Content-Type": "application/json",
},
),
);
Future<String> createPaymentIntent({
required int amount,
required String currency,
}) async {
try {
final response = await _dio.post(
"/create-payment-intent",
data: {
"amount": amount,
"currency": currency,
},
);
if (response.data == null || response.data['clientSecret'] == null) {
throw Exception('Invalid response from server');
}
return response.data['clientSecret'];
} on DioException catch (e) {
throw Exception('Network error: ${e.message}');
} catch (e) {
throw Exception('Failed to create payment intent: $e');
}
}
}
*/

View File

@@ -0,0 +1,475 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../bloc/stripe_payment_bloc.dart';
import '../bloc/stripe_payment_event.dart';
import '../bloc/stripe_payment_state.dart';
import '../repository/stripe_service.dart';
/// 🎯 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;
/// Amount to display (optional)
final double? amount;
/// Currency symbol (default: \$)
final String currencySymbol;
/// 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,
),
);
},
);
}
/// 🚀 Static method to show as full screen dialog
static Future<bool?> showAsDialog({
required BuildContext context,
required String clientSecret,
double? amount,
String currencySymbol = '\$',
String? title,
String loadingMessage = 'Processing payment...',
String successMessage = 'Payment Successful!',
String failureMessage = 'Payment Failed',
VoidCallback? onPaymentSuccess,
void Function(String error)? onPaymentFailure,
VoidCallback? onPaymentCancelled,
Color primaryColor = const Color(0xFFF95F62),
Color successColor = Colors.green,
Color errorColor = Colors.red,
bool barrierDismissible = false,
bool showCloseButtonDuringLoading = false,
Widget? headerWidget,
Widget? footerWidget,
}) async {
return await showDialog<bool>(
context: context,
barrierDismissible: barrierDismissible,
builder: (dialogContext) {
return BlocProvider(
create: (_) => StripePaymentBloc(stripeService: StripeService())
..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)),
child: Dialog(
backgroundColor: Colors.transparent,
child: StripePaymentScreen(
clientSecret: clientSecret,
amount: amount,
currencySymbol: currencySymbol,
title: title,
loadingMessage: loadingMessage,
successMessage: successMessage,
failureMessage: failureMessage,
onPaymentSuccess: onPaymentSuccess,
onPaymentFailure: onPaymentFailure,
onPaymentCancelled: onPaymentCancelled,
primaryColor: primaryColor,
successColor: successColor,
errorColor: errorColor,
heightRatio: 1.0,
showCloseButtonDuringLoading: showCloseButtonDuringLoading,
headerWidget: headerWidget,
footerWidget: footerWidget,
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<StripePaymentBloc, StripePaymentState>(
// 🔒 CRITICAL: Only listen when state actually changes to prevent duplicate triggers
listenWhen: (previous, current) {
// Don't re-trigger if both states are the same success state
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
debugPrint('⚠️ Preventing duplicate success listener');
return false;
}
return true;
},
listener: (context, state) {
if (state is StripePaymentSuccess) {
debugPrint('✅ Payment Success - Calling callback');
// ✅ Call the callback first
onPaymentSuccess?.call();
// ✅ Then auto-close and return true after 1.5 seconds
Future.delayed(const Duration(milliseconds: 1500), () {
if (context.mounted) {
Navigator.of(context).pop(true);
}
});
} else if (state is StripePaymentFailure) {
debugPrint('❌ Payment Failure - ${state.error}');
onPaymentFailure?.call(state.error);
// Auto-close after 2 seconds on failure
Future.delayed(const Duration(seconds: 2), () {
if (context.mounted) {
Navigator.of(context).pop(false);
}
});
} else if (state is StripePaymentCancelled) {
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),
),
),
child: Stack(
children: [
// Main content
Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Custom header widget
if (headerWidget != null) ...[
headerWidget!,
const SizedBox(height: 16),
],
// Title
if (title != null) ...[
Text(
title!,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
],
// Amount display
if (amount != null) ...[
Text(
'$currencySymbol${amount!.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 32.sp,
fontWeight: FontWeight.w700,
color: primaryColor,
),
),
const SizedBox(height: 24),
],
// Payment status
_buildPaymentStatus(context, state),
// Custom footer widget
if (footerWidget != null) ...[
const SizedBox(height: 16),
footerWidget!,
],
],
),
),
),
// Close button (only show when allowed)
if (_shouldShowCloseButton(state))
Positioned(
top: 16,
right: 16,
child: IconButton(
onPressed: () {
if (state is StripePaymentLoading) {
// Cancel payment if loading
context
.read<StripePaymentBloc>()
.add(CancelPaymentEvent());
} else {
Navigator.of(context).pop(false);
}
},
icon: Icon(
Icons.close,
color: Colors.grey[600],
size: 24,
),
),
),
],
),
);
},
);
}
/// Build payment status widget based on state
Widget _buildPaymentStatus(BuildContext context, StripePaymentState state) {
if (state is StripePaymentLoading) {
return Column(
children: [
CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
),
const SizedBox(height: 24),
Text(
loadingMessage,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF333333),
),
textAlign: TextAlign.center,
),
],
);
} else if (state is StripePaymentSuccess) {
return Column(
children: [
Icon(
Icons.check_circle,
color: successColor,
size: 64,
),
const SizedBox(height: 16),
Text(
successMessage,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
textAlign: TextAlign.center,
),
],
);
} else if (state is StripePaymentFailure) {
return Column(
children: [
Icon(
Icons.error,
color: errorColor,
size: 64,
),
const SizedBox(height: 16),
Text(
failureMessage,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
state.error,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
// Retry payment
context.read<StripePaymentBloc>().add(
RetryPaymentEvent(
clientSecret: clientSecret,
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Retry Payment',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
);
} else if (state is StripePaymentCancelled) {
return Column(
children: [
Icon(
Icons.cancel,
color: Colors.orange,
size: 64,
),
const SizedBox(height: 16),
const Text(
'Payment Cancelled',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
textAlign: TextAlign.center,
),
],
);
}
return const SizedBox.shrink();
}
/// Determine if close button should be shown
bool _shouldShowCloseButton(StripePaymentState state) {
if (state is StripePaymentLoading) {
return showCloseButtonDuringLoading;
}
// Show for failure and cancelled states
return state is StripePaymentFailure || state is StripePaymentCancelled;
}
}

View File

@@ -2,184 +2,270 @@ 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: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';
class AddDetailsView extends StatelessWidget {
AddDetailsView({super.key});
import '../checkout/bloc/pass_purchase_details_bloc.dart';
import '../checkout/bloc/pass_purchase_details_event.dart';
import '../checkout/bloc/pass_purchase_details_state.dart';
class AddDetailsView extends StatefulWidget {
final int bookingId;
const AddDetailsView({super.key, required this.bookingId});
@override
State<AddDetailsView> createState() => _AddDetailsViewState();
}
class _AddDetailsViewState extends State<AddDetailsView> {
final TextEditingController firstNameController = TextEditingController();
final TextEditingController lastNameController = TextEditingController();
final TextEditingController emailController = TextEditingController();
final TextEditingController phoneController = TextEditingController();
final TextEditingController addressController = TextEditingController();
final TextEditingController cityController = TextEditingController();
String? selectedCountry;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showCart: false,
showDivider: true,
),
Row(
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Icon(Icons.arrow_back, size: 24.sp),
),
SizedBox(width: 8.w),
Text(
"Add details",
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
SizedBox(height: 42.h),
Align(
alignment: Alignment.centerLeft,
child: CustomText(
text: "Tell us about yourself",
size: 18.sp,
weight: FontWeight.w500,
),
),
SizedBox(height: 12.h),
void dispose() {
firstNameController.dispose();
lastNameController.dispose();
emailController.dispose();
phoneController.dispose();
cityController.dispose();
super.dispose();
}
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "First Name",
hint: "Enter your first name",
controller: firstNameController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Last Name",
hint: "Enter your last name",
controller: lastNameController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Email",
hint: "Enter your email address",
controller: emailController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Phone Number",
hint: "Enter your phone number",
controller: phoneController,
),
),
void _handleSubmit(BuildContext context, bool isSubmitting) {
// If already submitting, do nothing
if (isSubmitting) return;
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "City",
hint: "Enter the name of your city",
controller: phoneController,
),
),
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "Country", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: StatefulBuilder(
builder: (context, setState) {
String? selectedCountry;
return DropdownButton<String>(
value: selectedCountry,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select your country",
style: TextStyle(
fontSize: 12.sp,
color: 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(),
);
},
),
),
),
],
),
),
const Spacer(),
CustomFilledButton(
onTap: () {
},
label: "Continue",
width: double.infinity,
),
SizedBox(height: 50.h),
],
),
// Validate inputs
if (firstNameController.text.isEmpty ||
lastNameController.text.isEmpty ||
emailController.text.isEmpty ||
phoneController.text.isEmpty ||
cityController.text.isEmpty ||
selectedCountry == null) {
ScaffoldMessenger.of(context).showSnackBar(
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,
recipientEmail: emailController.text,
recipientPhone: phoneController.text,
city: cityController.text,
country: selectedCountry!,
),
);
}
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => PurchaseDetailsBloc(),
child: BlocConsumer<PurchaseDetailsBloc, PurchaseDetailsState>(
listener: (context, state) {
// Handle API submission success
if (state is PurchaseDetailsSubmitted) {
// Show success message
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text('Gift details submitted successfully!'),
// backgroundColor: Color(0xffF95F62),
// ),
// );
// Navigate back
Navigator.of(context).pop('success');
}
// Handle API submission error
if (state is PurchaseDetailsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Failed to submit details'),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
final isSubmitting = state.isSubmittingDetails;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showCart: false,
showDivider: true,
),
Row(
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Icon(Icons.arrow_back, size: 24.sp),
),
SizedBox(width: 8.w),
Text(
"Add details",
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
SizedBox(height: 42.h),
Align(
alignment: Alignment.centerLeft,
child: CustomText(
text: "Tell us about the recipient",
size: 18.sp,
weight: FontWeight.w500,
),
),
SizedBox(height: 12.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "First Name",
hint: "Enter recipient's first name",
controller: firstNameController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Last Name",
hint: "Enter recipient's last name",
controller: lastNameController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Email",
hint: "Enter recipient's email address",
controller: emailController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Phone Number",
hint: "Enter recipient's phone number",
controller: phoneController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "City",
hint: "Enter the name of the city",
controller: cityController,
),
),
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "Country", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedCountry,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select country",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: (value) {
setState(() {
selectedCountry = value;
});
},
items: ["Australia"]
.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 ? "Submitting..." : "Continue",
width: double.infinity,
),
SizedBox(height: 50.h),
],
),
),
),
),
);
},
),
);
}
}

View File

@@ -1,484 +0,0 @@
import 'package:citycards_customer/attraction_details/share_bottomsheet.dart';
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../core/route_constants.dart';
class AttractionDetailsView extends StatelessWidget {
const AttractionDetailsView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Image.asset(
'assets/images/koh_rong_samloem_banner.png',
height: 377.h,
width: double.infinity,
fit: BoxFit.cover,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(isWhiteLogo: true, isProfilePage: false, showDivider: true,),
SizedBox(height: 10.h),
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(
Icons.arrow_back,
size: 24.sp,
color: Colors.white,
),
),
SizedBox(width: 8.w),
Text(
"Koh Rong Samloem",
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
],
),
),
),
),
Positioned(
bottom: 31.h,
left: 12.w,
child: Text(
"Koh Rong\nSamloem",
style: TextStyle(
color: Colors.white,
fontSize: 44.sp,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
Positioned(
bottom: 31.h,
right: 17.w,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const ShareBottomSheet(),
);
},
child: Container(
height: 36.h,
width: 36.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20.r),
),
child: Center(
child: Icon(
Icons.share_sharp,
color: Colors.black,
size: 18.sp,
),
),
),
),
),
],
),
// About Section
Padding(
padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 30.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"About",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.32.h),
Text(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non...",
style: TextStyle(
color: Color(0xFF262626),
fontWeight: FontWeight.w400,
fontSize: 14.sp,
height: 1.5,
),
),
],
),
),
SizedBox(height: 41.h),
// Booking Section
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"How to make a booking?",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Color(0xFFF95F62)),
),
child: Row(
children: [
Icon(
Icons.call,
color: Color(0xFFF95F62),
size: 32.w,
),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Contact Number",
color: Colors.black.withOpacity(.6),
size: 12.sp,
weight: FontWeight.w500,
),
SizedBox(height: 6.h),
CustomText(
text: "+1012 3456 789",
color: Colors.black,
size: 14.sp,
weight: FontWeight.w600,
),
SizedBox(height: 6.h),
CustomText(
text: "Tap to call",
color: Colors.black.withOpacity(.4),
size: 12.sp,
weight: FontWeight.w400,
),
],
),
),
],
),
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Color(0xFFF95F62)),
),
child: Row(
children: [
Icon(
Icons.email_sharp,
color: Color(0xFFF95F62),
size: 32.w,
),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Email",
color: Colors.black.withOpacity(.6),
size: 12.sp,
weight: FontWeight.w500,
),
SizedBox(height: 6.h),
CustomText(
text: "CityCards24@gmail.com",
color: Colors.black,
size: 14.sp,
weight: FontWeight.w600,
),
SizedBox(height: 6.h),
CustomText(
text: "Tap to email",
color: Colors.black.withOpacity(.4),
size: 12.sp,
weight: FontWeight.w400,
),
],
),
),
],
),
),
SizedBox(height: 16.h),
InkWell(
onTap: (){
Navigator.of(context).pushNamed(RouteConstants.makeBooking);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 24.w,
vertical: 18.h,
),
decoration: BoxDecoration(
color: Color(0xFFF95F62),
borderRadius: BorderRadius.circular(10.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Via CityCards",
size: 16.sp,
weight: FontWeight.w500,
color: Colors.white,
),
SizedBox(height: 8.h),
CustomText(
text: "Create a booking via app",
size: 11.sp,
weight: FontWeight.w400,
color: Colors.white,
),
],
),
),
Icon(
Icons.arrow_forward_ios_outlined,
color: Colors.white,
),
],
),
),
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"What is included",
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4.h),
Wrap(
runSpacing: 16.h,
spacing: 16.w,
children: [
includedBox(
"assets/icons/bus.png",
"Bus",
"Transportation",
),
includedBox(
"assets/icons/clock.png",
"2 day 1 night",
"Duration",
),
includedBox(
"assets/icons/bx_qr.png",
"TAC200812695",
"Product code",
),
],
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"Exact Location",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 8.h),
CustomText(
text: "View the location on map",
size: 12.sp,
color: Colors.black.withOpacity(.6),
),
SizedBox(height: 17.h),
ClipRRect(
borderRadius: BorderRadius.circular(13.54.r),
child: Image.asset(
height: 178.7.h,
width: double.infinity,
"assets/images/attra_detail_map.png",
fit: BoxFit.cover,
),
),
SizedBox(height: 17.h),
CustomText(
text:
"Angkor Mails Hotel \nNR6, Krong Siem Reap Cambodia",
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"People frequently ask",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 15.h),
faqBox(
"About this place",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
),
SizedBox(height: 15.h),
faqBox(
"Term and condition",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
),
SizedBox(height: 15.h),
faqBox(
"Cancellation Policy",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
),
],
),
),
SizedBox(height: 24.h),
],
),
),
),
);
}
Widget includedBox(String icon, String title, String disc) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(10.r),
border: Border.all(color: Color(0xFFFDCDCE)),
),
child: IntrinsicWidth(
child: Row(
children: [
Image.asset(icon, scale: 4),
SizedBox(width: 16.w),
Column(
children: [
CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: Color(0xFF212121),
),
SizedBox(height: 4.h),
CustomText(
text: disc,
size: 11.sp,
weight: FontWeight.w400,
color: Color(0xFF666666),
),
],
),
],
),
),
);
}
Widget faqBox(String title, String desc) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
border: Border.all(color: Color(0xFFFDCDCE)),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
children: [
Row(
children: [
CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: Color(0xFF212121),
),
SizedBox(width: 20.w),
Icon(Icons.arrow_forward_ios_outlined, size: 18.sp),
],
),
SizedBox(height: 9.h),
CustomText(text: desc, size: 11.sp, color: Color(0xFF7D7D7D)),
],
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'attraction_details_event.dart';
import 'attraction_details_state.dart';
import '../repository/attraction_details_repository.dart';
class AttractionDetailsBloc
extends Bloc<AttractionDetailsEvent, AttractionDetailsState> {
final AttractionDetailsRepository repository;
AttractionDetailsBloc({
required this.repository,
}) : super(AttractionDetailsInitial()) {
on<FetchAttractionDetails>(_onFetchAttractionDetails);
}
Future<void> _onFetchAttractionDetails(
FetchAttractionDetails event,
Emitter<AttractionDetailsState> emit,
) async {
emit(AttractionDetailsLoading());
try {
final response = await repository.fetchAttractionDetails(
attractionId: event.attractionId,
);
emit(
AttractionDetailsLoaded(
attractionDetails: response,
),
);
} catch (e) {
emit(
AttractionDetailsError(
message: e.toString(),
),
);
}
}
}

View File

@@ -0,0 +1,19 @@
import 'package:equatable/equatable.dart';
abstract class AttractionDetailsEvent extends Equatable {
const AttractionDetailsEvent();
@override
List<Object?> get props => [];
}
class FetchAttractionDetails extends AttractionDetailsEvent {
final int attractionId;
const FetchAttractionDetails({
required this.attractionId,
});
@override
List<Object?> get props => [attractionId];
}

View File

@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
import '../models/attraction_details_model.dart';
abstract class AttractionDetailsState extends Equatable {
const AttractionDetailsState();
@override
List<Object?> get props => [];
}
class AttractionDetailsInitial extends AttractionDetailsState {}
class AttractionDetailsLoading extends AttractionDetailsState {}
class AttractionDetailsLoaded extends AttractionDetailsState {
final AttractionDetailsModel attractionDetails;
const AttractionDetailsLoaded({
required this.attractionDetails,
});
@override
List<Object?> get props => [attractionDetails];
}
class AttractionDetailsError extends AttractionDetailsState {
final String message;
const AttractionDetailsError({
required this.message,
});
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,246 @@
class AttractionDetailsModel {
final int id;
final String title;
final String description;
final int cityXid;
final int? cardTypeXid;
final int partnerXid;
final String productCode;
final String subTitle;
final String urlSlug;
final bool isBookingRequired;
final bool isPartnerAccess;
final String bookingEmail;
final String bookingPhoneNumber;
final String address;
final double latitudeCoordinate;
final double longitudeCoordinate;
final double ticketPriceAdult;
final double ticketPriceChild;
final int durations;
final int groupSize;
final String ageRange;
final String seoTitle;
final String seoDescription;
final String attractionStatus;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
final List<AttractionGallery> attractionGalleries;
final List<AttractionInclusion> attractionInclusions;
final List<AttractionFaq> attractionFaqs;
AttractionDetailsModel({
required this.id,
required this.title,
required this.description,
required this.cityXid,
this.cardTypeXid,
required this.partnerXid,
required this.productCode,
required this.subTitle,
required this.urlSlug,
required this.isBookingRequired,
required this.isPartnerAccess,
required this.bookingEmail,
required this.bookingPhoneNumber,
required this.address,
required this.latitudeCoordinate,
required this.longitudeCoordinate,
required this.ticketPriceAdult,
required this.ticketPriceChild,
required this.durations,
required this.groupSize,
required this.ageRange,
required this.seoTitle,
required this.seoDescription,
required this.attractionStatus,
required this.isActive,
required this.createdAt,
required this.updatedAt,
required this.attractionGalleries,
required this.attractionInclusions,
required this.attractionFaqs,
});
factory AttractionDetailsModel.fromJson(Map<String, dynamic> json) {
return AttractionDetailsModel(
id: json['id'] ?? 0,
title: json['title'] ?? 'N/A',
description: json['description'] ?? 'N/A',
cityXid: json['cityXid'] ?? 0,
cardTypeXid: json['cardTypeXid'],
partnerXid: json['partnerXid'] ?? 0,
productCode: json['productCode'] ?? 'N/A',
subTitle: json['subTitle'] ?? 'N/A',
urlSlug: json['urlSlug'] ?? 'N/A',
isBookingRequired: json['isBookingRequired'] ?? false,
isPartnerAccess: json['isPartnerAccess'] ?? false,
bookingEmail: json['bookingEmail'] ?? 'N/A',
bookingPhoneNumber: json['bookingPhoneNumber'] ?? 'N/A',
address: json['address'] ?? 'N/A',
latitudeCoordinate: json['latitudeCoordinate'] != null
? (json['latitudeCoordinate'] as num).toDouble()
: 0.0,
longitudeCoordinate: json['longitudeCoordinate'] != null
? (json['longitudeCoordinate'] as num).toDouble()
: 0.0,
ticketPriceAdult: json['ticketPriceAdult'] != null
? (json['ticketPriceAdult'] as num).toDouble()
: 0.0,
ticketPriceChild: json['ticketPriceChild'] != null
? (json['ticketPriceChild'] as num).toDouble()
: 0.0,
durations: json['durations'] ?? 0,
groupSize: json['groupSize'] ?? 0,
ageRange: json['ageRange'] ?? 'N/A',
seoTitle: json['seoTitle'] ?? 'N/A',
seoDescription: json['seoDescription'] ?? 'N/A',
attractionStatus: json['attractionStatus'] ?? 'N/A',
isActive: json['isActive'] ?? false,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: DateTime.now(),
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: DateTime.now(),
attractionGalleries: json['attractionGalleries'] != null
? (json['attractionGalleries'] as List)
.map((e) => AttractionGallery.fromJson(e))
.toList()
: [],
attractionInclusions: json['attractionInclusions'] != null
? (json['attractionInclusions'] as List)
.map((e) => AttractionInclusion.fromJson(e))
.toList()
: [],
attractionFaqs: json['attractionFaqs'] != null
? (json['attractionFaqs'] as List)
.map((e) => AttractionFaq.fromJson(e))
.toList()
: [],
);
}
}
/// =======================
/// Attraction Gallery
/// =======================
class AttractionGallery {
final int id;
final int attractionXid;
final String fileType;
final String filePathUrl;
final String altText;
final bool isCoverImage;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
AttractionGallery({
required this.id,
required this.attractionXid,
required this.fileType,
required this.filePathUrl,
required this.altText,
required this.isCoverImage,
required this.isActive,
required this.createdAt,
required this.updatedAt,
});
factory AttractionGallery.fromJson(Map<String, dynamic> json) {
return AttractionGallery(
id: json['id'] ?? 0,
attractionXid: json['attractionXid'] ?? 0,
fileType: json['fileType'] ?? 'N/A',
filePathUrl: json['filePathUrl'] ?? 'N/A',
altText: json['altText'] ?? 'N/A',
isCoverImage: json['isCoverImage'] ?? false,
isActive: json['isActive'] ?? false,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: DateTime.now(),
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: DateTime.now(),
);
}
}
/// =======================
/// Attraction Inclusion
/// =======================
class AttractionInclusion {
final int id;
final int attractionXid;
final String title;
final String description;
final int? iconXid;
final bool isInclusion;
final bool isActive;
final DateTime createdAt;
final DateTime updatedAt;
AttractionInclusion({
required this.id,
required this.attractionXid,
required this.title,
required this.description,
this.iconXid,
required this.isInclusion,
required this.isActive,
required this.createdAt,
required this.updatedAt,
});
factory AttractionInclusion.fromJson(Map<String, dynamic> json) {
return AttractionInclusion(
id: json['id'] ?? 0,
attractionXid: json['attractionXid'] ?? 0,
title: json['title'] ?? 'N/A',
description: json['description'] ?? 'N/A',
iconXid: json['iconXid'],
isInclusion: json['isInclusion'] ?? false,
isActive: json['isActive'] ?? false,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: DateTime.now(),
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: DateTime.now(),
);
}
}
/// =======================
/// Attraction FAQ
/// =======================
class AttractionFaq {
final int id;
final int attractionXid;
final String faqQuestion;
final String faqAnswer;
final int displayOrder;
final bool isActive;
AttractionFaq({
required this.id,
required this.attractionXid,
required this.faqQuestion,
required this.faqAnswer,
required this.displayOrder,
required this.isActive,
});
factory AttractionFaq.fromJson(Map<String, dynamic> json) {
return AttractionFaq(
id: json['id'] ?? 0,
attractionXid: json['attractionXid'] ?? 0,
faqQuestion: json['faqQuestion'] ?? 'N/A',
faqAnswer: json['faqAnswer'] ?? 'N/A',
displayOrder: json['displayOrder'] ?? 0,
isActive: json['isActive'] ?? false,
);
}
}

View File

@@ -0,0 +1,17 @@
import '../models/attraction_details_model.dart';
import '../../networkApiServices/network_api_services.dart';
import '../../networkApiServices/api_urls.dart';
class AttractionDetailsRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Fetch attraction details by attractionId
Future<AttractionDetailsModel> fetchAttractionDetails({
required int attractionId,
}) async {
final response = await _apiService.getApi(
url: '${ApiUrls.attractionDetails}/$attractionId',
);
return AttractionDetailsModel.fromJson(response.data);
}
}

View File

@@ -0,0 +1,595 @@
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';
import 'package:flutter/material.dart';
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 '../../core/route_constants.dart';
import '../bloc/attraction_details_bloc.dart';
import '../bloc/attraction_details_event.dart';
import '../bloc/attraction_details_state.dart';
import '../repository/attraction_details_repository.dart';
class AttractionDetailsView extends StatelessWidget {
final int? attractionId;
const AttractionDetailsView({
super.key,
required this.attractionId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AttractionDetailsBloc(
repository: AttractionDetailsRepository(),
)..add(FetchAttractionDetails(attractionId: attractionId??0)),
child: BlocBuilder<AttractionDetailsBloc, AttractionDetailsState>(
builder: (context, state) {
if (state is AttractionDetailsLoading) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (state is AttractionDetailsError) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Text(
state.message,
style: TextStyle(color: Colors.red),
),
),
);
}
if (state is AttractionDetailsLoaded) {
final attraction = state.attractionDetails;
final coverImage = attraction.attractionGalleries
.firstWhere(
(gallery) => gallery.isCoverImage,
orElse: () => attraction.attractionGalleries.first,
)
.filePathUrl;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Image.network(
coverImage,
height: 377.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,
);
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 20.w, vertical: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: true,
isProfilePage: false,
showDivider: true,
),
SizedBox(height: 10.h),
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(
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,
),
),
],
),
],
),
),
),
),
Positioned(
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: 44.sp,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Positioned(
bottom: 31.h,
right: 17.w,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) =>
const ShareBottomSheet(),
);
},
child: Container(
height: 36.h,
width: 36.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20.r),
),
child: Center(
child: Icon(
Icons.share_sharp,
color: Colors.black,
size: 18.sp,
),
),
),
),
),
],
),
// About Section
Padding(
padding:
EdgeInsets.only(left: 16.w, right: 16.w, top: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"About",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.32.h),
Text(
attraction.description,
style: TextStyle(
color: Color(0xFF262626),
fontWeight: FontWeight.w400,
fontSize: 14.sp,
height: 1.5,
),
),
],
),
),
SizedBox(height: 41.h),
// Booking Section
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Text(
// "How to make a booking?",
// style: TextStyle(
// fontSize: 18.sp,
// fontWeight: FontWeight.w400,
// ),
// ),
// SizedBox(height: 16.h),
// Container(
// padding: EdgeInsets.symmetric(
// horizontal: 12.w,
// vertical: 12.h,
// ),
// decoration: BoxDecoration(
// borderRadius: BorderRadius.circular(8.r),
// border: Border.all(color: Color(0xFFF95F62)),
// ),
// child: Row(
// children: [
// Icon(
// Icons.call,
// color: Color(0xFFF95F62),
// size: 32.w,
// ),
// SizedBox(width: 16.w),
// Expanded(
// child: Column(
// crossAxisAlignment:
// CrossAxisAlignment.start,
// children: [
// CustomText(
// text: "Contact Number",
// color: Colors.black.withOpacity(.6),
// size: 12.sp,
// weight: FontWeight.w500,
// ),
// SizedBox(height: 6.h),
// CustomText(
// text: attraction.bookingPhoneNumber??"N/A",
// color: Colors.black,
// size: 14.sp,
// weight: FontWeight.w600,
// ),
// SizedBox(height: 6.h),
// CustomText(
// text: "Tap to call",
// color: Colors.black.withOpacity(.4),
// size: 12.sp,
// weight: FontWeight.w400,
// ),
// ],
// ),
// ),
// ],
// ),
// ),
// SizedBox(height: 16.h),
// Container(
// padding: EdgeInsets.symmetric(
// horizontal: 12.w,
// vertical: 12.h,
// ),
// decoration: BoxDecoration(
// borderRadius: BorderRadius.circular(8.r),
// border: Border.all(color: Color(0xFFF95F62)),
// ),
// child: Row(
// children: [
// Icon(
// Icons.email_sharp,
// color: Color(0xFFF95F62),
// size: 32.w,
// ),
// SizedBox(width: 16.w),
// Expanded(
// child: Column(
// crossAxisAlignment:
// CrossAxisAlignment.start,
// children: [
// CustomText(
// text: "Email",
// color: Colors.black.withOpacity(.6),
// size: 12.sp,
// weight: FontWeight.w500,
// ),
// SizedBox(height: 6.h),
// CustomText(
// text: attraction.bookingEmail??"N/A",
// color: Colors.black,
// size: 14.sp,
// weight: FontWeight.w600,
// ),
// SizedBox(height: 6.h),
// CustomText(
// text: "Tap to email",
// color: Colors.black.withOpacity(.4),
// size: 12.sp,
// weight: FontWeight.w400,
// ),
// ],
// ),
// ),
// ],
// ),
// ),
// SizedBox(height: 16.h),
// InkWell(
// onTap: () {
// Navigator.of(context)
// .pushNamed(RouteConstants.makeBooking);
// },
// child: Container(
// padding: EdgeInsets.symmetric(
// horizontal: 24.w,
// vertical: 18.h,
// ),
// decoration: BoxDecoration(
// color: Color(0xFFF95F62),
// borderRadius: BorderRadius.circular(10.r),
// ),
// child: Row(
// mainAxisAlignment:
// MainAxisAlignment.spaceBetween,
// children: [
// Expanded(
// child: Column(
// crossAxisAlignment:
// CrossAxisAlignment.start,
// children: [
// CustomText(
// text: "Via CityCards",
// size: 16.sp,
// weight: FontWeight.w500,
// color: Colors.white,
// ),
// SizedBox(height: 8.h),
// CustomText(
// text: "Create a booking via app",
// size: 11.sp,
// weight: FontWeight.w400,
// color: Colors.white,
// ),
// ],
// ),
// ),
// Icon(
// Icons.arrow_forward_ios_outlined,
// color: Colors.white,
// ),
// ],
// ),
// ),
// ),
// SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"What is included",
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4.h),
// Dynamic Inclusions from API
Wrap(
runSpacing: 16.h,
spacing: 16.w,
children: attraction.attractionInclusions
.where((inclusion) => inclusion.isInclusion)
.map(
(inclusion) => includedBox(
"assets/icons/bus.png",
inclusion.title,
inclusion.description,
),
)
.toList(),
),
SizedBox(height: 30.h),
// Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"Exact Location",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 8.h),
CustomText(
text: "View the location on map",
size: 12.sp,
color: Colors.black.withOpacity(.6),
),
SizedBox(height: 17.h),
Container(
height: 178.7.h,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(13.54.r),
border: Border.all(
color: Colors.grey.withOpacity(0.3),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13.54.r),
child: FlutterMap(
options: MapOptions(
initialCenter: LatLng(
attraction.latitudeCoordinate,
attraction.longitudeCoordinate,
),
initialZoom: 15.0,
interactionOptions: InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.citycards_customer',
),
MarkerLayer(
markers: [
Marker(
point: LatLng(
attraction.latitudeCoordinate,
attraction.longitudeCoordinate,
),
width: 40.w,
height: 40.h,
child: Icon(
Icons.location_on,
color: Color(0xFFF95F62),
size: 40.sp,
),
),
],
),
],
),
),
),
SizedBox(height: 17.h),
CustomText(
text: attraction.address,
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"People frequently ask",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
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: 24.h),
],
),
),
),
);
}
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Text("Something went wrong"),
),
);
},
),
);
}
Widget includedBox(String icon, String title, String disc) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(10.r),
border: Border.all(color: Color(0xFFFDCDCE)),
),
child: Row(
children: [
Image.asset(icon, scale: 4),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: Color(0xFF212121),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 4.h),
CustomText(
text: disc,
size: 11.sp,
weight: FontWeight.w400,
color: Color(0xFF666666),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget faqBox({
required String title,
required String desc,
}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
border: Border.all(color: const Color(0xFFFDCDCE)),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: const Color(0xFF212121),
),
),
SizedBox(width: 20.w),
Icon(
Icons.arrow_forward_ios_outlined,
size: 18.sp,
color: Colors.black,
),
],
),
SizedBox(height: 9.h),
CustomText(
text: desc,
size: 11.sp,
color: const Color(0xFF7D7D7D),
),
],
),
);
}
}

View File

@@ -26,15 +26,18 @@ class ShareBottomSheet extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// drag handle
Container(
height: 4.h,
width: 47.w,
margin: EdgeInsets.only(bottom: 16),
margin: EdgeInsets.only(bottom: 16.h),
decoration: BoxDecoration(
color: Color(0xFF222222),
color: const Color(0xFF222222),
borderRadius: BorderRadius.circular(8),
),
),
// link field
TextField(
readOnly: true,
decoration: InputDecoration(
@@ -51,7 +54,10 @@ class ShareBottomSheet extends StatelessWidget {
),
),
),
SizedBox(height: 20.h),
// grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -67,7 +73,16 @@ class ShareBottomSheet extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(item['icon']!, width: 55.w),
// FIXED SIZE ICON CONTAINER
Container(
width: 55.w,
height: 55.w,
alignment: Alignment.center,
child: Image.asset(
item['icon']!,
fit: BoxFit.contain,
),
),
SizedBox(height: 8.h),
Text(
item['title']!,
@@ -78,26 +93,32 @@ class ShareBottomSheet extends StatelessWidget {
);
},
),
const SizedBox(height: 20),
// page indicator
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
4,
(index) => Container(
(index) => Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 8.w,
height: 8.h,
decoration: BoxDecoration(
color: index == 0 ? Color(0xFF676363) : Colors.white,
border: Border.all(color: Color(0xFF676363)),
color: index == 0
? const Color(0xFF676363)
: Colors.white,
border: Border.all(color: const Color(0xFF676363)),
shape: BoxShape.circle,
),
),
),
),
SizedBox(height: 10.h),
],
),
);
}
}
}

View File

@@ -1,34 +1,42 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/attraction_model.dart';
import '../repository/attractions_repository.dart';
part 'attractions_event.dart';
part 'attractions_state.dart';
import 'attractions_event.dart';
import 'attractions_state.dart';
class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
final AttractionsRepository repository;
AttractionsBloc(this.repository) : super(AttractionsInitial()) {
on<LoadAttractions>((event, emit) {
final attractions = repository.fetchAttractions();
emit(AttractionsLoaded(attractions));
});
on<LoadMyPassAttraction>((event, emit) {
final attractions = repository.fetchMyPassAttraction();
emit(AttractionsLoaded(attractions));
});
on<SearchAttractions>((event, emit) {
if (state is AttractionsLoaded) {
final currentState = state as AttractionsLoaded;
final filtered = currentState.attractions
.where((a) =>
a.title.toLowerCase().contains(event.query.toLowerCase()) ||
a.location.toLowerCase().contains(event.query.toLowerCase()))
.toList();
emit(AttractionsLoaded(filtered));
}
});
AttractionsBloc({required this.repository})
: super(AttractionsInitial()) {
on<FetchAttractionsByCategory>(_onFetchAttractionsByCategory);
}
}
Future<void> _onFetchAttractionsByCategory(
FetchAttractionsByCategory event,
Emitter<AttractionsState> emit,
) async {
emit(AttractionsLoading());
try {
final AttractionsResponse response =
await repository.fetchAttractionsByCategory(
categoryXid: event.categoryXid, // Can be null now
);
emit(
AttractionsLoaded(
attractions: response.attractions ?? [],
categories: response.categories ?? [],
selectedCategoryId: event.categoryXid, // Can be null
),
);
} catch (e) {
emit(
AttractionsError(
e.toString(),
),
);
}
}
}

View File

@@ -1,12 +1,17 @@
part of 'attractions_bloc.dart';
import 'package:equatable/equatable.dart';
abstract class AttractionsEvent {}
abstract class AttractionsEvent extends Equatable {
const AttractionsEvent();
class LoadAttractions extends AttractionsEvent {}
class LoadMyPassAttraction extends AttractionsEvent {}
class SearchAttractions extends AttractionsEvent {
final String query;
SearchAttractions(this.query);
@override
List<Object?> get props => [];
}
class FetchAttractionsByCategory extends AttractionsEvent {
final int? categoryXid; // Make it nullable
const FetchAttractionsByCategory({this.categoryXid}); // Remove required
@override
List<Object?> get props => [categoryXid];
}

View File

@@ -1,10 +1,37 @@
part of 'attractions_bloc.dart';
import 'package:equatable/equatable.dart';
import '../models/attraction_model.dart';
abstract class AttractionsState {}
abstract class AttractionsState extends Equatable {
const AttractionsState();
@override
List<Object?> get props => [];
}
class AttractionsInitial extends AttractionsState {}
class AttractionsLoading extends AttractionsState {}
class AttractionsLoaded extends AttractionsState {
final List<Attraction> attractions;
AttractionsLoaded(this.attractions);
final List<Category> categories;
final int? selectedCategoryId; // Make it nullable
const AttractionsLoaded({
required this.attractions,
required this.categories,
this.selectedCategoryId, // Remove required
});
@override
List<Object?> get props => [attractions, categories, selectedCategoryId];
}
class AttractionsError extends AttractionsState {
final String message;
const AttractionsError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -1,19 +1,299 @@
/* -------------------- RESPONSE -------------------- */
class AttractionsResponse {
final List<Attraction> attractions;
final List<Category> categories;
AttractionsResponse({
required this.attractions,
required this.categories,
});
factory AttractionsResponse.fromJson(Map<String, dynamic> json) {
return AttractionsResponse(
attractions: (json['attractions'] as List<dynamic>?)
?.map((e) => Attraction.fromJson(e))
.toList() ??
[],
categories: (json['categories'] as List<dynamic>?)
?.map((e) => Category.fromJson(e))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'attractions': attractions.map((e) => e.toJson()).toList(),
'categories': categories.map((e) => e.toJson()).toList(),
};
}
}
/* -------------------- ATTRACTION -------------------- */
class Attraction {
final int id;
final String title;
final String location;
final String price;
final String image;
final List<String> tags;
final bool isBookingRequired;
final String description;
final String urlSlug;
final num cityXid;
final num cardTypeXid;
final num partnerXid;
final String productCode;
final bool isBookingRequired;
final bool isPartnerAccess;
final String bookingEmail;
final String bookingPhoneNumber;
final num latitudeCoordinate;
final num longitudeCoordinate;
final String address;
final num? ticketPriceAdult;
final num? ticketPriceChild;
final num durations;
final num groupSize;
final String ageRange;
final String seoTitle;
final String seoDescription;
final String attractionStatus;
final bool isActive;
final String createdAt;
final String updatedAt;
final List<CardModel> cards;
final List<Category> categories;
final List<Gallery> galleries;
Attraction({
required this.id,
required this.title,
required this.location,
required this.price,
required this.image,
required this.tags,
required this.description,
required this.urlSlug,
required this.cityXid,
required this.cardTypeXid,
required this.partnerXid,
required this.productCode,
required this.isBookingRequired,
required this.description
required this.isPartnerAccess,
required this.bookingEmail,
required this.bookingPhoneNumber,
required this.latitudeCoordinate,
required this.longitudeCoordinate,
required this.address,
this.ticketPriceAdult,
this.ticketPriceChild,
required this.durations,
required this.groupSize,
required this.ageRange,
required this.seoTitle,
required this.seoDescription,
required this.attractionStatus,
required this.isActive,
required this.createdAt,
required this.updatedAt,
required this.cards,
required this.categories,
required this.galleries,
});
factory Attraction.fromJson(Map<String, dynamic> json) {
return Attraction(
id: json['id'] ?? 0,
title: json['title'] ?? '',
description: json['description'] ?? '',
urlSlug: json['urlSlug'] ?? '',
cityXid: json['cityXid'] ?? 0,
cardTypeXid: json['cardTypeXid'] ?? 0,
partnerXid: json['partnerXid'] ?? 0,
productCode: json['productCode'] ?? '',
isBookingRequired: json['isBookingRequired'] ?? false,
isPartnerAccess: json['isPartnerAccess'] ?? false,
bookingEmail: json['bookingEmail'] ?? '',
bookingPhoneNumber: json['bookingPhonenumber'] ?? '',
latitudeCoordinate: (json['latitudeCoordinate'] as num?) ?? 0,
longitudeCoordinate: (json['longitudeCoordinate'] as num?) ?? 0,
address: json['address'] ?? '',
ticketPriceAdult: json['ticketPriceAdult'] as num?,
ticketPriceChild: json['ticketPriceChild'] as num?,
durations: json['durations'] ?? 0,
groupSize: json['groupSize'] ?? 0,
ageRange: json['ageRange'] ?? '',
seoTitle: json['seoTitle'] ?? '',
seoDescription: json['seoDescription'] ?? '',
attractionStatus: json['attractionStatus'] ?? '',
isActive: json['isActive'] ?? false,
createdAt: json['createdAt'] ?? '',
updatedAt: json['updatedAt'] ?? '',
cards: (json['cards'] as List<dynamic>?)
?.map((e) => CardModel.fromJson(e))
.toList() ??
[],
categories: (json['categories'] as List<dynamic>?)
?.map((e) => Category.fromJson(e))
.toList() ??
[],
galleries: (json['galleries'] as List<dynamic>?)
?.map((e) => Gallery.fromJson(e))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'urlSlug': urlSlug,
'cityXid': cityXid,
'cardTypeXid': cardTypeXid,
'partnerXid': partnerXid,
'productCode': productCode,
'isBookingRequired': isBookingRequired,
'isPartnerAccess': isPartnerAccess,
'bookingEmail': bookingEmail,
'bookingPhonenumber': bookingPhoneNumber,
'latitudeCoordinate': latitudeCoordinate,
'longitudeCoordinate': longitudeCoordinate,
'address': address,
'ticketPriceAdult': ticketPriceAdult,
'ticketPriceChild': ticketPriceChild,
'durations': durations,
'groupSize': groupSize,
'ageRange': ageRange,
'seoTitle': seoTitle,
'seoDescription': seoDescription,
'attractionStatus': attractionStatus,
'isActive': isActive,
'createdAt': createdAt,
'updatedAt': updatedAt,
'cards': cards.map((e) => e.toJson()).toList(),
'categories': categories.map((e) => e.toJson()).toList(),
'galleries': galleries.map((e) => e.toJson()).toList(),
};
}
/// 🟢 Helper: Cover image URL (UI-safe)
String get coverImageUrl {
if (galleries.isEmpty) return '';
return galleries
.firstWhere(
(g) => g.isCoverImage,
orElse: () => galleries.first,
)
.filePathUrl;
}
}
/* -------------------- CARD -------------------- */
class CardModel {
final int id;
final String title;
final num cardTypeXid;
final num adultPrice;
final num childPrice;
final String cardStatus;
CardModel({
required this.id,
required this.title,
required this.cardTypeXid,
required this.adultPrice,
required this.childPrice,
required this.cardStatus,
});
factory CardModel.fromJson(Map<String, dynamic> json) {
return CardModel(
id: json['id'] ?? 0,
title: json['title'] ?? '',
cardTypeXid: json['cardTypeXid'] ?? 0,
adultPrice: json['adultPrice'] ?? 0,
childPrice: json['childPrice'] ?? 0,
cardStatus: json['cardStatus'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'cardTypeXid': cardTypeXid,
'adultPrice': adultPrice,
'childPrice': childPrice,
'cardStatus': cardStatus,
};
}
}
/* -------------------- GALLERY -------------------- */
class Gallery {
final int id;
final String fileType;
final String filePathUrl;
final String altText;
final bool isCoverImage;
Gallery({
required this.id,
required this.fileType,
required this.filePathUrl,
required this.altText,
required this.isCoverImage,
});
factory Gallery.fromJson(Map<String, dynamic> json) {
return Gallery(
id: json['id'] ?? 0,
fileType: json['fileType'] ?? '',
filePathUrl: json['filePathUrl'] ?? '',
altText: json['altText'] ?? '',
isCoverImage: json['isCoverImage'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'fileType': fileType,
'filePathUrl': filePathUrl,
'altText': altText,
'isCoverImage': isCoverImage,
};
}
bool get hasImage => filePathUrl.isNotEmpty;
}
/* -------------------- CATEGORY -------------------- */
class Category {
final int id;
final String categoryName;
Category({
required this.id,
required this.categoryName,
});
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'] ?? 0,
categoryName: json['categoryName'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'categoryName': categoryName,
};
}
}

View File

@@ -1,113 +1,26 @@
import 'package:citycards_customer/networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
import '../models/attraction_model.dart';
class AttractionsRepository {
List<Attraction> fetchAttractions() {
return [
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_1.jpg",
tags: ["Unlimited Card", "Flexi Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Siem Reap",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_2.jpg",
tags: ["Unlimited Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Dart Palace",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_3.jpg",
tags: ["Unlimited Card", "Flexi Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_4.jpg",
tags: ["Flexi Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Dart Palace",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_5.jpg",
tags: ["Unlimited Card", "Flexi Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
];
}
final NetworkApiService _apiServices = NetworkApiService();
List<Attraction> fetchMyPassAttraction() {
return [
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_1.jpg",
tags: ["Unlimited Card", "Flexi Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Siem Reap",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_2.jpg",
tags: ["Unlimited Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Dart Palace",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_3.jpg",
tags: ["Unlimited Card", "Flexi Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_4.jpg",
tags: ["Flexi Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Dart Palace",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_5.jpg",
tags: ["Unlimited Card", "Flexi Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
];
/// Fetch attractions by categoryXid (optional)
Future<AttractionsResponse> fetchAttractionsByCategory({
int? categoryXid, // Make it nullable
}) async {
try {
// Build URL with or without categoryXid
String url = ApiUrls.attractionsList;
if (categoryXid != null) {
url = '$url?categoryXid=$categoryXid';
}
final response = await _apiServices.getApi(url: url);
return AttractionsResponse.fromJson(response.data);
} catch (e) {
throw Exception('Failed to fetch attractions: $e');
}
}
}
}

View File

@@ -3,8 +3,11 @@ 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 '../../common_packages/custom_search_field.dart';
import '../blocs/attractions_bloc.dart';
import '../blocs/attractions_event.dart';
import '../blocs/attractions_state.dart';
import '../repository/attractions_repository.dart';
import '../widget/attraction_card.dart';
import '../widget/filter_chip.dart';
@@ -17,14 +20,13 @@ class AttractionsPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (_) {
final bloc = AttractionsBloc(AttractionsRepository());
final bloc = AttractionsBloc(
repository: AttractionsRepository(),
);
// 🔥 Trigger event based on source
if (source == "home") {
bloc.add(LoadAttractions());
} else if (source == "qrPass") {
bloc.add(LoadMyPassAttraction());
}
bloc.add(
const FetchAttractionsByCategory(), // No categoryXid parameter
);
return bloc;
},
@@ -41,42 +43,73 @@ class AttractionsPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App bar
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true),
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
backWidget(context, "Your Attraction", Colors.black),
const SizedBox(height: 20),
// 🔍 Search field
// 🔍 Search field (UI kept, logic disabled)
CommonSearchField(
hint: "Search attractions...",
hintColor: Colors.grey.shade500,
onChanged: (value) {
if (value.isEmpty) {
bloc.add(LoadAttractions());
} else {
bloc.add(SearchAttractions(value));
}
// ❌ Search logic intentionally disabled
// UI only, no API call
},
),
const SizedBox(height: 16),
// 🏝 Category chips row
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
buildCategoryChip("Beach"),
buildCategoryChip("Hike"),
buildCategoryChip("Popular"),
buildCategoryChip("Best in Summer"),
],
// 🏖 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(),
),
),
),
// 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 AttractionsLoaded)
// 🙏 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(
@@ -84,7 +117,7 @@ class AttractionsPage extends StatelessWidget {
child: Text(
"No attractions found",
style: TextStyle(
color: Colors.grey[600],
color: Colors.grey,
fontSize: 14.sp,
),
),
@@ -92,17 +125,28 @@ class AttractionsPage extends StatelessWidget {
)
: Column(
children: state.attractions
.map((attraction) => AttractionCard(
attraction: attraction))
.map(
(attraction) => AttractionCard(
attraction: attraction,
),
)
.toList(),
)
else
const Center(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: CircularProgressIndicator(),
),
),
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(),
],
),
),
@@ -112,4 +156,4 @@ class AttractionsPage extends StatelessWidget {
),
);
}
}
}

View File

@@ -1,5 +1,7 @@
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 '../../core/route_constants.dart';
import '../models/attraction_model.dart';
@@ -9,64 +11,94 @@ class AttractionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
/// CARD TITLES (instead of categories)
final List<String> tags = attraction.cards
.map((e) => e.title)
.where((e) => e.isNotEmpty)
.toList();
/// GALLERY IMAGE (handled safely in model)
final String imageUrl = attraction.coverImageUrl;
return InkWell(
onTap: (){
Navigator.of(context).pushNamed(RouteConstants.attractionDetails);
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.attractionDetails,
arguments: attraction.id,
);
},
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
padding: const EdgeInsets.all(12),
margin: EdgeInsets.symmetric(vertical: 8.h, horizontal: 8.w),
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(15),
color: Color(0xffFFF5F5),
borderRadius: BorderRadius.circular(15.r),
color: const Color(0xffFFF5F5),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// IMAGE (network with fallback)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
attraction.image,
height: 94,
width: 94,
borderRadius: BorderRadius.circular(8.r),
child: imageUrl.isNotEmpty
? Image.network(
imageUrl,
height: 94.h,
width: 94.w,
fit: BoxFit.cover,
),
errorBuilder: (_, __, ___) => _imageFallback(),
)
: _imageFallback(),
),
const SizedBox(width: 10),
SizedBox(width: 10.w),
/// CONTENT
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
attraction.title,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 6),
Text(
attraction.location,
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Color(0xff464646),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
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.rich(
TextSpan(
children: [
TextSpan(
text: "from ${attraction.price}",
style: const TextStyle(
fontSize: 12,
text: "from \$${attraction.ticketPriceAdult}",
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
const TextSpan(
TextSpan(
text: "/person",
style: TextStyle(
fontSize: 10,
fontSize: 10.sp,
color: Colors.black,
fontWeight: FontWeight.w400,
),
@@ -74,63 +106,47 @@ class AttractionCard extends StatelessWidget {
],
),
),
const SizedBox(height: 6),
attraction.isBookingRequired == false
? Wrap(
spacing: 6,
children: attraction.tags
.map(
(tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: tag == "Flexi Card"
? const Color(0xffF95FAF).withOpacity(0.1)
: const Color(
0xffF95F62,
).withOpacity(0.1),
border: Border.all(
color: tag == "Flexi Card"
? const Color(0xffF95FAF)
: const Color(0xffF95F62),
),
borderRadius: BorderRadius.circular(20),
),
child: Text(
tag,
style: GoogleFonts.poppins(
fontSize: 11,
color: Color(0xff1A1A1A),
fontWeight: FontWeight.w400,
),
),
),
)
.toList(),
)
: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
SizedBox(height: 6.h),
/// TAGS (CARD TITLES)
Wrap(
spacing: 6.w,
runSpacing: 6.h,
children: tags
.map(
(tag) => Container(
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: tag ==
"${CommonAppText.selectiveCard} Card"
? const Color(0xffF95FAF)
.withOpacity(0.1)
: const Color(0xffF95F62)
.withOpacity(0.1),
border: Border.all(
color: tag ==
"${CommonAppText.selectiveCard} Card"
? const Color(0xffF95FAF)
: const Color(0xffF95F62),
),
decoration: BoxDecoration(
color: Color(0xffC1D2F8),
border: Border.all(
color: Color(0xff2563EB),
),
borderRadius: BorderRadius.circular(20),
),
child: Text(
"Booking Required",
style: GoogleFonts.poppins(
fontSize: 11,
color: Color(0xff1A1A1A),
fontWeight: FontWeight.w400,
),
borderRadius:
BorderRadius.circular(20.r),
),
child: Text(
tag,
style: GoogleFonts.poppins(
fontSize: 11.sp,
color: const Color(0xff1A1A1A),
fontWeight: FontWeight.w400,
),
),
),
)
.toList(),
)
],
),
),
@@ -139,4 +155,18 @@ class AttractionCard extends StatelessWidget {
),
);
}
/// SAME PLACEHOLDER AS BEFORE
Widget _imageFallback() {
return Container(
height: 94.h,
width: 94.w,
color: Colors.grey.shade200,
child: Icon(
Icons.image_not_supported_outlined,
size: 28.sp,
color: Colors.grey,
),
);
}
}

View File

@@ -1,20 +1,33 @@
import "package:flutter/material.dart";
import 'package:flutter/material.dart';
Widget buildCategoryChip(String label) {
return Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xffF95F62),
borderRadius: BorderRadius.circular(40),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
Widget buildCategoryChip(
String label, {
required bool isSelected,
VoidCallback? onTap,
}) {
const Color redColor = Color(0xffF95F62);
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? redColor : redColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(40),
border: Border.all(
color: redColor,
width: 1,
),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : redColor,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../repository/buy_pass_repository.dart';
import 'buy_pass_event.dart';
import 'buy_pass_state.dart';
class BuyPassBloc extends Bloc<BuyPassEvent, BuyPassState> {
final BuyPassRepository repository;
BuyPassBloc({required this.repository}) : super(BuyPassInitial()) {
/// Handle fetch buy pass data event
on<FetchBuyPassData>(_onFetchBuyPassData);
/// Handle change selected card event
on<ChangeSelectedCard>(_onChangeSelectedCard);
/// Handle update adult count event
on<UpdateAdultCount>(_onUpdateAdultCount);
/// Handle update child count event
on<UpdateChildCount>(_onUpdateChildCount);
/// Handle update validity duration event
on<UpdateValidityDuration>(_onUpdateValidityDuration); // ✅ Added
}
/// Fetch buy pass data from repository
Future<void> _onFetchBuyPassData(
FetchBuyPassData event,
Emitter<BuyPassState> emit,
) async {
emit(BuyPassLoading());
try {
final data = await repository.fetchBuyPass();
emit(BuyPassLoaded(data: data));
} catch (e) {
emit(BuyPassError(e.toString()));
}
}
/// Change selected card
void _onChangeSelectedCard(
ChangeSelectedCard event,
Emitter<BuyPassState> emit,
) {
if (state is BuyPassLoaded) {
final currentState = state as BuyPassLoaded;
final newCard = currentState.data.cards[event.cardIndex];
emit(currentState.copyWith(
selectedCardIndex: event.cardIndex,
adultCount: 1, // Reset counts when changing card
childCount: 1,
validityDuration: newCard.minNumber, // ✅ Reset to new card's minNumber
));
}
}
/// Update adult count
void _onUpdateAdultCount(
UpdateAdultCount event,
Emitter<BuyPassState> emit,
) {
if (state is BuyPassLoaded) {
final currentState = state as BuyPassLoaded;
if (event.count >= 0) {
emit(currentState.copyWith(adultCount: event.count));
}
}
}
/// Update child count
void _onUpdateChildCount(
UpdateChildCount event,
Emitter<BuyPassState> emit,
) {
if (state is BuyPassLoaded) {
final currentState = state as BuyPassLoaded;
if (event.count >= 0) {
emit(currentState.copyWith(childCount: event.count));
}
}
}
/// Update validity duration (days/attractions)
void _onUpdateValidityDuration(
UpdateValidityDuration event,
Emitter<BuyPassState> emit,
) {
if (state is BuyPassLoaded) {
final currentState = state as BuyPassLoaded;
final card = currentState.selectedCard;
// Validate that duration is within min and max range
if (event.duration >= card.minNumber && event.duration <= card.maxNumber) {
emit(currentState.copyWith(validityDuration: event.duration));
}
}
}
}

View File

@@ -0,0 +1,32 @@
abstract class BuyPassEvent {}
/// Event to fetch buy pass data from API
class FetchBuyPassData extends BuyPassEvent {}
/// Event to change the selected card pass
class ChangeSelectedCard extends BuyPassEvent {
final int cardIndex;
ChangeSelectedCard(this.cardIndex);
}
/// Event to update adult count
class UpdateAdultCount extends BuyPassEvent {
final int count;
UpdateAdultCount(this.count);
}
/// Event to update child count
class UpdateChildCount extends BuyPassEvent {
final int count;
UpdateChildCount(this.count);
}
/// Event to update validity duration (days/attractions)
class UpdateValidityDuration extends BuyPassEvent {
final int duration;
UpdateValidityDuration(this.duration);
}

View File

@@ -0,0 +1,59 @@
import '../models/buy_pass_model.dart';
abstract class BuyPassState {}
/// Initial state
class BuyPassInitial extends BuyPassState {}
/// Loading state
class BuyPassLoading extends BuyPassState {}
/// Success state with data
class BuyPassLoaded extends BuyPassState {
final BuyPassModel data;
final int selectedCardIndex;
final int adultCount;
final int childCount;
final int validityDuration; // ✅ Added
BuyPassLoaded({
required this.data,
this.selectedCardIndex = 0,
this.adultCount = 1,
this.childCount = 1,
int? validityDuration, // ✅ Added as optional parameter
}) : validityDuration = validityDuration ?? data.cards[selectedCardIndex].minNumber; // ✅ Initialize with minNumber
/// Method to copy state with updated values
BuyPassLoaded copyWith({
BuyPassModel? data,
int? selectedCardIndex,
int? adultCount,
int? childCount,
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, // ✅ Added
);
}
/// Get currently selected card
CardPass get selectedCard => data.cards[selectedCardIndex];
/// Calculate total price
double get totalPrice {
final card = selectedCard;
return ((card.adultPrice * adultCount) + (card.childPrice * childCount)) * validityDuration.toDouble(); // ✅ Multiply by validityDuration
}
}
/// Error state
class BuyPassError extends BuyPassState {
final String message;
BuyPassError(this.message);
}

View File

@@ -0,0 +1,328 @@
import 'dart:convert';
/// ---------- MAIN RESPONSE MODEL ----------
BuyPassModel buyPassModelFromJson(String str) =>
BuyPassModel.fromJson(json.decode(str));
String buyPassModelToJson(BuyPassModel data) =>
json.encode(data.toJson());
class BuyPassModel {
City city;
List<Offer> offers;
List<CardPass> cards;
List<Attraction> attractions;
BuyPassModel({
required this.city,
required this.offers,
required this.cards,
required this.attractions,
});
factory BuyPassModel.fromJson(Map<String, dynamic>? json) {
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(),
);
}
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(),
};
}
/// ---------- CITY ----------
class City {
int id;
String name;
String slug;
String tagLine;
String description;
String bestTimeToVisit;
String priceRange;
num individualTicketAmount;
num cityCardTicketAmount;
HeroBanner heroBanner;
City({
required this.id,
required this.name,
required this.slug,
required this.tagLine,
required this.description,
required this.bestTimeToVisit,
required this.priceRange,
required this.individualTicketAmount,
required this.cityCardTicketAmount,
required this.heroBanner,
});
factory City.fromJson(Map<String, dynamic>? json) {
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,
heroBanner: HeroBanner.fromJson(json['heroBanner']),
);
}
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"slug": slug,
"tagLine": tagLine,
"description": description,
"bestTimeToVisit": bestTimeToVisit,
"priceRange": priceRange,
"individualTicketAmount": individualTicketAmount,
"cityCardTicketAmount": cityCardTicketAmount,
"heroBanner": heroBanner.toJson(),
};
}
/// ---------- HERO BANNER ----------
class HeroBanner {
String title;
String image;
HeroBanner({
required this.title,
required this.image,
});
factory HeroBanner.fromJson(Map<String, dynamic>? json) {
json ??= {};
return HeroBanner(
title: json['title']?.toString() ?? "",
image: json['image']?.toString() ?? "",
);
}
Map<String, dynamic> toJson() => {
"title": title,
"image": image,
};
}
/// ---------- 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;
Offer({
required this.id,
required this.title,
required this.offerCode,
required this.description,
required this.redemptionLink,
required this.websiteBannerImage,
required this.mobileBannerImage,
required this.passType,
required this.startDateTime,
required this.endDateTime,
required this.offerStatus,
required this.applyToPasses,
});
factory Offer.fromJson(Map<String, dynamic>? json) {
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,
);
}
Map<String, dynamic> toJson() => {
"id": id,
"title": title,
"offerCode": offerCode,
"description": description,
"redemptionLink": redemptionLink,
"websiteBannerImage": websiteBannerImage,
"mobileBannerImage": mobileBannerImage,
"passType": passType,
"startDateTime": startDateTime.toIso8601String(),
"endDateTime": endDateTime.toIso8601String(),
"offerStatus": offerStatus,
"applyToPasses": applyToPasses,
};
}
/// ---------- 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;
CardPass({
required this.id,
required this.title,
required this.description,
required this.validityDuration,
required this.adultPrice,
required this.childPrice,
required this.minNumber,
required this.maxNumber,
required this.cardType,
required this.offers,
});
factory CardPass.fromJson(Map<String, dynamic>? json) {
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,
cardType: CardType.fromJson(json['cardType']),
offers: json['offers'] == null
? []
: List<Map<String, dynamic>>.from(json['offers'])
.map((e) => Offer.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() => {
"id": id,
"title": title,
"description": description,
"validityDuration": validityDuration,
"adultPrice": adultPrice,
"childPrice": childPrice,
"minNumber": minNumber,
"maxNumber": maxNumber,
"cardType": cardType.toJson(),
"offers": offers.map((e) => e.toJson()).toList(),
};
}
/// ---------- CARD TYPE ----------
class CardType {
int id;
String name;
String displayName;
CardType({
required this.id,
required this.name,
required this.displayName,
});
factory CardType.fromJson(Map<String, dynamic>? json) {
json ??= {};
return CardType(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name']?.toString() ?? "",
displayName: json['displayName']?.toString() ?? "",
);
}
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"displayName": displayName,
};
}
/// ---------- ATTRACTION ----------
class Attraction {
int id;
String title;
String slug;
String thumbnail;
num startingFrom;
Attraction({
required this.id,
required this.title,
required this.slug,
required this.thumbnail,
required this.startingFrom,
});
factory Attraction.fromJson(Map<String, dynamic>? json) {
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,
);
}
Map<String, dynamic> toJson() => {
"id": id,
"title": title,
"slug": slug,
"thumbnail": thumbnail,
"startingFrom": startingFrom,
};
}

View File

@@ -0,0 +1,43 @@
/// Model to pass checkout data from Buy Pass screen to Checkout screen
import 'package:flutter/material.dart';
class CheckoutData {
final String cityName;
final String heroImage;
final String cardTypeName; // "unlimited_card" or "selective_pass"
final String cardDisplayName; // "Unlimited" or "Selective"
final Color themeColor;
final int adultCount;
final int childCount;
final num adultPrice; // Changed from double to num
final num childPrice; // Changed from double to num
final int validityDuration; // Days or attractions count
final num totalPrice; // Changed from double to num
final String? description;
CheckoutData({
required this.cityName,
required this.heroImage,
required this.cardTypeName,
required this.cardDisplayName,
required this.themeColor,
required this.adultCount,
required this.childCount,
required this.adultPrice,
required this.childPrice,
required this.validityDuration,
required this.totalPrice,
this.description,
});
// Calculate quantity (total adults + children)
int get totalQuantity => adultCount + childCount;
// Check if it's unlimited card
bool get isUnlimitedCard => cardTypeName == "unlimited_card";
// Get validity label
String get validityLabel => isUnlimitedCard
? "$validityDuration Days"
: "$validityDuration Attractions";
}

View File

@@ -0,0 +1,54 @@
import 'package:citycards_customer/localPreference/local_preference.dart';
import '../models/buy_pass_model.dart';
import '../../networkApiServices/network_api_services.dart';
import '../../networkApiServices/api_urls.dart';
class BuyPassRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Fetch Buy A Pass data using selected cityId
Future<BuyPassModel> fetchBuyPass() async {
final int cityId = await LocalPreference.getSelectedCityId();
final response = await _apiService.getApi(
url: '${ApiUrls.buyAPass}/$cityId',
);
return BuyPassModel.fromJson(response.data);
}
/// Add Passes to Cart
Future<Map<String, dynamic>> addToCartPasses({
required int cityXid,
required int cardTypeXid,
required int cardXid,
required String cardMode, // flexi / fixed
required int totalAdult,
required int totalChild,
required int noOfAttractions,
required int noOfDays,
required double baseAmount,
}) async {
try {
final response = await _apiService.postApi(
url: ApiUrls.addToCartPasses,
data: {
"cityXid": cityXid,
"cardTypeXid": cardTypeXid,
"cardXid": cardXid,
"cardMode": cardMode,
"totalAdult": totalAdult,
"totalChild": totalChild,
"baseAmount": baseAmount,
"taxAmount": 2, // Fixed tax amount
"noOfAttractions": noOfAttractions,
"noOfDays": noOfDays,
},
);
return response.data as Map<String, dynamic>;
} catch (e) {
throw Exception('Failed to add passes to cart: $e');
}
}
}

View File

@@ -6,225 +6,494 @@ import 'package:citycards_customer/common_packages/custom_text.dart';
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 '../../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';
class BuyPassView extends StatelessWidget {
BuyPassView({super.key});
const BuyPassView({super.key});
final availableAttraction = [
{"image": "assets/images/aa1.png", "name": "Mystic Falls"},
{"image": "assets/images/aa2.png", "name": "Whispering Pines"},
{"image": "assets/images/aa3.png", "name": "Enchanted Oasis"},
{"image": "assets/images/aa4.png", "name": "Serenity Cove"},
];
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => BuyPassBloc(repository: BuyPassRepository())
..add(FetchBuyPassData()),
child: const BuyPassContent(),
);
}
}
final offers = [
{
"image": "assets/images/aa1.png",
"title": "Astor Hotels Ultra Deluxe",
"description": "15% Discount on all treatments for first-time clients",
},
{
"image": "assets/images/aa2.png",
"title": "Green Valley Spa Lux",
"description": "20% off on spa memberships and treatments",
},
];
class BuyPassContent extends StatelessWidget {
const BuyPassContent({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
child: CommonAppBar(isWhiteLogo: false, isProfilePage: true,showDivider: true,),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
child: Row(
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Icon(Icons.arrow_back),
),
SizedBox(width: 8.w),
CustomText(text: "Buy a Pass", size: 12.sp),
],
),
),
SizedBox(height: 22.h),
Padding(
padding: EdgeInsets.only(left: 20.0.w),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
PassCardView(themeColor: Color(0xFFF95FAF)),
SizedBox(width: 12.w),
PassCardView(themeColor: Color(0xFF1E8AF6)),
],
),
),
),
SizedBox(height: 40.h),
FeatureTable(),
SizedBox(height: 30.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Divider(color: Colors.black.withOpacity(0.1)),
),
SizedBox(height: 30.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
child: CustomText(text: "Available Attractions", size: 18.sp),
),
SizedBox(height: 12.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
...availableAttraction.map((item) {
return Padding(
padding: EdgeInsets.only(right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 104.h,
width: 104.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: Image.asset(
item["image"]!,
fit: BoxFit.cover,
),
),
),
CustomText(text: item["name"]!, size: 12.sp),
],
),
);
}),
],
),
),
),
SizedBox(height: 20.h),
Align(
alignment: Alignment.center,
child: CustomText(
text: "View All",
size: 12.sp,
child: BlocBuilder<BuyPassBloc, BuyPassState>(
builder: (context, state) {
if (state is BuyPassLoading) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFFF95F62),
),
),
SizedBox(height: 30.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Divider(color: Colors.black.withOpacity(0.1)),
),
SizedBox(height: 40.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
);
}
if (state is BuyPassError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CustomText(text: "Card Offers", size: 18.sp),
GestureDetector(
onTap: (){
Navigator.pushNamed(context,RouteConstants.searchOffer);
Icon(Icons.error_outline, size: 60.sp, color: Colors.red),
SizedBox(height: 16.h),
CustomText(
text: "Error loading data",
size: 16.sp,
color: Colors.red,
),
SizedBox(height: 8.h),
CustomText(
text: state.message,
size: 12.sp,
color: Colors.grey,
),
SizedBox(height: 20.h),
ElevatedButton(
onPressed: () {
context.read<BuyPassBloc>().add(FetchBuyPassData());
},
child: CustomText(
text: "View All",
size: 14.sp,
color: Color(0xFFFF5757),
),
child: const Text("Retry"),
),
],
),
),
SizedBox(height: 16.h),
Container(
height: 262.h,
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: GridView.builder(
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.w,
childAspectRatio: 0.66,
),
itemCount: 2,
itemBuilder: (context, index) {
final offer = offers[index];
return Container(
padding: EdgeInsets.symmetric(
horizontal: 6.w,
vertical: 6.h,
);
}
if (state is BuyPassLoaded) {
final data = state.data;
final selectedCard = state.selectedCard;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
child: CommonAppBar(
isWhiteLogo: false,
isProfilePage: true,
showDivider: true,
),
decoration: BoxDecoration(
border: Border.all(
color: Color(0xFFF95F62).withOpacity(.24),
),
borderRadius: BorderRadius.circular(12.sp),
),
child: Column(
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8.sp),
child: Image.asset(
offer["image"] ?? "",
width: double.infinity,
height: 120.5.h,
fit: BoxFit.cover,
),
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: const Icon(Icons.arrow_back),
),
SizedBox(height: 8.h),
CustomText(text: offer["title"] ?? "", size: 18.sp),
SizedBox(height: 8.h),
CustomText(
text: offer["description"] ?? "",
color: Colors.black.withOpacity(.6),
size: 12.sp,
SizedBox(width: 8.w),
CustomText(text: "Buy a Pass", size: 12.sp),
],
),
),
SizedBox(height: 22.h),
// Pass Cards Horizontal List
Padding(
padding: EdgeInsets.only(left: 20.0.w),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(
data.cards.length,
(index) {
final card = data.cards[index];
final isSelected = index == state.selectedCardIndex;
return GestureDetector(
onTap: () {
context.read<BuyPassBloc>().add(
ChangeSelectedCard(index),
);
},
child: Padding(
padding: EdgeInsets.only(right: 12.w),
child: PassCardView(
themeColor: isSelected
? Color(0xFFF97316)
: Color(0xFF1E8AF6),
city: data.city.name,
heroImage: data.city.heroBanner.image,
adultPrice: card.adultPrice,
childPrice: card.childPrice,
cardType: card.cardType.displayName,
description: card.description,
isSelected: isSelected,
),
),
);
},
),
),
),
),
SizedBox(height: 30.h),
// Payment Card
// ✅ UPDATED PAYMENT CARD SECTION IN buy_pass_view.dart
// Replace the existing PaymentCard widget (around line 154) with this:
Center(
child: PaymentCard(
city: data.city.name,
heroImage: data.city.heroBanner.image,
cardType: selectedCard.cardType.name,
cardDisplayName: selectedCard.cardType.displayName,
themeColor: state.selectedCardIndex == 0
? Color(0xFFF97316)
: Color(0xFF1E8AF6),
adultPrice: selectedCard.adultPrice.toDouble(),
childPrice: selectedCard.childPrice.toDouble(),
adults: state.adultCount,
children: state.childCount,
totalPrice: state.totalPrice,
minNumber: selectedCard.minNumber,
maxNumber: selectedCard.maxNumber,
selectedValue: state.validityDuration,
description: selectedCard.description,
// ✅ NEW: Add these 3 required parameters
cityXid: data.city.id,
cardTypeXid: selectedCard.cardType.id,
cardXid: selectedCard.id,
// ✅ END NEW PARAMETERS
onAdultChanged: (count) {
context.read<BuyPassBloc>().add(
UpdateAdultCount(count),
);
},
onChildChanged: (count) {
context.read<BuyPassBloc>().add(
UpdateChildCount(count),
);
},
onValidityChanged: (duration) {
context.read<BuyPassBloc>().add(
UpdateValidityDuration(duration),
);
},
),
),
SizedBox(height: 20.h),
FeatureTable(),
SizedBox(height: 30.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Divider(color: Colors.black.withOpacity(0.1)),
),
SizedBox(height: 40.h),
// Card Offers Section
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Card Offers", size: 18.sp),
GestureDetector(
onTap: () {
Navigator.pushNamed(
context, RouteConstants.searchOffer);
},
child: CustomText(
text: "View All",
size: 14.sp,
color: Color(0xFFFF5757),
),
),
],
),
);
},
),
),
),
SizedBox(height: 16.h),
SizedBox(height: 41.h),
Center(
child: PaymentCard(
city: 'Melbourne',
tag: 'Flexi Card',
oldPrice: 120,
newPrice: 90,
// Offers Grid (from selected card's offers)
if (selectedCard.offers.isNotEmpty)
Container(
height: 262.h,
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,
),
itemCount: selectedCard.offers.length > 2
? 2
: selectedCard.offers.length,
itemBuilder: (context, index) {
final offer = selectedCard.offers[index];
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.offerPassDetail,
arguments: offer.id, // ✅ pass offerId
);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 6.w,
vertical: 6.h,
),
decoration: BoxDecoration(
border: Border.all(
color: const Color(0xFFF95F62).withOpacity(.24),
),
borderRadius: BorderRadius.circular(12.sp),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// Image
ClipRRect(
borderRadius: BorderRadius.circular(8.sp),
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;
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),
/// Title
CustomText(
text: offer.title,
size: 18.sp,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 8.h),
/// Offer Code
CustomText(
text: offer.description??"N/A",
color: Colors.black.withOpacity(.6),
size: 12.sp,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
),
)
else
Container(
height: 100.h,
alignment: Alignment.center,
child: CustomText(
text: "No offers available",
size: 14.sp,
color: Colors.grey,
),
),
SizedBox(height: 30.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Divider(color: Colors.black.withOpacity(0.1)),
),
SizedBox(height: 30.h),
// Available Attractions
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
child: CustomText(
text: "Available Attractions", size: 18.sp),
),
SizedBox(height: 12.h),
if (data.attractions.isNotEmpty)
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: data.attractions.map((attraction) {
return Padding(
padding: EdgeInsets.only(right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 104.h,
width: 104.w,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8.r),
),
child: GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.attractionDetails,
arguments: attraction.id,
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: attraction.thumbnail != null &&
attraction.thumbnail!.isNotEmpty
? Image.network(
attraction.thumbnail!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.location_on,
size: 40.sp,
color: Colors.grey[400],
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: SizedBox(
width: 20.w,
height: 20.w,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
},
)
: Icon(
Icons.location_on,
size: 40.sp,
color: Colors.grey[400],
),
),
),
),
SizedBox(height: 4.h),
SizedBox(
width: 104.w,
child: CustomText(
text: attraction.title,
size: 12.sp,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
),
),
)
else
Container(
height: 100.h,
alignment: Alignment.center,
child: CustomText(
text: "No attractions available",
size: 14.sp,
color: Colors.grey,
),
),
SizedBox(height: 20.h),
GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.attractionsPage,
arguments: "home",
);
},
child: Align(
alignment: Alignment.center,
child: CustomText(
text: "View All",
size: 12.sp,
color: Color(0xFFF95F62),
),
),
),
SizedBox(height: 41.h),
],
),
),
SizedBox(height: 20.h),
],
),
);
}
return const SizedBox();
},
),
),
);
}
}
}

View File

@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../common_packages/common_app_texts.dart';
class FeatureTable extends StatelessWidget {
const FeatureTable({super.key});
@override
Widget build(BuildContext context) {
// Static data using a simple model
final features = [
FeatureModel('Access to attractions', true, true),
FeatureModel('Entry to attractions', true, true),
@@ -14,109 +15,147 @@ class FeatureTable extends StatelessWidget {
FeatureModel('Entry to sites', false, true),
FeatureModel('Access to venues', true, true),
FeatureModel('Entry to events', true, true),
FeatureModel('Access to experiences', true, true),
FeatureModel('Access to experiences', false, true),
FeatureModel('Access to Itinerary creation', false, true),
FeatureModel('Access to postcard creation', false, true),
];
return Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
color: Color(0xFFF3F3F3),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 1,
offset: const Offset(0, 2),
),
],
),
child: Table(
columnWidths: const {
0: FlexColumnWidth(2.5),
1: FlexColumnWidth(1.2),
2: FlexColumnWidth(1.2),
},
children: [
_buildHeaderRow(),
...features.map((f) => _buildFeatureRow(f)).toList(),
],
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
decoration: BoxDecoration(
color: const Color(0xFFF3F3F3),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 2,
offset: Offset(0, 2),
),
],
),
child: Table(
columnWidths: const {
0: FlexColumnWidth(2.7),
1: FlexColumnWidth(1.15),
2: FlexColumnWidth(1.15),
},
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: [
_buildHeaderRow(),
...features.map(_buildFeatureRow).toList(),
],
),
),
);
),
);
}
// Header Row
// HEADER ROW
TableRow _buildHeaderRow() {
return TableRow(
children: [
Padding(
padding: EdgeInsets.symmetric(vertical: 6.h),
padding: EdgeInsets.only(bottom: 12.h),
child: Text(
'Features',
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16.sp),
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 6.h),
child: Center(
child: Text(
'Flexi',
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16.sp),
),
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 6.h),
child: Center(
child: Text(
'Unlimited',
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16.sp),
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15.sp,
),
),
),
_buildHeaderText(CommonAppText.selectiveCard),
_buildHeaderText('Unlimited'),
],
);
}
// Each Feature Row
Widget _buildHeaderText(String text) {
return Padding(
padding: EdgeInsets.only(bottom: 12.h),
child: Center(
child: Text(
text,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14.sp,
),
),
),
);
}
// FEATURE ROW
TableRow _buildFeatureRow(FeatureModel feature) {
return TableRow(
children: [
_buildCell(feature.name),
_buildFeatureCell(feature.name),
_buildIconCell(feature.flexi),
_buildIconCell(feature.unlimited),
],
);
}
// Text cell
Widget _buildCell(String text) {
// FEATURE TEXT WITH BULLET
Widget _buildFeatureCell(String text) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 6.h),
child: Text(text, style: TextStyle(fontSize: 12.sp, color: Colors.black.withOpacity(.8)),),
padding: EdgeInsets.symmetric(vertical: 7.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: 2.h, right: 6.w),
child: Text(
'',
style: TextStyle(fontSize: 18.sp, height: 1),
),
),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 12.5.sp,
color: Colors.black.withOpacity(0.85),
height: 1.35,
),
),
),
],
),
);
}
// Icon cell
// ICON CELL
Widget _buildIconCell(bool isAvailable) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 6.h),
padding: EdgeInsets.symmetric(vertical: 7.h),
child: Center(
child: isAvailable
? Icon(Icons.check_circle, color: Colors.redAccent,size: 16.sp,)
: const Text('', style: TextStyle(color: Colors.black54)),
? Icon(
Icons.check_circle,
color: Colors.redAccent,
size: 16.sp,
)
: Text(
'',
style: TextStyle(
fontSize: 16.sp,
color: Colors.black45,
),
),
),
);
}
}
// Model for feature row
// MODEL
class FeatureModel {
final String name;
final bool flexi;

View File

@@ -5,17 +5,23 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
class PassCardView extends StatelessWidget {
final Color? themeColor;
final String? city;
final int? adultCount;
final int? childCount;
final String? heroImage; // ✅ heroBanner.image from API
final num? adultPrice;
final num? childPrice;
final String? cardType;
final String? description;
final bool isSelected;
const PassCardView({
super.key,
this.themeColor,
this.city,
this.adultCount,
this.childCount,
this.heroImage,
this.adultPrice,
this.childPrice,
this.cardType,
this.description,
this.isSelected = false,
});
@override
@@ -23,143 +29,177 @@ class PassCardView extends StatelessWidget {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color:( themeColor ?? Color(0xFFF95FAF)).withOpacity(0.24)),
border: Border.all(
color: (themeColor ?? const Color(0xFFF95FAF)).withOpacity(0.24),
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(8.r),
),
child: Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Row(
/// -------- 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),
/// -------- CARD DETAILS --------
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r)
),
child: Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 103.w,
height:140.h,
fit: BoxFit.cover,
),
CustomText(
text: city ?? "City",
weight: FontWeight.w500,
size: 16.sp,
),
SizedBox(width: 6.66.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
/// Adult Price
Row(
children: [
CustomText(
text: "Melbourne",
weight: FontWeight.w500,
size: 16.sp,
Text(
"From ",
style: TextStyle(
color: Colors.black.withOpacity(0.6),
fontSize: 11.sp,
fontWeight: FontWeight.w400,
),
),
Row(
children: [
Text(
"From ",
style: TextStyle(
color: Colors.black.withOpacity(0.6),
fontSize: 11.sp,
fontWeight: FontWeight.w400,
),
),
Text(
"\$80",
style: TextStyle(
color:themeColor,
fontWeight: FontWeight.w500,
fontSize: 24.sp,
),
),
Text(
" /Adult",
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,
),
),
Row(
children: [
Text(
"and ",
style: TextStyle(
color: Colors.black.withOpacity(0.6),
fontSize: 11.sp,
fontWeight: FontWeight.w400,
),
),
Text(
"\$10",
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,
),
),
],
),
SizedBox(
width: 193.w,
child: CustomText(
text:
"Dive into an extensive selection of thrilling destinations!",
color: Color(0xFF000000).withOpacity(0.6),
size: 11.sp,
Text(
" /Adult",
style: TextStyle(
color: Colors.black.withOpacity(0.8),
fontSize: 11.sp,
fontWeight: FontWeight.w400,
),
),
],
),
],
),
Container(
width: 35.w,
height: 140.h,
decoration: BoxDecoration(
color: themeColor,
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
),
),
child: RotatedBox(
quarterTurns: -1,
child: Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: "Flexi ",
style: TextStyle(color: Colors.white, fontSize: 16.sp),
),
TextSpan(
text: "Card",
style: TextStyle(color: Colors.white, fontSize: 12.sp),
),
],
/// Child Price
Row(
children: [
Text(
"and ",
style: TextStyle(
color: Colors.black.withOpacity(0.6),
fontSize: 11.sp,
fontWeight: FontWeight.w400,
),
),
Text(
"\$${childPrice ?? 0}",
style: TextStyle(
color: themeColor,
fontWeight: FontWeight.w500,
fontSize: 24.sp,
),
),
Text(
" /child",
style: TextStyle(
color: Colors.black.withOpacity(0.8),
fontSize: 11.sp,
fontWeight: FontWeight.w400,
),
),
],
),
/// Description
SizedBox(
width: 193.w,
child: CustomText(
text: description ??
"Dive into an extensive selection of thrilling destinations!",
color: const Color(0xFF000000).withOpacity(0.6),
size: 11.sp,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
),
);
}
/// -------- CARD TYPE LABEL --------
Container(
width: 35.w,
height: 140.h,
decoration: BoxDecoration(
color: themeColor,
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
),
),
child: RotatedBox(
quarterTurns: -1,
child: Center(
child: Text(
cardType ?? "Pass",
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
);
}
/// -------- FALLBACK ICON --------
Widget _fallbackIcon() {
return Icon(
Icons.card_travel,
size: 40.sp,
color: Colors.grey[400],
);
}
}

View File

@@ -1,158 +1,326 @@
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
import 'package:citycards_customer/core/route_constants.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class PaymentCard extends StatefulWidget {
import '../../localPreference/local_preference.dart';
import '../models/checkout_model.dart';
import '../../checkout/view/checkout_view.dart';
import '../repository/buy_pass_repository.dart'; // ✅ Import repository
class PaymentCard extends StatelessWidget {
final String city;
final String tag;
final double oldPrice;
final double newPrice;
final String heroImage;
final String cardType;
final String cardDisplayName;
final Color themeColor;
final double adultPrice;
final double childPrice;
final int adults;
final int children;
final double totalPrice;
final int minNumber;
final int maxNumber;
final int selectedValue;
final String? description;
final Function(int) onAdultChanged;
final Function(int) onChildChanged;
final Function(int) onValidityChanged;
// ✅ NEW: Required parameters for API call
final int cityXid;
final int cardTypeXid;
final int cardXid;
const PaymentCard({
super.key,
required this.city,
required this.tag,
required this.oldPrice,
required this.newPrice,
required this.heroImage,
required this.cardType,
required this.cardDisplayName,
required this.themeColor,
required this.adultPrice,
required this.childPrice,
required this.adults,
required this.children,
required this.totalPrice,
required this.minNumber,
required this.maxNumber,
required this.selectedValue,
this.description,
required this.onAdultChanged,
required this.onChildChanged,
required this.onValidityChanged,
required this.cityXid, // ✅ NEW
required this.cardTypeXid, // ✅ NEW
required this.cardXid, // ✅ NEW
});
@override
State<PaymentCard> createState() => _PaymentCardState();
}
class _PaymentCardState extends State<PaymentCard> {
int adults = 1;
int children = 1;
@override
Widget build(BuildContext context) {
return Container(
width: 320,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.pinkAccent, width: 1.2),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.pinkAccent.withOpacity(0.1),
blurRadius: 10,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Title
Text(
widget.city,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
final bool isUnlimitedCard = cardType == "unlimited_card";
final bool isSelectivePass = cardType == "selective_pass";
const SizedBox(height: 6),
// Tag
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Color(0xFFF95FAF),
borderRadius: BorderRadius.circular(20),
return Padding(
padding: const EdgeInsets.all(12.0),
child: Container(
width: double.infinity,
padding: EdgeInsets.all(20.sp),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.pinkAccent, width: 1.2),
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.pinkAccent.withOpacity(0.1),
blurRadius: 10,
spreadRadius: 2,
),
child: Text(
widget.tag,
style: const TextStyle(
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CustomText(
text: city,
size: 20.sp,
weight: FontWeight.bold,
),
SizedBox(height: 6.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
decoration: BoxDecoration(
color: Color(0xFFF95FAF),
borderRadius: BorderRadius.circular(20.r),
),
child: CustomText(
text: cardDisplayName,
size: 12.sp,
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
weight: FontWeight.w500,
),
),
),
const SizedBox(height: 16),
// Adult Counter
_buildCounterRow("No. of Adults", adults, (val) {
setState(() => adults = val);
}),
const SizedBox(height: 10),
// Children Counter
_buildCounterRow("No. of Children", children, (val) {
setState(() => children = val);
}),
const Divider(height: 30, thickness: 1),
// Price section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"You Pay",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
SizedBox(height: 16.h),
_buildCounterRow("No. of Adults", adults, onAdultChanged),
SizedBox(height: 10.h),
_buildCounterRow("No. of Children", children, onChildChanged),
SizedBox(height: 10.h),
if (isUnlimitedCard)
_buildDropdownRow(
label: "No. of Days",
value: selectedValue,
onChanged: onValidityChanged,
)
else if (isSelectivePass)
_buildDropdownRow(
label: "No. of Attractions",
value: selectedValue,
onChanged: onValidityChanged,
),
Row(
children: [
Text(
"\$${widget.oldPrice.toStringAsFixed(0)}",
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
decoration: TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
Text(
"\$${widget.newPrice.toStringAsFixed(0)}",
style: const TextStyle(
color: Color(0xFFF95F62),
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
],
),
],
),
Divider(height: 30.h, thickness: 1),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(
text: "You Pay",
size: 16.sp,
weight: FontWeight.w500,
),
CustomText(
text: "\$${totalPrice.toStringAsFixed(0)}",
size: 18.sp,
color: Color(0xFFF95F62),
weight: FontWeight.bold,
),
],
),
SizedBox(height: 20.h),
CustomFilledButton(
onTap: () async {
try {
// ✅ Check login status first
final bool isLoggedIn = await LocalPreference.getLogin();
const SizedBox(height: 20),
// ✅ Create checkout data (needed for both cases)
final checkoutData = CheckoutData(
cityName: city,
heroImage: heroImage,
cardTypeName: cardType,
cardDisplayName: cardDisplayName,
themeColor: themeColor,
adultCount: adults,
childCount: children,
adultPrice: adultPrice,
childPrice: childPrice,
validityDuration: selectedValue,
totalPrice: totalPrice,
description: description,
);
// Proceed Button
CustomFilledButton(
onTap: () {
Navigator.of(
context,
).pushNamed(RouteConstants.checkout);
},
label: "Proceed to Pay",
),
],
// ✅ Save to local preference (for both logged in and guest users)
await LocalPreference.setPassCart(
cityName: city,
heroImage: heroImage,
cardTypeName: cardType,
cardDisplayName: cardDisplayName,
themeColor: themeColor.value,
adultCount: adults,
childCount: children,
adultPrice: adultPrice,
childPrice: childPrice,
validityDuration: selectedValue,
totalPrice: totalPrice,
description: description,
);
if (isLoggedIn) {
// ✅ User is logged in - hit API
final repository = BuyPassRepository();
final response = await repository.addToCartPasses(
cityXid: cityXid,
cardTypeXid: cardTypeXid,
cardXid: cardXid,
cardMode: isSelectivePass ? 'flexi' : 'unlimited',
totalAdult: adults,
totalChild: children,
noOfAttractions: isSelectivePass ? selectedValue : 0,
noOfDays: isUnlimitedCard ? selectedValue : 0,
baseAmount: totalPrice,
);
// ✅ Extract bookingId from response
final int bookingId = response['id'];
// ✅ Navigate to checkout with bookingId
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CheckoutView(bookingId: bookingId),
settings: RouteSettings(
arguments: checkoutData,
),
),
);
}
} else {
// ✅ User is NOT logged in - skip API, navigate directly
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CheckoutView(bookingId: 0), // or 0, depending on your CheckoutView implementation
settings: RouteSettings(
arguments: checkoutData,
),
),
);
}
}
} catch (e) {
// ✅ Show error message
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to proceed: ${e.toString()}'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 3),
),
);
}
}
},
label: "Proceed to Pay",
),
],
),
),
);
}
Widget _buildCounterRow(String label, int value, Function(int) onChanged) {
Widget _buildDropdownRow({
required String label,
required int value,
required Function(int) onChanged,
}) {
List<int> numbersList = List.generate(
maxNumber - minNumber + 1,
(index) => minNumber + index,
);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: 15.sp)),
CustomText(
text: label,
size: 15.sp,
),
Container(
height: 36.h,
width: 88.w,
padding: EdgeInsets.symmetric(horizontal: 14.w),
decoration: BoxDecoration(
color: Color(0xFFF95F62).withValues(alpha: 0.13),
border: Border.all(
color: const Color(0xFFF95F62),
width: 1.4,
),
borderRadius: BorderRadius.circular(16.r),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: value,
isExpanded: true,
icon: Icon(
Icons.keyboard_arrow_down_rounded,
color: const Color(0xFFF95F62),
size: 22.sp,
),
items: numbersList.map((int number) {
return DropdownMenuItem<int>(
value: number,
child: Align(
alignment: Alignment.centerLeft,
child: CustomText(
text: "$number",
size: 16.sp,
weight: FontWeight.bold,
),
),
);
}).toList(),
onChanged: (int? newValue) {
if (newValue != null) {
onChanged(newValue);
}
},
),
),
),
],
);
}
Widget _buildCounterRow(
String label,
int value,
Function(int) onChanged,
) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: label, size: 15.sp),
Row(
children: [
_circleButton(Icons.remove, () {
if (value > 0) onChanged(value - 1);
}),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
"$value",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
),
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: CustomText(
text: "$value",
size: 16.sp,
weight: FontWeight.bold,
),
),
_circleButton(Icons.add, () {
@@ -173,9 +341,9 @@ class _PaymentCardState extends State<PaymentCard> {
shape: BoxShape.circle,
color: Color(0xFFF95F62),
),
padding: const EdgeInsets.all(4),
padding: EdgeInsets.all(4.sp),
child: Icon(icon, color: Colors.white, size: 18.sp),
),
);
}
}
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/my_pass_cart_repository.dart';
import 'my_pass_cart_event.dart';
import 'my_pass_cart_state.dart';
class MyPassCartBloc extends Bloc<MyPassCartEvent, MyPassCartState> {
final MyPassCartRepository repository;
MyPassCartBloc({required this.repository}) : super(const MyPassCartInitial()) {
on<CheckLoginAndFetchEvent>(_onCheckLoginAndFetch);
on<FetchPassCartEvent>(_onFetchPassCart);
on<ClearPassCartEvent>(_onClearPassCart);
}
/// Handle checking login status and fetching cart data accordingly
Future<void> _onCheckLoginAndFetch(
CheckLoginAndFetchEvent event,
Emitter<MyPassCartState> emit,
) async {
try {
if (kDebugMode) {
print('🔍 [BLOC] Checking login status and fetching cart...');
}
emit(const MyPassCartLoading());
// Check if user is logged in
final isLoggedIn = await repository.isUserLoggedIn();
if (kDebugMode) {
print('🔐 [BLOC] User logged in: $isLoggedIn');
}
if (isLoggedIn) {
// User is logged in - fetch from API
if (kDebugMode) {
print('🌐 [BLOC] Fetching cart data from API...');
}
try {
final apiCartData = await repository.fetchMyPassesCart();
// Check if API data is empty
if (apiCartData.cartItems.isEmpty) {
if (kDebugMode) {
print('⚠️ [BLOC] API returned empty cart, checking local data...');
}
// Try to fetch from local if API is empty
final localCartData = await repository.fetchPassesCartByLocal();
if (localCartData != null) {
if (kDebugMode) {
print('✅ [BLOC] Using local cart data as fallback');
}
emit(MyPassCartLoaded(cartData: localCartData));
} else {
if (kDebugMode) {
print(' [BLOC] No local data available, cart is empty');
}
emit(const MyPassCartEmpty());
}
} else {
// API has cart items
if (kDebugMode) {
print('✅ [BLOC] API cart data loaded successfully with ${apiCartData.cartItems.length} items');
}
emit(MyPassCartApiLoaded(apiCartData: apiCartData));
}
} catch (apiError) {
if (kDebugMode) {
print('❌ [BLOC] API error: $apiError, trying local data...');
}
// API failed, try local data as fallback
final localCartData = await repository.fetchPassesCartByLocal();
if (localCartData != null) {
if (kDebugMode) {
print('✅ [BLOC] Using local cart data after API failure');
}
emit(MyPassCartLoaded(cartData: localCartData));
} else {
if (kDebugMode) {
print('❌ [BLOC] No local data available after API failure');
}
emit(MyPassCartError(message: 'Failed to load cart data: ${apiError.toString()}'));
}
}
} else {
// User is not logged in - fetch from local only
if (kDebugMode) {
print('📱 [BLOC] User not logged in, fetching from local storage...');
}
final localCartData = await repository.fetchPassesCartByLocal();
if (localCartData != null) {
if (kDebugMode) {
print('✅ [BLOC] Local cart data loaded successfully');
}
emit(MyPassCartLoaded(cartData: localCartData));
} else {
if (kDebugMode) {
print(' [BLOC] No local cart data available');
}
emit(const MyPassCartEmpty());
}
}
} catch (e) {
if (kDebugMode) {
print('❌ [BLOC] Error in CheckLoginAndFetch: $e');
}
emit(MyPassCartError(message: e.toString()));
}
}
/// Handle fetching pass cart data from local storage
Future<void> _onFetchPassCart(
FetchPassCartEvent event,
Emitter<MyPassCartState> emit,
) async {
try {
if (kDebugMode) {
print('📄 [BLOC] Fetching pass cart from local...');
}
emit(const MyPassCartLoading());
final cartData = await repository.fetchPassesCartByLocal();
if (cartData != null) {
if (kDebugMode) {
print('✅ [BLOC] Cart data loaded successfully');
}
emit(MyPassCartLoaded(cartData: cartData));
} else {
if (kDebugMode) {
print(' [BLOC] Cart is empty');
}
emit(const MyPassCartEmpty());
}
} catch (e) {
if (kDebugMode) {
print('❌ [BLOC] Error fetching cart: $e');
}
emit(MyPassCartError(message: e.toString()));
}
}
/// Handle clearing pass cart
Future<void> _onClearPassCart(
ClearPassCartEvent event,
Emitter<MyPassCartState> emit,
) async {
try {
if (kDebugMode) {
print('📄 [BLOC] Clearing pass cart...');
}
// You can add clearPassCart method to repository if needed
// await repository.clearPassCartFromLocal();
emit(const MyPassCartEmpty());
if (kDebugMode) {
print('✅ [BLOC] Cart cleared successfully');
}
} catch (e) {
if (kDebugMode) {
print('❌ [BLOC] Error clearing cart: $e');
}
emit(MyPassCartError(message: e.toString()));
}
}
}

View File

@@ -0,0 +1,26 @@
import 'package:equatable/equatable.dart';
abstract class MyPassCartEvent extends Equatable {
const MyPassCartEvent();
@override
List<Object?> get props => [];
}
/// Event to check login status and fetch pass cart data accordingly
/// - If logged in: fetch from API
/// - If not logged in: fetch from local
/// - If API returns empty and local data exists: use local data
class CheckLoginAndFetchEvent extends MyPassCartEvent {
const CheckLoginAndFetchEvent();
}
/// Event to fetch pass cart data from local database
class FetchPassCartEvent extends MyPassCartEvent {
const FetchPassCartEvent();
}
/// Event to clear pass cart
class ClearPassCartEvent extends MyPassCartEvent {
const ClearPassCartEvent();
}

View File

@@ -0,0 +1,55 @@
import 'package:equatable/equatable.dart';
import '../../model/my_passes_cart_mode.dart';
abstract class MyPassCartState extends Equatable {
const MyPassCartState();
@override
List<Object?> get props => [];
}
/// Initial state
class MyPassCartInitial extends MyPassCartState {
const MyPassCartInitial();
}
/// Loading state when fetching cart data
class MyPassCartLoading extends MyPassCartState {
const MyPassCartLoading();
}
/// Loaded state with cart data from local storage
class MyPassCartLoaded extends MyPassCartState {
final Map<String, dynamic> cartData;
const MyPassCartLoaded({required this.cartData});
@override
List<Object?> get props => [cartData];
}
/// Loaded state with cart data from API
class MyPassCartApiLoaded extends MyPassCartState {
final MyPassesCartModel apiCartData;
const MyPassCartApiLoaded({required this.apiCartData});
@override
List<Object?> get props => [apiCartData];
}
/// Empty state when no cart data exists
class MyPassCartEmpty extends MyPassCartState {
const MyPassCartEmpty();
}
/// Error state
class MyPassCartError extends MyPassCartState {
final String message;
const MyPassCartError({required this.message});
@override
List<Object?> get props => [message];
}

View File

@@ -1,40 +1,40 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../model/pass_model.dart';
abstract class PassEvent {}
class LoadPasses extends PassEvent {}
abstract class PassState {}
class PassLoading extends PassState {}
class PassLoaded extends PassState {
final List<PassModel> passes;
final double subtotal;
final double discountPercent;
final double total;
PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
}
class PassBloc extends Bloc<PassEvent, PassState> {
PassBloc() : super(PassLoading()) {
on<LoadPasses>((event, emit) {
final passes = [
PassModel(
title: "Melbourne",
imageUrl: "assets/images/city_melbourne.png",
duration: "2 days",
adults: 3,
kids: 3,
quantity: 2,
price: 49.50,
discount: 7.2,
),
];
final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
final discountPercent = passes.first.discount;
final total = subtotal - (subtotal * discountPercent / 100);
emit(PassLoaded(passes, subtotal, discountPercent, total));
});
}
}
// import 'package:flutter_bloc/flutter_bloc.dart';
// import '../model/pass_model.dart';
//
// abstract class PassEvent {}
// class LoadPasses extends PassEvent {}
//
// abstract class PassState {}
// class PassLoading extends PassState {}
// class PassLoaded extends PassState {
// final List<PassModel> passes;
// final double subtotal;
// final double discountPercent;
// final double total;
//
// PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
// }
//
// class PassBloc extends Bloc<PassEvent, PassState> {
// PassBloc() : super(PassLoading()) {
// on<LoadPasses>((event, emit) {
// final passes = [
// PassModel(
// title: "Melbourne",
// imageUrl: "assets/images/city_melbourne.png",
// duration: "2 days",
// adults: 3,
// kids: 3,
// quantity: 2,
// price: 49.50,
// discount: 7.2,
// ),
// ];
//
// final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
// final discountPercent = passes.first.discount;
// final total = subtotal - (subtotal * discountPercent / 100);
// emit(PassLoaded(passes, subtotal, discountPercent, total));
// });
// }
// }

View File

@@ -0,0 +1,207 @@
import 'dart:convert';
/// ---------- MAIN RESPONSE ----------
MyPassesCartModel myPassesCartModelFromJson(String str) =>
MyPassesCartModel.fromJson(json.decode(str));
String myPassesCartModelToJson(MyPassesCartModel data) =>
json.encode(data.toJson());
class MyPassesCartModel {
CartCity city;
List<CartItem> cartItems;
MyPassesCartModel({
required this.city,
required this.cartItems,
});
factory MyPassesCartModel.fromJson(Map<String, dynamic>? json) {
json ??= {};
return MyPassesCartModel(
city: CartCity.fromJson(json['city']),
cartItems: json['cartItems'] == null
? []
: List<Map<String, dynamic>>.from(json['cartItems'])
.map((e) => CartItem.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() => {
"city": city.toJson(),
"cartItems": cartItems.map((e) => e.toJson()).toList(),
};
}
/// ---------- CITY ----------
class CartCity {
int id;
String name;
CartCity({
required this.id,
required this.name,
});
factory CartCity.fromJson(Map<String, dynamic>? json) {
json ??= {};
return CartCity(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name']?.toString() ?? "",
);
}
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
};
}
/// ---------- CART ITEM ----------
class CartItem {
int id;
String bookingNumber;
String cardMode;
int noOfDays;
int noOfAttractions;
int totalAdult;
int totalChild;
num baseAmount;
num totalTaxAmount;
num totalAmount;
String bookingStatus;
bool isForSelf;
String recipientFirstName;
String recipientLastName;
String recipientEmail;
String recipientPhone;
String recipientCity;
String recipientCountry;
String giftMessage;
bool isPaymentRequired;
int couponXid;
num couponDiscountAmount;
num couponDiscountPercent;
String paymentStatus;
String createdAt;
ItemCity city;
CartItem({
required this.id,
required this.bookingNumber,
required this.cardMode,
required this.noOfDays,
required this.noOfAttractions,
required this.totalAdult,
required this.totalChild,
required this.baseAmount,
required this.totalTaxAmount,
required this.totalAmount,
required this.bookingStatus,
required this.isForSelf,
required this.recipientFirstName,
required this.recipientLastName,
required this.recipientEmail,
required this.recipientPhone,
required this.recipientCity,
required this.recipientCountry,
required this.giftMessage,
required this.isPaymentRequired,
required this.couponXid,
required this.couponDiscountAmount,
required this.couponDiscountPercent,
required this.paymentStatus,
required this.createdAt,
required this.city,
});
factory CartItem.fromJson(Map<String, dynamic>? json) {
json ??= {};
return CartItem(
id: (json['id'] as num?)?.toInt() ?? 0,
bookingNumber: json['bookingNumber']?.toString() ?? "",
cardMode: json['cardMode']?.toString() ?? "",
noOfDays: (json['noOfDays'] as num?)?.toInt() ?? 0,
noOfAttractions: (json['noOfAttractions'] as num?)?.toInt() ?? 0,
totalAdult: (json['totalAdult'] as num?)?.toInt() ?? 0,
totalChild: (json['totalChild'] as num?)?.toInt() ?? 0,
baseAmount: json['baseAmount'] ?? 0,
totalTaxAmount: json['totalTaxAmount'] ?? 0,
totalAmount: json['totalAmount'] ?? 0,
bookingStatus: json['bookingStatus']?.toString() ?? "",
isForSelf: json['isForSelf'] ?? false,
recipientFirstName: json['recipientFirstName']?.toString() ?? "",
recipientLastName: json['recipientLastName']?.toString() ?? "",
recipientEmail: json['recipientEmail']?.toString() ?? "",
recipientPhone: json['recipientPhone']?.toString() ?? "",
recipientCity: json['recipientCity']?.toString() ?? "",
recipientCountry: json['recipientCountry']?.toString() ?? "",
giftMessage: json['giftMessage']?.toString() ?? "",
isPaymentRequired: json['isPaymentRequired'] ?? false,
couponXid: (json['couponXid'] as num?)?.toInt() ?? 0,
couponDiscountAmount: json['couponDiscountAmount'] ?? 0,
couponDiscountPercent: json['couponDiscountPercent'] ?? 0,
paymentStatus: json['paymentStatus']?.toString() ?? "",
createdAt: json['createdAt']?.toString() ?? "",
city: ItemCity.fromJson(json['city']),
);
}
Map<String, dynamic> toJson() => {
"id": id,
"bookingNumber": bookingNumber,
"cardMode": cardMode,
"noOfDays": noOfDays,
"noOfAttractions": noOfAttractions,
"totalAdult": totalAdult,
"totalChild": totalChild,
"baseAmount": baseAmount,
"totalTaxAmount": totalTaxAmount,
"totalAmount": totalAmount,
"bookingStatus": bookingStatus,
"isForSelf": isForSelf,
"recipientFirstName": recipientFirstName,
"recipientLastName": recipientLastName,
"recipientEmail": recipientEmail,
"recipientPhone": recipientPhone,
"recipientCity": recipientCity,
"recipientCountry": recipientCountry,
"giftMessage": giftMessage,
"isPaymentRequired": isPaymentRequired,
"couponXid": couponXid,
"couponDiscountAmount": couponDiscountAmount,
"couponDiscountPercent": couponDiscountPercent,
"paymentStatus": paymentStatus,
"createdAt": createdAt,
"city": city.toJson(),
};
}
/// ---------- ITEM CITY ----------
class ItemCity {
int id;
String cityName;
ItemCity({
required this.id,
required this.cityName,
});
factory ItemCity.fromJson(Map<String, dynamic>? json) {
json ??= {};
return ItemCity(
id: (json['id'] as num?)?.toInt() ?? 0,
cityName: json['cityName']?.toString() ?? "",
);
}
Map<String, dynamic> toJson() => {
"id": id,
"cityName": cityName,
};
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/foundation.dart';
import '../../localPreference/local_preference.dart';
import '../../networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
import '../model/my_passes_cart_mode.dart';
class MyPassCartRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Check if user is logged in
Future<bool> isUserLoggedIn() async {
try {
final isLogin = await LocalPreference.getLogin();
if (kDebugMode) {
print('🔐 [REPO] User login status: $isLogin');
}
return isLogin;
} catch (e) {
if (kDebugMode) {
print('❌ [REPO] Error checking login status: $e');
}
return false;
}
}
/// Fetch pass cart data from local database
Future<Map<String, dynamic>?> fetchPassesCartByLocal() async {
try {
if (kDebugMode) {
print('📄 [REPO] Fetching pass cart from local database...');
}
final passCartData = await LocalPreference.getPassCart();
if (passCartData != null) {
if (kDebugMode) {
print('✅ [REPO] Pass cart retrieved successfully');
print('📦 [REPO] Cart details: ${passCartData['card_display_name']} - ${passCartData['city_name']}');
}
return passCartData;
} else {
if (kDebugMode) {
print(' [REPO] No pass cart data found in local database');
}
return null;
}
} catch (e) {
if (kDebugMode) {
print('❌ [REPO] Error fetching pass cart: $e');
}
rethrow;
}
}
/// Fetch pass cart data from API
Future<MyPassesCartModel> fetchMyPassesCart() async {
try {
if (kDebugMode) {
print('🌐 [REPO] Fetching pass cart from API...');
}
final cityID = await LocalPreference.getSelectedCityId();
final response = await _apiService.getApi(
url: '${ApiUrls.myPassesCart}?cityXid=$cityID',
);
if (kDebugMode) {
print('✅ [REPO] API response received');
}
return MyPassesCartModel.fromJson(response.data);
} catch (e) {
if (kDebugMode) {
print('❌ [REPO] Error fetching pass cart from API: $e');
}
rethrow;
}
}
}

View File

@@ -3,9 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../common_packages/back_widget.dart';
import '../blocs/pass_bloc.dart';
import '../blocs/myPassCart/my_pass_cart_bloc.dart';
import '../blocs/myPassCart/my_pass_cart_event.dart';
import '../blocs/postcard_bloc.dart';
import 'my_pass_page_view.dart';
import '../repository/my_pass_cart_repository.dart';
import 'my_pass_cart_page_view.dart';
import 'my_postcard_page_view.dart';
class MyCartPage extends StatefulWidget {
@@ -22,8 +24,14 @@ class _MyCartPageState extends State<MyCartPage> {
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => PassBloc()..add(LoadPasses())),
BlocProvider(create: (_) => PostCardBloc()..add(LoadPostCards())),
BlocProvider(
create: (_) => PostCardBloc()..add(LoadPostCards()),
),
BlocProvider(
create: (_) => MyPassCartBloc(
repository: MyPassCartRepository(),
)..add(const FetchPassCartEvent()),
),
],
child: Scaffold(
backgroundColor: Colors.white,

View File

@@ -0,0 +1,892 @@
import 'package:citycards_customer/cart/views/view_pass_page_view.dart';
import 'package:citycards_customer/checkout/widget/all_coupons_bottomsheet.dart';
import 'package:citycards_customer/common_packages/custom_dashed_line.dart';
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../add_details/add_details_view.dart';
import '../../checkout/widget/pass_purchase_details_bottomsheet.dart';
import '../../login/view/login_email_bottomsheet.dart';
import '../../common_packages/common_app_texts.dart';
import '../../localPreference/local_preference.dart';
import '../blocs/myPassCart/my_pass_cart_bloc.dart';
import '../blocs/myPassCart/my_pass_cart_event.dart';
import '../blocs/myPassCart/my_pass_cart_state.dart';
class MyPassesPage extends StatefulWidget {
const MyPassesPage({super.key});
@override
State<MyPassesPage> createState() => _MyPassesPageState();
}
class _MyPassesPageState extends State<MyPassesPage> {
// For coupon/discount management
String? appliedCouponCode;
double discountPercentage = 0.0;
bool isPurchaseDetailsConfirmed = false;
@override
void initState() {
super.initState();
// Fetch cart data when page loads
context.read<MyPassCartBloc>().add(const CheckLoginAndFetchEvent());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<MyPassCartBloc, MyPassCartState>(
builder: (context, state) {
if (state is MyPassCartLoading) {
return const Center(child: CircularProgressIndicator());
}
// ========== HANDLE API DATA (LOGGED IN USER) ==========
else if (state is MyPassCartApiLoaded) {
final apiCartData = state.apiCartData;
if (apiCartData.cartItems.isEmpty) {
return const Center(child: Text('Your cart is empty'));
}
// Get first cart item (you can modify to handle multiple items)
final cartItem = apiCartData.cartItems.first;
// Extract data from API cart item
final String cityName = cartItem.city.cityName;
final String heroImage = ''; // API doesn't have hero_image
final String cardTypeName = cartItem.cardMode;
final String cardDisplayName = cartItem.cardMode;
final int themeColor = 0xFFF95FAF;
final int adultCount = cartItem.totalAdult;
final int childCount = cartItem.totalChild;
final int validityDuration = cartItem.noOfDays;
final double totalPrice = cartItem.totalAmount.toDouble();
// Calculate pricing
final double subtotal = cartItem.baseAmount.toDouble();
final double discountAmount = cartItem.couponDiscountAmount.toDouble();
final double totalBeforeTax = subtotal - discountAmount;
final double taxAmount = cartItem.totalTaxAmount.toDouble();
final double finalTotal = totalPrice;
// Determine if unlimited card
final bool isUnlimitedCard = cardTypeName.toLowerCase().contains("unlimited");
final String validityLabel = isUnlimitedCard
? "$validityDuration Days"
: "${cartItem.noOfAttractions} Attractions";
return Column(
children: [
SizedBox(height: 22.h),
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: Color(themeColor).withOpacity(0.2),
),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
child: Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
),
),
SizedBox(width: 6.66.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: cityName,
weight: FontWeight.w500,
size: 16.sp,
),
SizedBox(height: 5.h),
CustomText(
text: validityLabel,
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(height: 5.h),
SizedBox(
width: MediaQuery.of(context).size.width * .5,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Image.asset(
'assets/icons/adult.png',
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "$adultCount ${adultCount == 1 ? 'adult' : 'adults'}",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
],
),
Row(
children: [
Image.asset(
'assets/icons/qty.png',
scale: 4,
),
SizedBox(width: 4.w),
Text.rich(
TextSpan(
children: [
TextSpan(
text: "Qty:",
style: TextStyle(
color: Color(0xFF8E8E8E),
fontSize: 12.sp,
),
),
TextSpan(
text: " ${adultCount + childCount}",
style: TextStyle(
color: Color(0xFF000000),
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 5.h),
Row(
children: [
Image.asset(
"assets/icons/kid.png",
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "$childCount ${childCount == 1 ? 'Kid' : 'Kids'}",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(width: 53.w),
CustomText(
text: "\$${totalPrice.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
color: Color(0xFFF95F62),
),
],
),
],
),
],
),
Container(
width: 35.w,
height: 123.h,
decoration: BoxDecoration(
color: Color(themeColor),
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
),
),
child: RotatedBox(
quarterTurns: -1,
child: Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: "$cardDisplayName ",
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
),
],
),
),
),
),
),
],
),
),
SizedBox(height: 15.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: Color(0xFFBB474A).withOpacity(0.4),
width: 0.8,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: (cartItem.couponDiscountAmount > 0 || appliedCouponCode != null)
? "Coupon Applied (${(cartItem.couponDiscountAmount > 0 ? cartItem.couponDiscountPercent : discountPercentage).toStringAsFixed(0)}% off)"
: "Get 10% off on your first trip",
color: Color(0xFF262626),
size: 14.sp,
),
SizedBox(height: 7.h),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => AllCouponsBottomsheet(),
);
},
child: CustomText(
text: "View all coupons",
color: Color(0xFFF95F62),
size: 12,
),
),
SizedBox(width: 3.w),
Icon(Icons.arrow_right, color: Color(0xFFF95F62)),
],
),
],
),
const Spacer(),
// Only show Apply/Remove button if no API coupon is applied
if (cartItem.couponDiscountAmount == 0)
GestureDetector(
onTap: () {
setState(() {
if (appliedCouponCode == null) {
appliedCouponCode = "FIRST10";
discountPercentage = 10.0;
} else {
appliedCouponCode = null;
discountPercentage = 0.0;
}
});
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 10.h,
),
decoration: BoxDecoration(
border: Border.all(color: Color(0xFFF95F62)),
borderRadius: BorderRadius.circular(8.r),
),
child: CustomText(
text: appliedCouponCode != null ? "Remove" : "Apply",
color: Color(0xFFF95F62),
size: 14.sp,
),
),
),
],
),
),
SizedBox(height: 15.h),
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
// Calculate final discount and totals
Builder(
builder: (context) {
// Use API discount if available, otherwise use local discount
final effectiveDiscountAmount = cartItem.couponDiscountAmount > 0
? cartItem.couponDiscountAmount
: (subtotal * (discountPercentage / 100));
final effectiveDiscountPercent = cartItem.couponDiscountAmount > 0
? cartItem.couponDiscountPercent
: discountPercentage;
// Calculate tax on subtotal after discount
final subtotalAfterDiscount = subtotal - effectiveDiscountAmount;
final calculatedTax = subtotalAfterDiscount * 0.01; // 1% tax
final calculatedTotal = subtotalAfterDiscount + calculatedTax;
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Subtotal", size: 14.sp),
CustomText(
text: "\$${subtotal.toStringAsFixed(2)}",
size: 14.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 14.h),
if (effectiveDiscountAmount > 0) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Discount", size: 14.sp),
CustomText(
text: "-\$${effectiveDiscountAmount.toStringAsFixed(2)} (${effectiveDiscountPercent.toStringAsFixed(0)}%)",
size: 14.sp,
weight: FontWeight.w500,
color: Colors.green,
),
],
),
SizedBox(height: 14.h),
],
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: 'Total', size: 14.sp),
SizedBox(height: 4.h),
CustomText(
text: "Including \$${calculatedTax.toStringAsFixed(2)} in taxes",
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
],
),
),
CustomText(
text: "\$${calculatedTotal.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 150.h),
FutureBuilder<bool>(
future: LocalPreference.getLogin(),
builder: (context, snapshot) {
final isLoggedIn = snapshot.data ?? false;
return CustomFilledButton(
onTap: () async {
if (isLoggedIn) {
if (isPurchaseDetailsConfirmed) {
print("✅ Ready to pay: \$${calculatedTotal.toStringAsFixed(2)}");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Payment integration pending'),
backgroundColor: Colors.orange,
),
);
} else {
final result = await PassPurchaseBottomSheet.show(
context,
bookingId: cartItem.id,
);
if (result == 'success') {
setState(() {
isPurchaseDetailsConfirmed = true;
});
} else if (result == 'gift') {
final giftResult = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => AddDetailsView(bookingId: cartItem.id),
),
);
if (giftResult == 'success') {
setState(() {
isPurchaseDetailsConfirmed = true;
});
}
}
}
} else {
Navigator.pop(context);
showModalBottomSheet(
backgroundColor: Colors.white,
context: context,
isScrollControlled: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => const LoginEmailBottomsheet(),
);
}
},
width: double.infinity,
label: isLoggedIn
? (isPurchaseDetailsConfirmed
? "Pay \$${calculatedTotal.toStringAsFixed(2)}"
: "Checkout")
: "Login to Checkout",
);
},
),
SizedBox(height: 25.h),
],
);
},
),
],
);
}
// ========== HANDLE LOCAL DATA (NOT LOGGED IN) ==========
else if (state is MyPassCartLoaded) {
final cartData = state.cartData;
// Extract data from cart
final String cityName = cartData['city_name'] as String? ?? '';
final String heroImage = cartData['hero_image'] as String? ?? '';
final String cardTypeName = cartData['card_type_name'] as String? ?? '';
final String cardDisplayName = cartData['card_display_name'] as String? ?? '';
final int themeColor = cartData['theme_color'] as int? ?? 0xFFF95FAF;
final int adultCount = cartData['adult_count'] as int? ?? 0;
final int childCount = cartData['child_count'] as int? ?? 0;
final double adultPrice = (cartData['adult_price'] as num?)?.toDouble() ?? 0.0;
final double childPrice = (cartData['child_price'] as num?)?.toDouble() ?? 0.0;
final int validityDuration = cartData['validity_duration'] as int? ?? 0;
final double totalPrice = (cartData['total_price'] as num?)?.toDouble() ?? 0.0;
final String? description = cartData['description'] as String?;
// Calculate pricing
final double subtotal = totalPrice;
final double discountAmount = subtotal * (discountPercentage / 100);
final double totalBeforeTax = subtotal - discountAmount;
final double taxAmount = 2;
final double finalTotal = totalBeforeTax + taxAmount;
// Determine if unlimited card
final bool isUnlimitedCard = cardTypeName == "unlimited_card";
final String validityLabel = isUnlimitedCard
? "$validityDuration Days"
: "$validityDuration Attractions";
return Column(
children: [
SizedBox(height: 22.h),
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: Color(themeColor).withOpacity(0.2),
),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
child: heroImage.isNotEmpty
? Image.network(
heroImage,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
);
},
)
: Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
),
),
SizedBox(width: 6.66.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: cityName,
weight: FontWeight.w500,
size: 16.sp,
),
SizedBox(height: 5.h),
CustomText(
text: validityLabel,
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(height: 5.h),
SizedBox(
width: MediaQuery.of(context).size.width * .5,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Image.asset(
'assets/icons/adult.png',
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "$adultCount ${adultCount == 1 ? 'adult' : 'adults'}",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
],
),
Row(
children: [
Image.asset(
'assets/icons/qty.png',
scale: 4,
),
SizedBox(width: 4.w),
Text.rich(
TextSpan(
children: [
TextSpan(
text: "Qty:",
style: TextStyle(
color: Color(0xFF8E8E8E),
fontSize: 12.sp,
),
),
TextSpan(
text: " ${adultCount + childCount}",
style: TextStyle(
color: Color(0xFF000000),
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 5.h),
Row(
children: [
Image.asset(
"assets/icons/kid.png",
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "$childCount ${childCount == 1 ? 'Kid' : 'Kids'}",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(width: 53.w),
CustomText(
text: "\$${totalPrice.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
color: Color(0xFFF95F62),
),
],
),
],
),
],
),
Container(
width: 35.w,
height: 123.h,
decoration: BoxDecoration(
color: Color(themeColor),
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
),
),
child: RotatedBox(
quarterTurns: -1,
child: Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: "$cardDisplayName ",
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
),
],
),
),
),
),
),
],
),
),
SizedBox(height: 15.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: Color(0xFFBB474A).withOpacity(0.4),
width: 0.8,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Get 10% off on your first trip",
color: Color(0xFF262626),
size: 14.sp,
),
SizedBox(height: 7.h),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => AllCouponsBottomsheet(),
);
},
child: CustomText(
text: "View all coupons",
color: Color(0xFFF95F62),
size: 12,
),
),
SizedBox(width: 3.w),
Icon(Icons.arrow_right, color: Color(0xFFF95F62)),
],
),
],
),
const Spacer(),
GestureDetector(
onTap: () {
setState(() {
if (appliedCouponCode == null) {
appliedCouponCode = "FIRST10";
discountPercentage = 10.0;
} else {
appliedCouponCode = null;
discountPercentage = 0.0;
}
});
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 10.h,
),
decoration: BoxDecoration(
border: Border.all(color: Color(0xFFF95F62)),
borderRadius: BorderRadius.circular(8.r),
),
child: CustomText(
text: appliedCouponCode != null ? "Remove" : "Apply",
color: Color(0xFFF95F62),
size: 14.sp,
),
),
),
],
),
),
SizedBox(height: 15.h),
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Subtotal", size: 14.sp),
CustomText(
text: "\$${subtotal.toStringAsFixed(2)}",
size: 14.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 14.h),
if (discountPercentage > 0) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Discount", size: 14.sp),
CustomText(
text: "-\$${discountAmount.toStringAsFixed(2)} (${discountPercentage.toStringAsFixed(0)}%)",
size: 14.sp,
weight: FontWeight.w500,
color: Colors.green,
),
],
),
SizedBox(height: 14.h),
],
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: 'Total', size: 14.sp),
SizedBox(height: 4.h),
CustomText(
text: "Including \$${taxAmount.toStringAsFixed(2)} in taxes",
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
],
),
),
CustomText(
text: "\$${finalTotal.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 150.h),
],
);
}
else if (state is MyPassCartEmpty) {
return Center(
child: Column(
children: [
Image.asset("assets/gif/empty_cart.gif", width: 250.w),
CustomText(
text: "You do not have any passes",
size: 24.sp,
color: Color(0xFFF95F62),
),
SizedBox(height: 4.h),
Text(
"Get a pass and get offers and discounts and more on your trip to your favourite city",
style: TextStyle(color: Color(0xFF656565), fontSize: 14.sp),
textAlign: TextAlign.center,
),
],
),
);
} else if (state is MyPassCartError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 60.sp, color: Colors.red),
SizedBox(height: 16.h),
CustomText(
text: "Error loading cart",
size: 16.sp,
color: Colors.red,
),
SizedBox(height: 8.h),
CustomText(
text: state.message,
size: 12.sp,
color: Colors.grey,
),
],
),
);
}
return const SizedBox.shrink();
},
);
}
}

View File

@@ -1,371 +0,0 @@
import 'package:citycards_customer/cart/views/view_pass_page_view.dart';
import 'package:citycards_customer/checkout/widget/all_coupons_bottomsheet.dart';
import 'package:citycards_customer/common_packages/custom_dashed_line.dart';
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../blocs/pass_bloc.dart';
class MyPassesPage extends StatelessWidget {
const MyPassesPage({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<PassBloc, PassState>(
builder: (context, state) {
if (state is PassLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is PassLoaded) {
return
Column(
children: [
SizedBox(height: 22.h),
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: Color(0xFFF95FAF).withOpacity(0.2),
),
borderRadius: BorderRadius.circular(8.r),
),
child: Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
child: Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
),
),
SizedBox(width: 6.66.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Melbourne",
weight: FontWeight.w500,
size: 16.sp,
),
SizedBox(height: 5.h),
CustomText(
text: "2 Days",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(height: 5.h),
SizedBox(
width: MediaQuery.of(context).size.width * .5,
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Image.asset(
'assets/icons/adult.png',
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "3 adults",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
],
),
Row(
children: [
Image.asset(
'assets/icons/qty.png',
scale: 4,
),
SizedBox(width: 4.w),
Text.rich(
TextSpan(
children: [
TextSpan(
text: "Qty:",
style: TextStyle(
color: Color(0xFF8E8E8E),
fontSize: 12.sp,
),
),
TextSpan(
text: " 2",
style: TextStyle(
color: Color(0xFF000000),
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 5.h),
Row(
children: [
Image.asset(
"assets/icons/kid.png",
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "3 Kids",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(width: 53.w),
CustomText(
text: "\$49.50",
size: 24.sp,
weight: FontWeight.w500,
color: Color(0xFFF95F62),
),
],
),
],
),
],
),
Container(
width: 35.w,
height: 123.h,
decoration: BoxDecoration(
color: Color(0xFFF95FAF),
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
),
),
child: RotatedBox(
quarterTurns: -1,
child: Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: "Flexi ",
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
),
TextSpan(
text: "Card",
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
),
),
],
),
),
),
),
),
],
),
),
),
SizedBox(height: 15.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: Color(0xFFBB474A).withOpacity(0.4),
width: 0.8,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Get 10% off on your first trip",
color: Color(0xFF262626),
size: 14.sp,
),
SizedBox(height: 7.h),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => AllCouponsBottomsheet(),
);
},
child: CustomText(
text: "View all coupons",
color: Color(0xFFF95F62),
size: 12,
),
),
SizedBox(width: 3.w),
Icon(Icons.arrow_right, color: Color(0xFFF95F62)),
],
),
],
),
const Spacer(),
Container(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 10.h,
),
decoration: BoxDecoration(
border: Border.all(color: Color(0xFFF95F62)),
borderRadius: BorderRadius.circular(8.r),
),
child: CustomText(
text: "Apply",
color: Color(0xFFF95F62),
size: 14.sp,
),
),
],
),
),
SizedBox(height: 15.h),
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Subtotal", size: 14.sp),
CustomText(
text: "\$49.50",
size: 14.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 14.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Discount", size: 14.sp),
CustomText(
text: "-7.20%",
size: 14.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 10.h),
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: 'Total', size: 14.sp),
SizedBox(height: 4.h),
CustomText(
text: "Including \$2.24 in taxes",
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
],
),
),
CustomText(
text: "\$42.60",
size: 24.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 150.h,),
CustomFilledButton(
onTap: () {},
width: double.infinity,
label: "Proceed to Checkout",
),
SizedBox(height: 25.h),
],
);
}
return Center(
child: Column(
children: [
Image.asset("assets/gif/empty_cart.gif", width: 250.w),
CustomText(
text: "You do not have any passes",
size: 24.sp,
color: Color(0xFFF95F62),
),
SizedBox(height: 4.h),
Text(
"Get a pass and get offers and discounts and more on your trip to your favourite city",
style: TextStyle(color: Color(0xFF656565), fontSize: 14.sp),
textAlign: TextAlign.center,
),
],
),
);
},
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../login/view/login_email_bottomsheet.dart';
import '../blocs/postcard_bloc.dart';
class MyPostCardsPage extends StatelessWidget {
@@ -156,7 +157,19 @@ class MyPostCardsPage extends StatelessWidget {
),
SizedBox(height: 60.h),
CustomFilledButton(
onTap: () {},
onTap: () {
showModalBottomSheet(
backgroundColor: Colors.white,
context: context,
isScrollControlled: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => const LoginEmailBottomsheet(),
);
},
width: double.infinity,
label: "Proceed to Checkout",
),

View File

@@ -0,0 +1,25 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/all_coupons_repository.dart';
import 'all_coupons_event.dart';
import 'all_coupons_state.dart';
class AllCouponsBloc extends Bloc<AllCouponsEvent, AllCouponsState> {
final AllCouponsRepository repository;
AllCouponsBloc({required this.repository}) : super(AllCouponsInitialState()) {
on<FetchAllCouponsEvent>(_onFetchAllCoupons);
}
Future<void> _onFetchAllCoupons(
FetchAllCouponsEvent event,
Emitter<AllCouponsState> emit,
) async {
emit(CouponsLoadingState());
try {
final coupons = await repository.fetchAllCoupons();
emit(CouponsLoadedState(coupons: coupons));
} catch (e) {
emit(CouponsErrorState(error: e.toString()));
}
}
}

View File

@@ -0,0 +1,3 @@
abstract class AllCouponsEvent {}
class FetchAllCouponsEvent extends AllCouponsEvent {}

View File

@@ -0,0 +1,19 @@
import '../../models/all_coupons_model.dart';
abstract class AllCouponsState {}
class AllCouponsInitialState extends AllCouponsState {}
class CouponsLoadingState extends AllCouponsState {}
class CouponsLoadedState extends AllCouponsState {
final List<AllCouponsModel> coupons;
CouponsLoadedState({required this.coupons});
}
class CouponsErrorState extends AllCouponsState {
final String error;
CouponsErrorState({required this.error});
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/all_coupons_repository.dart';
import '../../repository/checkout_repository.dart';
import 'checkout_event.dart';
import 'checkout_state.dart';
class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
final AllCouponsRepository couponsRepository;
final CheckoutRepository checkoutRepository;
CheckoutBloc({
required this.couponsRepository,
required this.checkoutRepository,
}) : super(CheckoutInitialState()) {
on<FetchCheckoutCouponsEvent>(_onFetchCheckoutCoupons);
on<ApplyCouponEvent>(_onApplyCoupon);
on<RemoveCouponEvent>(_onRemoveCoupon);
on<ApplyCouponToBackendEvent>(_onApplyCouponToBackend); // 🆕 NEW
on<InitiatePaymentEvent>(_onInitiatePayment); // 🆕 NEW
on<ConfirmPaymentEvent>(_onConfirmPayment); // 🆕 NEW
}
Future<void> _onFetchCheckoutCoupons(
FetchCheckoutCouponsEvent event,
Emitter<CheckoutState> emit,
) async {
emit(CheckoutCouponsLoadingState());
try {
final coupons = await couponsRepository.fetchAllCoupons();
emit(CheckoutCouponsLoadedState(coupons: coupons));
} catch (e) {
emit(CheckoutCouponsErrorState(error: e.toString()));
}
}
void _onApplyCoupon(
ApplyCouponEvent event,
Emitter<CheckoutState> emit,
) {
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(appliedCoupon: event.coupon));
}
}
Future<void> _onRemoveCoupon(
RemoveCouponEvent event,
Emitter<CheckoutState> emit,
) async {
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
// Show loading
emit(currentState.copyWith(isApplyingCoupon: true, couponError: null));
try {
// Call API with empty coupon code
await checkoutRepository.applyCoupon(
bookingId: event.bookingId,
couponCode: '', // Empty string to remove coupon
);
// Clear applied coupon from state
emit(currentState.copyWith(
clearAppliedCoupon: true,
isApplyingCoupon: false,
couponError: null,
));
} catch (e) {
emit(currentState.copyWith(
isApplyingCoupon: false,
couponError: e.toString(),
));
}
}
}
/// 🆕 Apply Coupon to Backend
/// Calls the PUT /apply-coupon API
Future<void> _onApplyCouponToBackend(
ApplyCouponToBackendEvent event,
Emitter<CheckoutState> emit,
) async {
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
// Show loading
emit(currentState.copyWith(isApplyingCoupon: true, couponError: null));
try {
// Call API
final response = await checkoutRepository.applyCoupon(
bookingId: event.bookingId,
couponCode: event.couponCode,
);
// Find the coupon from the list
final appliedCoupon = currentState.coupons.firstWhere(
(c) => c.couponCode == event.couponCode,
orElse: () => currentState.coupons.first,
);
// Update state with applied coupon
emit(currentState.copyWith(
appliedCoupon: appliedCoupon,
isApplyingCoupon: false,
couponError: null,
));
// Success message will be handled in view
} catch (e) {
emit(currentState.copyWith(
isApplyingCoupon: false,
couponError: e.toString(),
));
}
}
}
/// 🆕 Initiate Payment
/// Calls the /pay API to get clientSecret for Stripe
Future<void> _onInitiatePayment(
InitiatePaymentEvent event,
Emitter<CheckoutState> emit,
) async {
// Show loading state
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(
isInitiatingPayment: true,
paymentError: null,
clientSecret: null,
));
} else {
emit(CheckoutPaymentInitiatingState());
}
try {
// Call the /pay API
final response = await checkoutRepository.initiatePayment(
bookingId: event.bookingId,
);
// Extract clientSecret and bookingId from response
final clientSecret = response['clientSecret'] as String?;
final bookingId = response['bookingId'] as int?;
// Validate response
if (clientSecret == null || clientSecret.isEmpty) {
emit(CheckoutPaymentInitiationErrorState(
error: 'Payment initialization failed - no client secret received from server',
));
return;
}
if (bookingId == null) {
emit(CheckoutPaymentInitiationErrorState(
error: 'Payment initialization failed - no booking ID received from server',
));
return;
}
// Emit success state with clientSecret
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(
isInitiatingPayment: false,
clientSecret: clientSecret,
bookingId: bookingId,
paymentError: null,
));
} else {
emit(CheckoutPaymentInitiatedState(
clientSecret: clientSecret,
bookingId: bookingId,
));
}
} catch (e) {
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(
isInitiatingPayment: false,
paymentError: e.toString(),
));
} else {
emit(CheckoutPaymentInitiationErrorState(
error: e.toString(),
));
}
}
}
/// 🆕 Confirm Payment
/// Called after Stripe payment succeeds or fails
/// Sends stripeStatus and paymentStatus to backend
Future<void> _onConfirmPayment(
ConfirmPaymentEvent event,
Emitter<CheckoutState> emit,
) async {
// 🔒 GUARD: Prevent duplicate confirmation calls
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
if (currentState.hasConfirmationBeenSent) {
print('⚠️ [CHECKOUT BLOC] Payment confirmation already sent. Ignoring duplicate call.');
return;
}
}
// Show loading state
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(
isConfirmingPayment: true,
confirmationError: null,
isPaymentConfirmed: false,
hasConfirmationBeenSent: true, // 🔒 Mark as sent
));
} else {
emit(CheckoutPaymentConfirmingState());
}
try {
// Call the confirm-payment API
final response = await checkoutRepository.confirmPayment(
bookingId: event.bookingId,
stripeStatus: event.stripeStatus,
paymentStatus: event.paymentStatus,
);
// Emit success state with booking details
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(
isConfirmingPayment: false,
isPaymentConfirmed: true,
confirmationError: null,
bookingDetails: response,
clearClientSecret: true,
));
} else {
emit(CheckoutPaymentConfirmedState(
bookingDetails: response,
));
}
} catch (e) {
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(
isConfirmingPayment: false,
isPaymentConfirmed: false,
confirmationError: e.toString(),
hasConfirmationBeenSent: false, // 🔓 Reset on error to allow retry
));
} else {
emit(CheckoutPaymentConfirmationErrorState(
error: e.toString(),
));
}
}
}
}

View File

@@ -0,0 +1,48 @@
import '../../models/all_coupons_model.dart';
abstract class CheckoutEvent {}
class FetchCheckoutCouponsEvent extends CheckoutEvent {}
class ApplyCouponEvent extends CheckoutEvent {
final AllCouponsModel coupon;
ApplyCouponEvent({required this.coupon});
}
/// 🆕 Apply Coupon to Backend Event
class ApplyCouponToBackendEvent extends CheckoutEvent {
final int bookingId;
final String couponCode;
ApplyCouponToBackendEvent({
required this.bookingId,
required this.couponCode,
});
}
class RemoveCouponEvent extends CheckoutEvent {
final int bookingId;
RemoveCouponEvent({required this.bookingId});
}
/// 🆕 Initiate Payment Event
/// Triggered when user clicks "Pay" button
class InitiatePaymentEvent extends CheckoutEvent {
final int bookingId;
InitiatePaymentEvent({required this.bookingId});
}
/// 🆕 Confirm Payment Event
/// Triggered after Stripe payment completes (success or failure)
class ConfirmPaymentEvent extends CheckoutEvent {
final int bookingId;
final String stripeStatus; // e.g., "succeeded", "requires_payment_method"
final String paymentStatus; // e.g., "success", "failed"
ConfirmPaymentEvent({
required this.bookingId,
required this.stripeStatus,
required this.paymentStatus,
});
}

View File

@@ -0,0 +1,123 @@
import '../../models/all_coupons_model.dart';
abstract class CheckoutState {}
class CheckoutInitialState extends CheckoutState {}
class CheckoutCouponsLoadingState extends CheckoutState {}
class CheckoutCouponsLoadedState extends CheckoutState {
final List<AllCouponsModel> coupons;
final AllCouponsModel? appliedCoupon;
// 🆕 Coupon application tracking
final bool isApplyingCoupon;
final String? couponError;
// 🆕 Payment-related fields
final bool isInitiatingPayment;
final String? clientSecret; // Stripe client secret
final int? bookingId; // Booking ID from payment initiation
final String? paymentError;
// 🆕 Payment confirmation tracking
final bool isConfirmingPayment;
final bool isPaymentConfirmed;
final String? confirmationError;
final Map<String, dynamic>? bookingDetails; // Full booking response after confirmation
final bool hasConfirmationBeenSent; // 🔒 Prevent duplicate confirmation calls
CheckoutCouponsLoadedState({
required this.coupons,
this.appliedCoupon,
this.isApplyingCoupon = false,
this.couponError,
this.isInitiatingPayment = false,
this.clientSecret,
this.bookingId,
this.paymentError,
this.isConfirmingPayment = false,
this.isPaymentConfirmed = false,
this.confirmationError,
this.bookingDetails,
this.hasConfirmationBeenSent = false,
});
CheckoutCouponsLoadedState copyWith({
List<AllCouponsModel>? coupons,
AllCouponsModel? appliedCoupon,
bool clearAppliedCoupon = false,
bool? isApplyingCoupon,
String? couponError,
bool? isInitiatingPayment,
String? clientSecret,
int? bookingId,
String? paymentError,
bool? isConfirmingPayment,
bool? isPaymentConfirmed,
String? confirmationError,
bool clearClientSecret = false,
Map<String, dynamic>? bookingDetails,
bool? hasConfirmationBeenSent,
}) {
return CheckoutCouponsLoadedState(
coupons: coupons ?? this.coupons,
appliedCoupon: clearAppliedCoupon ? null : (appliedCoupon ?? this.appliedCoupon),
isApplyingCoupon: isApplyingCoupon ?? this.isApplyingCoupon,
couponError: couponError,
isInitiatingPayment: isInitiatingPayment ?? this.isInitiatingPayment,
bookingId: bookingId ?? this.bookingId,
paymentError: paymentError,
isConfirmingPayment: isConfirmingPayment ?? this.isConfirmingPayment,
isPaymentConfirmed: isPaymentConfirmed ?? this.isPaymentConfirmed,
confirmationError: confirmationError,
clientSecret: clearClientSecret ? null : (clientSecret ?? this.clientSecret),
bookingDetails: bookingDetails ?? this.bookingDetails,
hasConfirmationBeenSent: hasConfirmationBeenSent ?? this.hasConfirmationBeenSent,
);
}
}
class CheckoutCouponsErrorState extends CheckoutState {
final String error;
CheckoutCouponsErrorState({required this.error});
}
/// 🆕 Payment Initiation Loading State
class CheckoutPaymentInitiatingState extends CheckoutState {}
/// 🆕 Payment Initiation Success State
/// This state contains the clientSecret for Stripe payment
class CheckoutPaymentInitiatedState extends CheckoutState {
final String clientSecret;
final int bookingId;
CheckoutPaymentInitiatedState({
required this.clientSecret,
required this.bookingId,
});
}
/// 🆕 Payment Initiation Error State
class CheckoutPaymentInitiationErrorState extends CheckoutState {
final String error;
CheckoutPaymentInitiationErrorState({required this.error});
}
/// 🆕 Payment Confirmation Loading State
class CheckoutPaymentConfirmingState extends CheckoutState {}
/// 🆕 Payment Confirmation Success State
class CheckoutPaymentConfirmedState extends CheckoutState {
final Map<String, dynamic> bookingDetails;
CheckoutPaymentConfirmedState({required this.bookingDetails});
}
/// 🆕 Payment Confirmation Error State
class CheckoutPaymentConfirmationErrorState extends CheckoutState {
final String error;
CheckoutPaymentConfirmationErrorState({required this.error});
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../profile/repository/profile_repository.dart';
import '../repository/pass_purchase_details_repository.dart';
import 'pass_purchase_details_event.dart';
import 'pass_purchase_details_state.dart';
class PurchaseDetailsBloc
extends Bloc<PassPurchaseDetailsEvent, PurchaseDetailsState> {
final ProfileRepository _profileRepository;
final PassPurchaseDetailsRepository _purchaseDetailsRepository;
PurchaseDetailsBloc({
ProfileRepository? profileRepository,
PassPurchaseDetailsRepository? purchaseDetailsRepository,
}) : _profileRepository = profileRepository ?? ProfileRepository(),
_purchaseDetailsRepository = purchaseDetailsRepository ?? PassPurchaseDetailsRepository(),
super(PurchaseDetailsInitial()) {
on<LoadProfileEvent>(_onLoadProfile);
on<SetPurchaseDetailsEvent>(_onSetPurchaseDetails);
on<ToggleGiftModeEvent>(_onToggleGiftMode);
on<SubmitUserDetailsEvent>(_onSubmitUserDetails);
}
Future<void> _onLoadProfile(
LoadProfileEvent event,
Emitter<PurchaseDetailsState> emit,
) async {
emit(PurchaseDetailsProfileLoading(isGift: state.isGift));
try {
final profile = await _profileRepository.fetchUserProfile();
emit(PurchaseDetailsLoaded(
isGift: state.isGift,
profile: profile,
));
} catch (e) {
// Handle error - emit loaded state with null profile
emit(PurchaseDetailsLoaded(
isGift: state.isGift,
profile: null,
));
}
}
void _onSetPurchaseDetails(
SetPurchaseDetailsEvent event,
Emitter<PurchaseDetailsState> emit,
) {
final isGift = event.buyPassValue == "gift";
emit(PurchaseDetailsUpdated(
buyPassState: event.buyPassValue,
isGift: isGift,
profile: state.profile,
));
}
void _onToggleGiftMode(
ToggleGiftModeEvent event,
Emitter<PurchaseDetailsState> emit,
) {
emit(PurchaseDetailsLoaded(
isGift: event.isGift,
profile: state.profile,
));
}
Future<void> _onSubmitUserDetails(
SubmitUserDetailsEvent event,
Emitter<PurchaseDetailsState> emit,
) async {
emit(PurchaseDetailsSubmitting(
isGift: state.isGift,
profile: state.profile,
));
try {
final response = await _purchaseDetailsRepository.submitUserDetails(
bookingId: event.bookingId,
isForSelf: event.isForSelf,
recipientFirstName: event.recipientFirstName,
recipientLastName: event.recipientLastName,
recipientEmail: event.recipientEmail,
recipientPhone: event.recipientPhone,
city: event.city,
country: event.country,
);
emit(PurchaseDetailsSubmitted(
response: response,
isGift: state.isGift,
profile: state.profile,
));
} catch (e) {
emit(PurchaseDetailsError(
errorMessage: e.toString(),
isGift: state.isGift,
profile: state.profile,
));
}
}
}

View File

@@ -0,0 +1,37 @@
abstract class PassPurchaseDetailsEvent {}
class SetPurchaseDetailsEvent extends PassPurchaseDetailsEvent {
final String buyPassValue; // "self" or "gift"
SetPurchaseDetailsEvent(this.buyPassValue);
}
class LoadProfileEvent extends PassPurchaseDetailsEvent {}
class ToggleGiftModeEvent extends PassPurchaseDetailsEvent {
final bool isGift;
ToggleGiftModeEvent(this.isGift);
}
class SubmitUserDetailsEvent extends PassPurchaseDetailsEvent {
final int bookingId;
final bool isForSelf;
final String? recipientFirstName;
final String? recipientLastName;
final String? recipientEmail;
final String? recipientPhone;
final String? city;
final String? country;
SubmitUserDetailsEvent({
required this.bookingId,
required this.isForSelf,
this.recipientFirstName,
this.recipientLastName,
this.recipientEmail,
this.recipientPhone,
this.city,
this.country,
});
}

View File

@@ -0,0 +1,93 @@
import '../../profile/models/profile_model.dart';
abstract class PurchaseDetailsState {
final bool isGift;
final ProfileModel? profile;
final bool isLoadingProfile;
final bool isSubmittingDetails;
final String? errorMessage;
PurchaseDetailsState({
this.isGift = false,
this.profile,
this.isLoadingProfile = false,
this.isSubmittingDetails = false,
this.errorMessage,
});
}
class PurchaseDetailsInitial extends PurchaseDetailsState {
PurchaseDetailsInitial() : super(isLoadingProfile: true);
}
class PurchaseDetailsLoaded extends PurchaseDetailsState {
PurchaseDetailsLoaded({
required bool isGift,
ProfileModel? profile,
}) : super(
isGift: isGift,
profile: profile,
isLoadingProfile: false,
);
}
class PurchaseDetailsUpdated extends PurchaseDetailsState {
final String buyPassState; // "self" or "gift"
PurchaseDetailsUpdated({
required this.buyPassState,
required bool isGift,
ProfileModel? profile,
}) : super(
isGift: isGift,
profile: profile,
isLoadingProfile: false,
);
}
class PurchaseDetailsProfileLoading extends PurchaseDetailsState {
PurchaseDetailsProfileLoading({
required bool isGift,
}) : super(
isGift: isGift,
isLoadingProfile: true,
);
}
class PurchaseDetailsSubmitting extends PurchaseDetailsState {
PurchaseDetailsSubmitting({
required bool isGift,
ProfileModel? profile,
}) : super(
isGift: isGift,
profile: profile,
isSubmittingDetails: true,
);
}
class PurchaseDetailsSubmitted extends PurchaseDetailsState {
final Map<String, dynamic> response;
PurchaseDetailsSubmitted({
required this.response,
required bool isGift,
ProfileModel? profile,
}) : super(
isGift: isGift,
profile: profile,
isSubmittingDetails: false,
);
}
class PurchaseDetailsError extends PurchaseDetailsState {
PurchaseDetailsError({
required String errorMessage,
required bool isGift,
ProfileModel? profile,
}) : super(
isGift: isGift,
profile: profile,
errorMessage: errorMessage,
isSubmittingDetails: false,
);
}

View File

@@ -1,24 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
abstract class PurchaseDetails {}
class SetPurchaseDetailsEvent extends PurchaseDetails {
final String buyPassValue;
SetPurchaseDetailsEvent(this.buyPassValue);
}
class PurchaseDetailsState {
final String buyPassState;
PurchaseDetailsState(this.buyPassState);
}
class PurchaseDetailsBloc
extends Bloc<SetPurchaseDetailsEvent, PurchaseDetailsState> {
PurchaseDetailsBloc() : super(PurchaseDetailsState("")) {
on<SetPurchaseDetailsEvent>((event, emit){
emit(PurchaseDetailsState(event.buyPassValue));
});
}
}

View File

@@ -0,0 +1,61 @@
class AllCouponsModel {
final int id;
final String title;
final String? description;
final int cityXid;
final int discountPercent;
final String couponCode;
final DateTime startDateTime;
final DateTime endDateTime;
final bool showAtCheckout;
final String couponStatus;
final bool isActive;
AllCouponsModel({
required this.id,
required this.title,
this.description,
required this.cityXid,
required this.discountPercent,
required this.couponCode,
required this.startDateTime,
required this.endDateTime,
required this.showAtCheckout,
required this.couponStatus,
required this.isActive,
});
/// From JSON
factory AllCouponsModel.fromJson(Map<String, dynamic> json) {
return AllCouponsModel(
id: json['id'] as int,
title: json['title'] as String,
description: json['description'],
cityXid: json['cityXid'] as int,
discountPercent: json['discountPercent'] as int,
couponCode: json['couponCode'] as String,
startDateTime: DateTime.parse(json['startDateTime']),
endDateTime: DateTime.parse(json['endDateTime']),
showAtCheckout: json['showAtCheckout'] as bool,
couponStatus: json['couponStatus'] as String,
isActive: json['isActive'] as bool,
);
}
/// To JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'cityXid': cityXid,
'discountPercent': discountPercent,
'couponCode': couponCode,
'startDateTime': startDateTime.toIso8601String(),
'endDateTime': endDateTime.toIso8601String(),
'showAtCheckout': showAtCheckout,
'couponStatus': couponStatus,
'isActive': isActive,
};
}
}

View File

@@ -0,0 +1,16 @@
import 'package:citycards_customer/localPreference/local_preference.dart';
import '../models/all_coupons_model.dart';
import '../../networkApiServices/network_api_services.dart';
import '../../networkApiServices/api_urls.dart';
class AllCouponsRepository {
final NetworkApiService _apiService = NetworkApiService();
Future<List<AllCouponsModel>> fetchAllCoupons() async {
final int cityXid = await LocalPreference.getSelectedCityId();
final response = await _apiService.getApi(
url: '${ApiUrls.coupons}?cityXid=$cityXid',
);
final List<dynamic> data = response.data as List;
return data.map((json) => AllCouponsModel.fromJson(json)).toList();
}
}

View File

@@ -0,0 +1,155 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import '../../networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
class CheckoutRepository {
final NetworkApiService _apiServices = NetworkApiService();
/// 🆕 Initiate Payment - Hit the /pay API
/// POST https://devapi.citycards.betadelivery.com/mobile/passes/{bookingId}/pay
/// Returns: {"bookingId": 4, "clientSecret": "pi_xxx_secret_xxx"}
Future<Map<String, dynamic>> initiatePayment({
required int bookingId,
}) async {
try {
log('🟢 initiatePayment() called');
log('📤 [INITIATE PAYMENT] Booking ID: $bookingId');
// Construct URL with bookingId
final url = '${ApiUrls.baseUrl}/mobile/passes/$bookingId/pay';
if (kDebugMode) {
print('📤 [INITIATE PAYMENT] API URL: $url');
}
// Send POST request
final response = await _apiServices.postApi(
url: url,
data: {}, // Empty body, bookingId is in URL
);
log('✅ [INITIATE PAYMENT] Response Status: ${response.statusCode}');
log('📥 [INITIATE PAYMENT] Response Data: ${response.data}');
if (kDebugMode) {
print('📤 [INITIATE PAYMENT] ✅ Payment initiation successful');
print('📤 [INITIATE PAYMENT] Full Response: ${response.data}');
}
return response.data as Map<String, dynamic>;
} catch (e, stackTrace) {
log(
'❌ initiatePayment FAILED',
error: e,
stackTrace: stackTrace,
);
throw Exception('Failed to initiate payment: $e');
}
}
/// 🆕 Confirm Payment after successful Stripe payment
/// POST https://devapi.citycards.betadelivery.com/mobile/passes/{bookingId}/confirm-payment
/// Body: {"stripeStatus": "succeeded", "paymentStatus": "success"}
Future<Map<String, dynamic>> confirmPayment({
required int bookingId,
required String stripeStatus,
required String paymentStatus,
}) async {
try {
log('🟢 confirmPayment() called');
log('📤 [CONFIRM PAYMENT] Booking ID: $bookingId');
log('📤 [CONFIRM PAYMENT] Stripe Status: $stripeStatus');
log('📤 [CONFIRM PAYMENT] Payment Status: $paymentStatus');
// Construct URL with bookingId
final url = '${ApiUrls.baseUrl}/mobile/passes/$bookingId/confirm-payment';
if (kDebugMode) {
print('📤 [CONFIRM PAYMENT] API URL: $url');
}
// Request body
final requestBody = {
'stripeStatus': stripeStatus,
'paymentStatus': paymentStatus,
};
log('📦 Request Body: $requestBody');
// Send POST request
final response = await _apiServices.postApi(
url: url,
data: requestBody,
);
log('✅ [CONFIRM PAYMENT] Response Status: ${response.statusCode}');
log('📥 [CONFIRM PAYMENT] Response Data: ${response.data}');
if (kDebugMode) {
print('📤 [CONFIRM PAYMENT] ✅ Payment confirmation successful');
print('📤 [CONFIRM PAYMENT] Full Response: ${response.data}');
}
return response.data as Map<String, dynamic>;
} catch (e, stackTrace) {
log(
'❌ confirmPayment FAILED',
error: e,
stackTrace: stackTrace,
);
throw Exception('Failed to confirm payment: $e');
}
}
Future<Map<String, dynamic>> applyCoupon({
required int bookingId,
required String couponCode,
}) async {
try {
log('🟢 applyCoupon() called');
log('📤 [APPLY COUPON] Booking ID: $bookingId');
log('📤 [APPLY COUPON] Coupon Code: $couponCode');
// Construct API URL
final url =
'${ApiUrls.baseUrl}/mobile/passes/$bookingId/apply-coupon';
if (kDebugMode) {
print('📤 [APPLY COUPON] API URL: $url');
}
// Request body
final requestBody = {
'couponCode': couponCode,
};
log('📦 Request Body: $requestBody');
// Send PUT request
final response = await _apiServices.putApi(
url: url,
data: requestBody,
);
log('✅ [APPLY COUPON] Response Status: ${response.statusCode}');
log('📥 [APPLY COUPON] Response Data: ${response.data}');
if (kDebugMode) {
print('📤 [APPLY COUPON] ✅ Coupon applied successfully');
print('📤 [APPLY COUPON] Full Response: ${response.data}');
}
return response.data as Map<String, dynamic>;
} catch (e, stackTrace) {
log(
'❌ applyCoupon FAILED',
error: e,
stackTrace: stackTrace,
);
throw Exception('Failed to apply coupon: $e');
}
}
}

View File

@@ -0,0 +1,71 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import '../../networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
class PassPurchaseDetailsRepository {
final NetworkApiService _apiServices = NetworkApiService();
/// Submit user details for pass purchase
/// POST https://devapi.citycards.betadelivery.com/mobile/passes/{bookingId}/user-details
Future<Map<String, dynamic>> submitUserDetails({
required int bookingId,
required bool isForSelf,
String? recipientFirstName,
String? recipientLastName,
String? recipientEmail,
String? recipientPhone,
String? city,
String? country,
}) async {
try {
log('🟢 submitUserDetails() called');
log('📤 [SUBMIT USER DETAILS] Booking ID: $bookingId');
log('📤 [SUBMIT USER DETAILS] Is For Self: $isForSelf');
// Construct URL with bookingId
final url = '${ApiUrls.baseUrl}/mobile/passes/$bookingId/user-details';
if (kDebugMode) {
print('📤 [SUBMIT USER DETAILS] API URL: $url');
}
// Request body
final requestBody = {
'isForSelf': isForSelf,
'recipientFirstName': recipientFirstName ?? '',
'recipientLastName': recipientLastName ?? '',
'recipientEmail': recipientEmail ?? '',
'recipientPhone': recipientPhone ?? '',
'recipientCity': city ?? '',
'recipientCountry': country ?? '',
};
log('📦 Request Body: $requestBody');
// Send POST request
final response = await _apiServices.putApi(
url: url,
data: requestBody,
);
log('✅ [SUBMIT USER DETAILS] Response Status: ${response.statusCode}');
log('📥 [SUBMIT USER DETAILS] Response Data: ${response.data}');
if (kDebugMode) {
print('📤 [SUBMIT USER DETAILS] ✅ User details submission successful');
print('📤 [SUBMIT USER DETAILS] Full Response: ${response.data}');
}
return response.data as Map<String, dynamic>;
} catch (e, stackTrace) {
log(
'❌ submitUserDetails FAILED',
error: e,
stackTrace: stackTrace,
);
throw Exception('Failed to submit user details: $e');
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,142 +1,174 @@
import 'package:citycards_customer/postcard/widgets/purchase_details_bottom_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import '../bloc/allCoupons/all_coupons_bloc.dart';
import '../bloc/allCoupons/all_coupons_event.dart';
import '../bloc/allCoupons/all_coupons_state.dart';
import '../repository/all_coupons_repository.dart';
class AllCouponsBottomsheet extends StatelessWidget {
AllCouponsBottomsheet({super.key});
final Function(dynamic coupon)? onCouponSelected;
final List<Map<String, String>> coupons = [
{
"text": "Flat 3% cashback using Amazon Pay Balance",
"coupon_code": "AMZNPAY3",
},
{
"text": "Flat 3% cashback using Amazon Pay Balance",
"coupon_code": "AMZNPAY3",
},
{
"text": "Flat 3% cashback using Amazon Pay Balance",
"coupon_code": "AMZNPAY3",
},
{
"text": "Flat 3% cashback using Amazon Pay Balance",
"coupon_code": "AMZNPAY3",
},
];
const AllCouponsBottomsheet({
super.key,
this.onCouponSelected,
});
@override
Widget build(BuildContext context) {
return AnimatedPadding(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
padding: EdgeInsets.only(
top: 24.h,
left: 20.w,
right: 20.w,
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
/// --- Header ---
Container(
height: 4.h,
width: 40.w,
decoration: BoxDecoration(
color: Color(0xFF2D3134),
borderRadius: BorderRadius.circular(4.r),
return BlocProvider(
create: (context) => AllCouponsBloc(repository: AllCouponsRepository())
..add(FetchAllCouponsEvent()),
child: AnimatedPadding(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
padding: EdgeInsets.only(
top: 24.h,
left: 20.w,
right: 20.w,
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
/// --- Header ---
Container(
height: 4.h,
width: 40.w,
decoration: BoxDecoration(
color: Color(0xFF2D3134),
borderRadius: BorderRadius.circular(4.r),
),
),
),
SizedBox(height: 12.h),
CustomText(text: "All Coupons", size: 18.sp, weight: FontWeight.w500),
SizedBox(height: 22.h),
SizedBox(height: 12.h),
CustomText(
text: "All Coupons", size: 18.sp, weight: FontWeight.w500),
SizedBox(height: 22.h),
/// --- Coupon list ---
Flexible(
child: ListView.separated(
shrinkWrap: true,
physics: const BouncingScrollPhysics(),
itemCount: coupons.length,
separatorBuilder: (_, __) => SizedBox(height: 12.h),
itemBuilder: (context, index) {
final coupon = coupons[index];
return Container(
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: const Color(0xFFF95F62).withOpacity(0.12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 220.w,
child: CustomText(
text: coupon['text'] ?? "",
size: 12.sp,
weight: FontWeight.w400,
/// --- Coupon list ---
Flexible(
child: BlocBuilder<AllCouponsBloc, AllCouponsState>(
builder: (context, state) {
if (state is CouponsLoadingState) {
return Center(
child: CircularProgressIndicator(
color: Color(0xFFF95F62),
),
);
} else if (state is CouponsErrorState) {
return Center(
child: CustomText(
text: "Error: ${state.error}",
size: 14.sp,
color: Colors.red,
),
);
} else if (state is CouponsLoadedState) {
if (state.coupons.isEmpty) {
return Center(
child: CustomText(
text: "No coupons available",
size: 14.sp,
),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const BouncingScrollPhysics(),
itemCount: state.coupons.length,
separatorBuilder: (_, __) => SizedBox(height: 12.h),
itemBuilder: (context, index) {
final coupon = state.coupons[index];
return Container(
alignment: Alignment.center,
padding: EdgeInsets.symmetric(
horizontal: 8.w, vertical: 8.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: const Color(0xFFF95F62).withOpacity(0.12),
),
),
GestureDetector(
onTap: () {
Navigator.pop(context);
PurchaseDetailsBottomSheet.show(context);
},
child: Container(
width: 110.w,
height: 44.h,
decoration: BoxDecoration(
color: Color(0xFFF95F62),
borderRadius: BorderRadius.circular(12.r),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 220.w,
child: CustomText(
text: "${coupon.discountPercent}% discount on ${coupon.title}",
size: 12.sp,
weight: FontWeight.w400,
),
),
GestureDetector(
onTap: () {
// Pass the selected coupon back to checkout view
if (onCouponSelected != null) {
onCouponSelected!(coupon);
}
Navigator.pop(context);
},
child: Container(
width: 110.w,
height: 44.h,
decoration: BoxDecoration(
color: Color(0xFFF95F62),
borderRadius:
BorderRadius.circular(12.r),
),
child: Center(
child: CustomText(
text: "Apply Coupon",
size: 12.sp,
color: Colors.white,
),
),
),
),
],
),
child: Center(
child: CustomText(
text: "Apply Coupon",
size: 12.sp,
color: Colors.white,
SizedBox(height: 8.h),
Container(
height: 32.h,
width: 83.w,
decoration: BoxDecoration(
color:
Color(0xFFF95F62).withOpacity(0.12),
border: Border.all(color: Color(0xFFF95F62)),
borderRadius: BorderRadius.circular(6.r),
),
child: Center(
child: CustomText(
text: coupon.couponCode,
size: 12.sp,
weight: FontWeight.w400,
color: Color(0xFFF95F62),
),
),
),
),
],
),
],
),
SizedBox(height: 8.h),
Container(
height: 32.h,
width: 83.w,
decoration: BoxDecoration(
color: Color(0xFFF95F62).withOpacity(0.12),
border: Border.all(color: Color(0xFFF95F62)),
);
},
);
}
borderRadius: BorderRadius.circular(6.r),
),
child: Center(
child: CustomText(
text: coupon['coupon_code'] ?? "",
size: 12.sp,
weight: FontWeight.w400,
color: Color(0xFFF95F62),
),
),
),
],
),
);
},
return SizedBox.shrink();
},
),
),
),
],
],
),
),
);
}
}
}

View File

@@ -1,113 +0,0 @@
import 'package:citycards_customer/checkout/widget/verify_otp_bottomsheet.dart';
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:citycards_customer/core/route_constants.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class LoginEmailBottomsheet extends StatelessWidget {
const LoginEmailBottomsheet({super.key});
@override
Widget build(BuildContext context) {
return AnimatedPadding(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
padding: EdgeInsets.only(
top: 24.h,
left: 20.h,
right: 20.h,
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, // shrink to fit content
children: [
Image.asset("assets/logo/logo_city_cards_orange.png", scale: 4),
SizedBox(height: 8.h),
CustomText(text: "Get Started", size: 18.sp, weight: FontWeight.w500),
SizedBox(height: 42.h),
CustomText(
text: "Enter your email to begin your CityCards journey",
size: 14.sp,
color: const Color(0xFF000000).withOpacity(.6),
),
SizedBox(height: 12.h),
TextField(
decoration: InputDecoration(
filled: true,
contentPadding: EdgeInsets.symmetric(vertical: 6.h),
fillColor: const Color(0xFFFFF5F5),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: const Color(0xFFBB474A), width: 0.4.w),
borderRadius: BorderRadius.circular(8.sp),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: const Color(0xFFBB474A), width: 0.4.w),
borderRadius: BorderRadius.circular(8.sp),
),
prefixIcon: const Icon(Icons.email_outlined, color: Color(0xFFF95F62)),
hintText: "john.doe@gmail.com",
hintStyle: TextStyle(
color: const Color(0xFF000000).withOpacity(0.6),
fontSize: 12.sp,
),
),
),
SizedBox(height: 38.h),
CustomFilledButton(
onTap: () {
Navigator.pop(context);
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => VerifyOtpBottomsheet(),
);
},
label: "Continue",
width: double.infinity,
),
SizedBox(height: 20.h),
InkWell(
onTap: (){
Navigator.of(context).pushNamed(RouteConstants.createAcct);
},
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: "Already have an account?",
style: TextStyle(
color: Colors.black.withOpacity(0.6),
fontSize: 12.sp,
fontWeight: FontWeight.w400,
),
),
TextSpan(
text: " Sign in",
style: TextStyle(
color: const Color(0xFFF95F62),
fontSize: 12.sp,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
SizedBox(height: 15.h),
],
),
),
);
}
}

View File

@@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../add_details/add_details_view.dart';
import '../../profile/repository/profile_repository.dart';
import '../../profile/view/edit_profile/edit_profile_view.dart';
import '../bloc/pass_purchase_details_bloc.dart';
import '../bloc/pass_purchase_details_event.dart';
import '../bloc/pass_purchase_details_state.dart';
class PassPurchaseBottomSheet {
static Future<String?> show(BuildContext context, {required int bookingId}) async {
return await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) {
return BlocProvider(
create: (_) => PurchaseDetailsBloc()..add(LoadProfileEvent()),
child: _PassPurchaseContent(bookingId: bookingId),
);
},
);
}
static void close(BuildContext context) {
Navigator.of(context).pop();
}
}
class _PassPurchaseContent extends StatelessWidget {
final int bookingId;
const _PassPurchaseContent({required this.bookingId});
@override
Widget build(BuildContext context) {
return BlocConsumer<PurchaseDetailsBloc, PurchaseDetailsState>(
listener: (context, state) {
// Handle API submission success
if (state is PurchaseDetailsSubmitted) {
// Close bottom sheet and return success
Navigator.of(context).pop('success');
// Show success message
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text('Details submitted successfully!'),
// backgroundColor: Color(0xffF95F62),
// ),
// );
}
// Handle API submission error
if (state is PurchaseDetailsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Failed to submit details'),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
top: 16,
left: 16,
right: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 45,
height: 5,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 12),
Text(
"Purchase Details",
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
),
const SizedBox(height: 24),
/// BUY FOR MYSELF
GestureDetector(
onTap: () {
context.read<PurchaseDetailsBloc>().add(ToggleGiftModeEvent(false));
context.read<PurchaseDetailsBloc>().add(SetPurchaseDetailsEvent("self"));
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: !state.isGift
? Border.all(color: const Color(0xffF95F62), width: 1.5)
: null,
),
child: Row(
children: [
Radio<bool>(
value: false,
groupValue: state.isGift,
onChanged: (_) {},
activeColor: const Color(0xffF95F62),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Buy Pass for Myself",
style: TextStyle(
fontWeight: FontWeight.w600,
color: !state.isGift
? const Color(0xffF95F62)
: const Color(0xff9E9E9E),
),
),
if (!state.isGift && state.profile != null) ...[
const SizedBox(height: 8),
Text(
"${state.profile!.firstName} ${state.profile!.lastName}",
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
Text(
"${state.profile!.address1 ?? ""}\n${state.profile!.address2 ?? ""}",
style: const TextStyle(
fontSize: 13,
color: Color(0xff5E5E5E),
),
),
],
if (!state.isGift && state.isLoadingProfile) ...[
const SizedBox(height: 8),
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xffF95F62),
),
),
],
],
),
),
if (!state.isGift)
ElevatedButton(
onPressed: () async {
PassPurchaseBottomSheet.close(context);
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const EditProfilePage(),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
"Edit Details",
style: TextStyle(fontSize: 12, color: Colors.white),
),
),
],
),
),
),
const SizedBox(height: 20),
/// GIFT PASS
GestureDetector(
onTap: () {
context.read<PurchaseDetailsBloc>().add(ToggleGiftModeEvent(true));
context.read<PurchaseDetailsBloc>().add(SetPurchaseDetailsEvent("gift"));
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: state.isGift
? Border.all(color: const Color(0xffF95F62), width: 1.5)
: null,
),
child: Row(
children: [
Radio<bool>(
value: true,
groupValue: state.isGift,
onChanged: (_) {},
activeColor: const Color(0xffF95F62),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
"Gift the pass",
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 4),
Text(
"Gift the pass for someone else",
style: TextStyle(
fontSize: 13, color: Color(0xff9E9E9E)),
),
],
),
),
],
),
),
),
const SizedBox(height: 15),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: state.isSubmittingDetails
? null
: () {
if (state.isGift) {
// ✅ Just close bottom sheet and return 'gift'
// Let checkout view handle the navigation
Navigator.of(context).pop('gift');
} else {
// Submit user details for "Buy for Myself"
if (state.profile != null) {
context.read<PurchaseDetailsBloc>().add(
SubmitUserDetailsEvent(
bookingId: bookingId,
isForSelf: true,
recipientFirstName: state.profile!.firstName,
recipientLastName: state.profile!.lastName,
recipientEmail: state.profile!.emailAddress,
recipientPhone: state.profile!.mobileNumber,
city: '', // Empty for self
country: '', // Empty for self
),
);
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
padding: EdgeInsets.symmetric(vertical: 16.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40),
),
),
child: state.isSubmittingDetails
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
"Proceed",
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 15),
],
),
);
},
);
}
}

View File

@@ -1,122 +0,0 @@
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../core/route_constants.dart';
class VerifyOtpBottomsheet extends StatelessWidget {
VerifyOtpBottomsheet({super.key});
@override
Widget build(BuildContext context) {
return AnimatedPadding(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
padding: EdgeInsets.only(
top: 24.h,
left: 20.h,
right: 20.h,
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, // shrink to fit content
children: [
Image.asset("assets/logo/logo_city_cards_orange.png", scale: 4),
SizedBox(height: 8.h),
CustomText(
text: "Verify your phone",
size: 18.sp,
weight: FontWeight.w500,
),
SizedBox(height: 42.h),
Text.rich(
TextSpan(
children: [
TextSpan(
text: "Enter the verification code sent to your email id",
style: TextStyle(
fontSize: 14.sp,
color: Colors.black.withOpacity(0.6),
),
),
TextSpan(
text: " frank7824@mail.com",
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
],
),
),
SizedBox(height: 15.h),
OtpTextField(
numberOfFields: 6,
borderWidth: 0.4.w,
fieldWidth: 48.w,
fieldHeight: 60.h,
borderRadius: BorderRadius.circular(8.r),
filled: true,
fillColor: const Color(0xFFFFF5F5),
borderColor: const Color(0xFFBB474A),
cursorColor: const Color(0xFFF95F62),
showFieldAsBox: true,
textStyle: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
onCodeChanged: (code) {},
onSubmit: (code) {
debugPrint("OTP entered: $code");
},
),
SizedBox(height: 42.h),
CustomFilledButton(
onTap: () {
Navigator.pop(context);
},
label: "Continue",
width: double.infinity,
),
SizedBox(height: 20.h),
InkWell(
onTap: () {
Navigator.of(context).pushNamed(RouteConstants.createAcct);
},
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: "Already have an account?",
style: TextStyle(
color: Colors.black.withOpacity(0.6),
fontSize: 12.sp,
fontWeight: FontWeight.w400,
),
),
TextSpan(
text: " Sign in",
style: TextStyle(
color: const Color(0xFFF95F62),
fontSize: 12.sp,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
SizedBox(height: 15.h),
],
),
),
);
}
}

View File

@@ -1,8 +1,13 @@
import 'package:citycards_customer/networkApiServices/api_urls.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../core/route_constants.dart';
import '../home/widgets/search_city_bottomsheet.dart';
import '../localPreference/local_preference.dart';
import '../profile/bloc/profile/profile_bloc.dart';
import '../profile/bloc/profile/profile_state.dart';
class CommonAppBar extends StatelessWidget {
const CommonAppBar({
@@ -10,64 +15,103 @@ class CommonAppBar extends StatelessWidget {
required this.isWhiteLogo,
required this.isProfilePage,
this.showCart = true,
required this.showDivider
required this.showDivider,
this.imageUrl,
this.isSelectCity = false,
});
final bool isWhiteLogo;
final bool isProfilePage;
final bool? showCart;
final bool showDivider;
final String? imageUrl;
final bool isSelectCity;
@override
Widget build(BuildContext context) {
final bool isPathIcon =
imageUrl != null && imageUrl!.isNotEmpty;
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
/// LEFT SIDE
Row(
children: [
Image.asset(
isWhiteLogo
? "assets/logo/melbourne_white.png"
: "assets/logo/melbourne_logo.png",
scale: 4,
/// ✅ LOGO / PATH ICON (SIZE CONTROLLED)
SizedBox(
height: isPathIcon ? 40.h : 32.h, // 🔥 ONLY path icon bigger
child: isPathIcon
? Image.network(
imageUrl!,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
isWhiteLogo
? "assets/logo/logo_city_cards_white.png"
: "assets/logo/logo_city_cards.png",
fit: BoxFit.contain,
);
},
)
: Image.asset(
isWhiteLogo
? "assets/logo/logo_city_cards_white.png"
: "assets/logo/logo_city_cards.png",
fit: BoxFit.contain,
),
),
IconButton(onPressed: (){
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const CitySelectionBottomSheet(),
);
}, icon: Icon(Icons.arrow_drop_down, color: isWhiteLogo ? Colors.white : Color(0xffF95F62), size: 30,))
/// ✅ CITY DROPDOWN
if (isSelectCity)
IconButton(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const CitySelectionBottomSheet(),
);
},
icon: Icon(
Icons.arrow_drop_down,
color: isWhiteLogo
? Colors.white
: const Color(0xffF95F62),
size: 30,
),
),
],
),
/// RIGHT SIDE
Row(
children: [
if(showCart!)
InkWell(
onTap: (){
Navigator.of(
context,
rootNavigator: true,
).pushNamed(RouteConstants.cartPage);
},
child: Container(
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Image.asset(
"assets/icons/shopping_cart.png",
height: 20.h,
),
),
),
if (showCart!)
InkWell(
onTap: () {
Navigator.of(
context,
rootNavigator: true,
).pushNamed(RouteConstants.cartPage);
},
child: Container(
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Image.asset(
"assets/icons/shopping_cart.png",
height: 20.h,
),
),
),
SizedBox(width: 8.w),
if (!isProfilePage)
GestureDetector(
onTap: () {
@@ -76,20 +120,51 @@ class CommonAppBar extends StatelessWidget {
rootNavigator: true,
).pushNamed(RouteConstants.profile);
},
child: CircleAvatar(
backgroundColor: Color(0xffFFDFDF),
child: Image.asset( "assets/images/profile_default_img.png",),
child: BlocBuilder<ProfileBloc, ProfileState>(
builder: (context, state) {
String? imagePath;
// ✅ Get image from profile state
if (state is ProfileLoaded) {
imagePath = state.profile.profileImage;
}
// ✅ Build full image URL
final String? imageUrl =
(imagePath != null && imagePath.isNotEmpty)
? "${ApiUrls.baseUrl}$imagePath"
: null;
return CircleAvatar(
radius: 20.r,
backgroundColor: const Color(0xffFFDFDF),
// ✅ Network image only if exists
backgroundImage:
(imageUrl != null && imageUrl.isNotEmpty)
? NetworkImage(imageUrl)
: null,
// ✅ Default fallback (unchanged)
child: (imageUrl == null || imageUrl.isEmpty)
? Image.asset(
"assets/images/profile_default_img.png",
)
: null,
);
},
),
),
],
),
],
),
/// DIVIDER
if (showDivider)
Column(
children: [
SizedBox(height: 12.h),
Divider(height: 1.h, color: Color(0xFFD9D9D9)),
const Divider(height: 1, color: Color(0xFFD9D9D9)),
SizedBox(height: 22.h),
],
),

View File

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

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class DashedBorderPainter extends CustomPainter {
final Color color;
final double strokeWidth;
final double gap;
final double dashWidth;
final double radius;
DashedBorderPainter({
required this.color,
this.strokeWidth = 1.5,
this.gap = 6,
this.dashWidth = 6,
this.radius = 16,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final rRect = RRect.fromRectAndRadius(
Offset.zero & size,
Radius.circular(radius),
);
final path = Path()..addRRect(rRect);
final dashPath = Path();
for (final metric in path.computeMetrics()) {
double distance = 0;
while (distance < metric.length) {
dashPath.addPath(
metric.extractPath(distance, distance + dashWidth),
Offset.zero,
);
distance += dashWidth + gap;
}
}
canvas.drawPath(dashPath, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
class CustomText extends StatelessWidget {
@@ -8,6 +7,7 @@ class CustomText extends StatelessWidget {
final String text;
final int? maxLines;
final TextOverflow? overflow;
final TextAlign? textAlign;
const CustomText({
Key? key,
@@ -17,6 +17,7 @@ class CustomText extends StatelessWidget {
required this.text,
this.maxLines,
this.overflow,
this.textAlign,
}) : super(key: key);
@override
@@ -37,7 +38,7 @@ class CustomText extends StatelessWidget {
),
maxLines: maxLines,
overflow: overflow,
textAlign: textAlign,
);
}
}
}

View File

@@ -7,6 +7,12 @@ class CustomTextField extends StatelessWidget {
final String hint;
final TextEditingController controller;
final int? maxLines;
final bool enabled;
final String? Function(String?)? validator; // ✅ NEW: Validator function
final TextInputType? keyboardType; // ✅ NEW: Keyboard type
final bool obscureText; // ✅ NEW: For password fields
final Widget? suffixIcon; // ✅ NEW: For icons like visibility toggle
final void Function(String)? onChanged; // ✅ NEW: OnChanged callback
const CustomTextField({
super.key,
@@ -14,6 +20,12 @@ class CustomTextField extends StatelessWidget {
required this.hint,
required this.controller,
this.maxLines = 1,
this.enabled = true,
this.validator,
this.keyboardType,
this.obscureText = false,
this.suffixIcon,
this.onChanged,
});
@override
@@ -23,33 +35,75 @@ class CustomTextField extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: label, size: 14.sp),
CustomText(
text: label,
size: 14.sp,
),
SizedBox(height: 6.h),
SizedBox(
height: maxLines == 1 ? 42.h : null,
child: TextField(
child: TextFormField( // ✅ Changed from TextField to TextFormField
controller: controller,
maxLines: maxLines,
maxLines: obscureText ? 1 : maxLines, // ✅ Password fields always single line
enabled: enabled,
validator: validator, // ✅ Added validator
keyboardType: keyboardType, // ✅ Added keyboard type
obscureText: obscureText, // ✅ Added obscure text
onChanged: onChanged, // ✅ Added onChanged
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(fontSize: 12.sp, color: Color(0xFF8E8E8E)),
hintStyle: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
filled: true,
fillColor: const Color(0xFFFFF5F5),
contentPadding: EdgeInsets.symmetric(horizontal: 24.w),
fillColor: enabled
? const Color(0xFFFFF5F5)
: Colors.grey.shade200,
contentPadding: EdgeInsets.symmetric(
horizontal: 24.w,
vertical: maxLines != null && maxLines! > 1 ? 12.h : 0, // ✅ Better padding for multiline
),
suffixIcon: suffixIcon, // ✅ Added suffix icon
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(
color: Color(0xBBC83B61).withOpacity(0.4),
color: const Color(0xBBC83B61).withOpacity(0.4),
width: .4.w,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(
color: Color(0xFFF95F62),
color: const Color(0xFFF95F62),
width: 1.w,
),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(
color: Colors.grey.shade400,
width: .4.w,
),
),
errorBorder: OutlineInputBorder( // ✅ NEW: Error state border
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(
color: Colors.red,
width: 1.w,
),
),
focusedErrorBorder: OutlineInputBorder( // ✅ NEW: Focused error state
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(
color: Colors.red,
width: 1.5.w,
),
),
errorStyle: TextStyle( // ✅ NEW: Error text style
fontSize: 11.sp,
color: Colors.red,
),
),
),
),
@@ -57,4 +111,4 @@ class CustomTextField extends StatelessWidget {
),
);
}
}
}

View File

@@ -1,237 +0,0 @@
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/back_widget.dart';
import 'package:flutter/material.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:citycards_customer/common_packages/custom_textfield.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class ContactUsPage extends StatelessWidget {
const ContactUsPage({super.key});
@override
Widget build(BuildContext context) {
final TextEditingController firstNameController = TextEditingController();
final TextEditingController lastNameController = TextEditingController();
final TextEditingController emailController = TextEditingController();
final TextEditingController phoneController = TextEditingController();
final TextEditingController messageController = TextEditingController();
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header bar
CommonAppBar(isWhiteLogo: false, isProfilePage: true, showDivider: true,),
backWidget(context,"Contact Us", Colors.black),
SizedBox(height: 22.h),
CustomText(
text:
"You can get in touch with us through the below platforms. Our team will contact you shortly",
size: 14.sp,
color: Colors.black.withOpacity(.6),
),
SizedBox(height: 20.h),
// Customer Support Section
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 16.h),
decoration: BoxDecoration(
color: Color(0x00000005).withOpacity(.02),
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Customer Support",
size: 18.sp,
weight: FontWeight.w500,
),
SizedBox(height: 16.h),
_supportBox(
icon: Icons.phone,
title: "Contact Number",
subtitle: "+1012 3456 789",
action: "Tap to call",
),
SizedBox(height: 12.h),
_supportBox(
icon: Icons.email_rounded,
title: "Email",
subtitle: "citycards24@gmail.com",
action: "Tap to email",
),
SizedBox(height: 12.h),
_supportBox(
icon: Icons.location_on,
title: "Location",
subtitle:
"132 Dartmouth Street Boston, Massachusetts 02156 United States",
action: "View on map",
),
],
),
),
SizedBox(height: 24.h),
// Text fields
CustomTextField(
label: "First Name",
hint: "Enter your first name",
controller: firstNameController,
),
CustomTextField(
label: "Last Name",
hint: "Enter your last name",
controller: lastNameController,
),
CustomTextField(
label: "Email",
hint: "Enter your email address",
controller: emailController,
),
CustomTextField(
label: "Phone Number",
hint: "Enter your phone number",
controller: phoneController,
),
CustomTextField(
label: "Description",
hint: "Write your message here",
maxLines: 4,
controller: messageController,
),
// _descriptionField(messageController),
SizedBox(height: 24.h),
// Submit Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFF95F62),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(38.r),
),
padding: EdgeInsets.symmetric(vertical: 6.h),
),
onPressed: () {},
child: CustomText(
text: "Submit Ticket",
size: 16.sp,
weight: FontWeight.w500,
color: Colors.white,
),
),
),
SizedBox(height: 20.h),
],
),
),
),
);
}
// --- Support Info Box ---
Widget _supportBox({
required IconData icon,
required String title,
required String subtitle,
required String action,
}) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: const Color(0xFFF95F62), width: 0.8),
color: Colors.white,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, color: const Color(0xFFF95F62), size: 32.sp),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: title,
size: 11.sp,
weight: FontWeight.w600,
color: Color(0x00000000).withOpacity(.6),
),
SizedBox(height: 6.h),
Text(
subtitle,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: Colors.black,
),
),
SizedBox(height: 2.h),
Text(
action,
style: TextStyle(
fontSize: 11.sp,
color: Color(0xFF000000).withOpacity(.4),
fontWeight: FontWeight.w400,
),
),
],
),
),
],
),
);
}
// --- Description Field ---
Widget _descriptionField(TextEditingController controller) {
return Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "Description", size: 14.sp),
SizedBox(height: 6.h),
TextField(
controller: controller,
maxLines: 4,
decoration: InputDecoration(
hintText: "Write your message here",
hintStyle: TextStyle(fontSize: 12.sp, color: Color(0xFF8E8E8E)),
filled: true,
fillColor: const Color(0xFFFFF5F5),
contentPadding: EdgeInsets.symmetric(
horizontal: 24.w,
vertical: 12.h,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: .4.w,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(color: Color(0xFFF95F62), width: 1.w),
),
),
),
],
),
);
}
}

View File

@@ -1,14 +1,12 @@
import 'package:citycards_customer/Profile/profile_page_view.dart';
import 'package:citycards_customer/add_details/add_details_view.dart';
import 'package:citycards_customer/attraction_details/attraction_details_view.dart';
import 'package:citycards_customer/attraction_details/views/attraction_details_view.dart';
import 'package:citycards_customer/attractions/models/attraction_model.dart';
import 'package:citycards_customer/buy_a_pass/view/buy_pass_view.dart';
import 'package:citycards_customer/checkout/view/checkout_view.dart';
import 'package:citycards_customer/common_bloc/language_selection_bloc.dart';
import 'package:citycards_customer/contact_us/contact_us_view.dart';
import 'package:citycards_customer/create_account/create_account_view.dart';
import 'package:citycards_customer/edit_profile/edit_profile_view.dart';
import 'package:citycards_customer/create_account/view/create_account_view.dart';
import 'package:citycards_customer/esim_offer/esim_offer_view.dart';
import 'package:citycards_customer/faq/faq_view.dart';
import 'package:citycards_customer/hotel_offer/hotel_offer_view.dart';
import 'package:citycards_customer/intro_screens/views/intro_screen_view.dart';
import 'package:citycards_customer/itinerary_creation/bloc/itinerary_detail_bloc.dart';
@@ -16,13 +14,13 @@ import 'package:citycards_customer/itinerary_creation/bloc/itinerary_steps_selec
import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_start_view.dart';
import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_view.dart';
import 'package:citycards_customer/itinerary_creation/views/magic_itinerary_empty_view.dart';
import 'package:citycards_customer/itinerary_creation/views/magic_itinerary_filled_view.dart';
import 'package:citycards_customer/itinerary_creation/views/magic_itinerary_view.dart';
import 'package:citycards_customer/my_pass/views/pass_attractions_page_view.dart';
import 'package:citycards_customer/my_pass/views/search_pass_offers_with_listing.dart';
import 'package:citycards_customer/offer_pass_detail/offer_pass_detail_view.dart';
import 'package:citycards_customer/privacy/privacy_view.dart';
import 'package:citycards_customer/search_offers/bloc/search_offers_listing_bloc.dart';
import 'package:citycards_customer/search_offers/view/search_offers_with_listing.dart';
import 'package:citycards_customer/splash_screen/views/splash_screen.dart';
import 'package:citycards_customer/terms_and_condition/terms_and_condition_view.dart';
import 'package:citycards_customer/trail.dart';
import 'package:citycards_customer/your_itinerary/view/your_itinerary_view.dart';
import 'package:flutter/material.dart';
@@ -32,6 +30,19 @@ import '../cart/views/my_cart_view_page.dart';
import '../common_bloc/bottom_navigation_bloc.dart';
import '../home/views/home_page_view.dart';
import '../home/views/registered_user_home_page.dart';
import '../my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart';
import '../my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart';
import '../my_pass/repository/my_passes_attractions_repository.dart';
import '../my_pass/repository/my_passes_offers_repository.dart';
import '../my_pass/views/pass_attraction_details_view.dart';
import '../profile/view/contact_us/contact_us_view.dart';
import '../profile/view/edit_profile/edit_profile_view.dart';
import '../profile/view/faq/faq_view.dart';
import '../profile/view/privacy/privacy_view.dart';
import '../profile/view/profile_page_view.dart';
import '../profile/view/terms_and_condition/terms_and_condition_view.dart';
import '../search_offers/bloc/offers_bloc.dart';
import '../search_offers/repository/offers_repository.dart';
import 'route_constants.dart';
class AppRouter {
@@ -66,6 +77,24 @@ class AppRouter {
case RouteConstants.attractionsPage:
final args = settings.arguments as String;
return MaterialPageRoute(builder: (_) => AttractionsPage(source: args));
case RouteConstants.passAttractionsPage:
final Map<String, dynamic> args = settings.arguments as Map<String, dynamic>;
final int cityId = args['cityId'] as int;
final String source = args['source'] as String;
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => MyPassesAttractionsBloc(
repository: MyPassesAttractionsRepository(),
),
child: PassAttractionsPage(
cityXid: cityId,
source: source,
),
);
},
);
case RouteConstants.profile:
return MaterialPageRoute(
builder: (_) {
@@ -146,9 +175,18 @@ class AppRouter {
);
case RouteConstants.attractionDetails:
final attractionId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView();
return AttractionDetailsView(attractionId: attractionId);
},
);
case RouteConstants.passAttractionDetails:
final attractionID = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView(attractionId: attractionID);
},
);
@@ -160,12 +198,13 @@ class AppRouter {
);
case RouteConstants.checkout:
final bookingId = settings.arguments as int; // or String
return MaterialPageRoute(
builder: (_) {
return CheckoutView();
},
builder: (_) => CheckoutView(bookingId: bookingId),
);
case RouteConstants.cartPage:
return MaterialPageRoute(
builder: (_) {
@@ -177,23 +216,40 @@ class AppRouter {
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => OffersBloc(),
child: SearchOffersWithListing(),
create: (_) => OffersBloc(OffersRepository()),
child: OffersScreen(),
);
},
);
case RouteConstants.searchPassOffer:
final int cityId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => MyPassesOffersBloc(MyPassesOffersRepository()),
child: PassOffersScreen(cityId: cityId),
);
},
);
case RouteConstants.addDetails:
final bookingId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return AddDetailsView();
return AddDetailsView(bookingId: bookingId);
},
);
case RouteConstants.createAcct:
final email = settings.arguments as String;
return MaterialPageRoute(
builder: (_) {
return CreateAccountView();
return CreateAccountView(
email: email, // ✅ required param
);
},
);
@@ -214,17 +270,20 @@ class AppRouter {
case RouteConstants.magicItineraryFilledScreen:
return MaterialPageRoute(
builder: (_) {
return MagicItineraryFilledView();
return MagicItineraryView();
},
);
case RouteConstants.offerPassDetail:
final offerId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return OfferPassDetailView();
},
builder: (_) => OffersDetailsView(
offerId: offerId,
),
);
case RouteConstants.registeredUserHome:
return MaterialPageRoute(
builder: (_) {

View File

@@ -1,26 +1,40 @@
import 'package:citycards_customer/attractions/models/attraction_model.dart';
import 'package:citycards_customer/core/route_constants.dart';
import 'package:citycards_customer/home/views/registered_user_home_page.dart';
import 'package:citycards_customer/my_pass/blocs/my_pass_bloc.dart';
import 'package:citycards_customer/my_pass/views/pass_attraction_details_view.dart';
import 'package:citycards_customer/my_pass/views/pass_attractions_page_view.dart';
import 'package:citycards_customer/my_pass/views/search_pass_offers_with_listing.dart';
import 'package:citycards_customer/postcard/views/add_filter_step_page_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../attraction_details/attraction_details_view.dart';
import '../attraction_details/views/attraction_details_view.dart';
import '../attractions/views/attractions_page_view.dart';
import '../buy_a_pass/view/buy_pass_view.dart';
import '../checkout/view/checkout_view.dart';
import '../create_account/create_account_view.dart';
import '../create_account/view/create_account_view.dart';
import '../intro_screens/views/intro_screen_view.dart';
import '../itinerary_creation/bloc/itinerary_detail_bloc.dart';
import '../itinerary_creation/bloc/itinerary_steps_selection_bloc.dart';
import '../itinerary_creation/views/itinerary_creation_view.dart';
import '../itinerary_creation/views/magic_itinerary_filled_view.dart';
import '../itinerary_creation/views/magic_itinerary_view.dart';
import '../my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart';
import '../my_pass/blocs/myPassesDetails/my_passes_details_bloc.dart';
import '../my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart';
import '../my_pass/repository/my_passes_attractions_repository.dart';
import '../my_pass/repository/my_passes_details_repository.dart';
import '../my_pass/repository/my_passes_offers_repository.dart';
import '../my_pass/views/booking_page_view.dart';
import '../my_pass/views/booking_successful_page_view.dart';
import '../my_pass/views/qr_pass_page_view.dart';
import '../my_pass/views/pass_details_page_view.dart';
import '../offer_pass_detail/offer_pass_detail_view.dart';
import '../postcard/blocs/postcard_creation_bloc.dart';
import '../postcard/views/postcard_creation_page_view.dart';
import '../profile/view/privacy/privacy_view.dart';
import '../search_offers/bloc/offers_bloc.dart';
import '../search_offers/bloc/search_offers_listing_bloc.dart';
import '../search_offers/repository/offers_repository.dart';
import '../search_offers/view/search_offers_with_listing.dart';
import '../your_itinerary/view/your_itinerary_view.dart';
@@ -39,17 +53,49 @@ Widget buildOffstageNavigator(
case '/':
return MaterialPageRoute(builder: (_) => child);
case RouteConstants.intro:
return MaterialPageRoute(builder: (_){
return IntroScreensView();
});
// 🔹 Attractions Page
case RouteConstants.attractionsPage:
final args = settings.arguments as String;
return MaterialPageRoute(
builder: (_) => AttractionsPage(source: args),
);
case RouteConstants.passAttractionsPage:
final Map<String, dynamic> args = settings.arguments as Map<String, dynamic>;
final int cityId = args['cityId'] as int;
final String source = args['source'] as String;
case RouteConstants.attractionDetails:
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView();
return BlocProvider(
create: (_) => MyPassesAttractionsBloc(
repository: MyPassesAttractionsRepository(),
),
child: PassAttractionsPage(
cityXid: cityId,
source: source,
),
);
},
);
case RouteConstants.attractionDetails:
final attractionID = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView(attractionId: attractionID);
},
);
case RouteConstants.passAttractionDetails:
final attractionID = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return PassAttractionDetailsView(attractionId: attractionID);
},
);
@@ -72,19 +118,40 @@ Widget buildOffstageNavigator(
);
case RouteConstants.offerPassDetail:
return MaterialPageRoute(builder: (_){
return OfferPassDetailView();
});
final offerId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) => OffersDetailsView(
offerId: offerId,
),
);
case RouteConstants.searchOffer:
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => OffersBloc(),
child: SearchOffersWithListing(),
create: (_) => OffersBloc(OffersRepository()),
child: OffersScreen(),
);
},
);
case RouteConstants.searchPassOffer:
final int cityId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => MyPassesOffersBloc(MyPassesOffersRepository()),
child: PassOffersScreen(cityId: cityId),
);
},
);
case RouteConstants.privacyPolicy:
return MaterialPageRoute(
builder: (_) {
return const PrivacyPolicyPage();
},
);
// 🔹 Upload Photo Page (start of postcard creation flow)
case RouteConstants.uploadPhotoPage:
@@ -110,12 +177,14 @@ Widget buildOffstageNavigator(
);
case RouteConstants.qrPage:
final bookingId = settings.arguments as int;
return MaterialPageRoute(
builder: (context) {
final previousBloc = BlocProvider.of<MyPassBloc>(context);
return BlocProvider.value(
value: previousBloc,
child: const QrPassView(),
return BlocProvider(
create: (context) => MyPassesDetailsBloc(
repository: MyPassesDetailsRepository(),
),
child: PassDetailsView(bookingId: bookingId),
);
},
);
@@ -147,16 +216,19 @@ Widget buildOffstageNavigator(
case RouteConstants.magicItineraryFilledScreen:
return MaterialPageRoute(builder: (_){
return MagicItineraryFilledView();
return MagicItineraryView();
});
case RouteConstants.checkout:
final bookingId = settings.arguments as int; // or String
return MaterialPageRoute(
builder: (_) {
return CheckoutView();
},
builder: (_) => CheckoutView(
bookingId: bookingId,
),
);
case RouteConstants.buyPass:
return MaterialPageRoute(
builder: (_) {
@@ -165,9 +237,13 @@ Widget buildOffstageNavigator(
);
case RouteConstants.createAcct:
final email = settings.arguments as String;
return MaterialPageRoute(
builder: (_) {
return CreateAccountView();
return CreateAccountView(
email: email, // ✅ required param
);
},
);

View File

@@ -9,6 +9,7 @@ class RouteConstants {
static const String home = '/home';
static const String registeredUserHome = '/registeredUserHome';
static const String attractionsPage = "/attractions";
static const String passAttractionsPage = "/passAttractionsPage";
static const String postCardPage = "/postcards";
static const String uploadPhotoPage = "/uploadPhoto";
static const String addFilterPage = "/addFilter";
@@ -27,7 +28,8 @@ class RouteConstants {
static const String magicItineraryEmptyScreen = '/magicItineraryEmptyScreen';
static const String itineraryCreationStart = '/itineraryCreationStart';
static const String itineraryCreation = '/itineraryCreation';
static const String magicItineraryFilledScreen = "/magicItineraryFilledScreen";
static const String magicItineraryFilledScreen =
"/magicItineraryFilledScreen";
/**************************** ESIM Page *****************************************/
@@ -37,12 +39,14 @@ class RouteConstants {
/**************************** Attraction Page *****************************************/
static const String attractionDetails ='/attractionDetails';
static const String passAttractionDetails ='/passAttractionDetails';
/**************************** By Pass Page Page *****************************************/
static const String buyPass ='/buyPass';
static const String checkout ='/checkout';
static const String buyPass = '/buyPass';
static const String checkout = '/checkout';
static const String searchOffer = '/searchOffer';
static const String searchPassOffer = '/searchPassOffer';
static const String createAcct = '/createAcct';
static const String addDetails = '/addDetails';
static const String offerPassDetail = "/offerPassDetail";
@@ -56,4 +60,5 @@ class RouteConstants {
static const String qrPage = '/qrPage';
static const String makeBooking = '/makeBooking';
static const String bookingSuccessful = '/bookingSuccessful';
static const String editPostCard = '/editPostCard';
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../localPreference/local_preference.dart';
import '../models/create_account_model.dart';
import '../repository/create_account_repository.dart';
import 'create_account_event.dart';
import 'create_account_state.dart';
class CreateAccountBloc extends Bloc<CreateAccountEvent, CreateAccountState> {
final CreateAccountRepository repository;
CreateAccountBloc({required this.repository})
: super(const CreateAccountInitial()) {
on<CreateAccountSubmitted>(_onCreateAccountSubmitted);
on<CreateAccountReset>(_onCreateAccountReset);
}
Future<void> _onCreateAccountSubmitted(
CreateAccountSubmitted event,
Emitter<CreateAccountState> emit,
) async {
emit(const CreateAccountLoading());
try {
final response = await repository.registerUser(
firstName: event.firstName,
lastName: event.lastName,
emailAddress: event.emailAddress,
mobileNumber: event.mobileNumber,
address1: event.address1,
address2: event.address2,
city: event.city,
state: event.state,
country: event.country,
postalCode: event.postalCode,
);
await LocalPreference.setLogin(true);
// ✅ FIX: Parse directly from response, just like verify OTP
final userModel = UserRegisteredModel.fromJson(response);
await LocalPreference.setTokens(
accessToken: userModel.accessToken,
refreshToken: userModel.refreshToken,
refreshTokenMaxAge: userModel.refreshTokenMaxAge,
);
await LocalPreference.setUserDetails(
userId: userModel.user.id,
firstName: userModel.user.firstName,
lastName: userModel.user.lastName,
fullName: userModel.user.fullName,
emailAddress: userModel.user.emailAddress,
role: userModel.user.role,
roleId: userModel.user.roleId,
);
await LocalPreference.setProfileImage(userModel.user.profileImage);
emit(CreateAccountSuccess(
message: 'Account created successfully',
userData: response,
));
} catch (e) {
emit(CreateAccountFailure(
errorMessage: e.toString().replaceAll('Exception: ', ''),
));
}
}
void _onCreateAccountReset(
CreateAccountReset event,
Emitter<CreateAccountState> emit,
) {
emit(const CreateAccountInitial());
}
}

View File

@@ -0,0 +1,52 @@
import 'package:equatable/equatable.dart';
abstract class CreateAccountEvent extends Equatable {
const CreateAccountEvent();
@override
List<Object?> get props => [];
}
class CreateAccountSubmitted extends CreateAccountEvent {
final String firstName;
final String lastName;
final String emailAddress;
final String mobileNumber;
final String address1;
final String address2;
final String city;
final String state;
final String country;
final String postalCode;
const CreateAccountSubmitted({
required this.firstName,
required this.lastName,
required this.emailAddress,
required this.mobileNumber,
required this.address1,
required this.address2,
required this.city,
required this.state,
required this.country,
required this.postalCode,
});
@override
List<Object?> get props => [
firstName,
lastName,
emailAddress,
mobileNumber,
address1,
address2,
city,
state,
country,
postalCode,
];
}
class CreateAccountReset extends CreateAccountEvent {
const CreateAccountReset();
}

View File

@@ -0,0 +1,38 @@
import 'package:equatable/equatable.dart';
abstract class CreateAccountState extends Equatable {
const CreateAccountState();
@override
List<Object?> get props => [];
}
class CreateAccountInitial extends CreateAccountState {
const CreateAccountInitial();
}
class CreateAccountLoading extends CreateAccountState {
const CreateAccountLoading();
}
class CreateAccountSuccess extends CreateAccountState {
final String message;
final Map<String, dynamic> userData;
const CreateAccountSuccess({
required this.message,
required this.userData,
});
@override
List<Object?> get props => [message, userData];
}
class CreateAccountFailure extends CreateAccountState {
final String errorMessage;
const CreateAccountFailure({required this.errorMessage});
@override
List<Object?> get props => [errorMessage];
}

View File

@@ -1,123 +0,0 @@
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:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class CreateAccountView extends StatelessWidget {
CreateAccountView({super.key});
final TextEditingController firstNameController = TextEditingController();
final TextEditingController lastNameController = TextEditingController();
final TextEditingController emailController = TextEditingController();
final TextEditingController phoneController = TextEditingController();
final TextEditingController addressController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showCart: false,
showDivider: true,
),
Row(
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Icon(Icons.arrow_back),
),
SizedBox(width: 8.w),
CustomText(text: "Create your account", size: 12.sp),
],
),
SizedBox(height: 26.h,),
Align(
alignment: Alignment.centerLeft,
child: CustomText(
text: "Personal Information",
size: 18.sp,
weight: FontWeight.w500,
),
),
SizedBox(height: 12.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "First Name",
hint: "Enter your first name",
controller: firstNameController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Last Name",
hint: "Enter your last name",
controller: lastNameController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Email",
hint: "Enter your email address",
controller: emailController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Phone Number",
hint: "Enter your phone number",
controller: phoneController,
),
),
SizedBox(height: 2.h),
// Location Details
Align(
alignment: Alignment.centerLeft,
child: CustomText(
text: "Location Details",
size: 18.sp,
weight: FontWeight.w500,
),
),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
child: CustomTextField(
label: "Address 1",
hint: "Enter address manually or tap to search",
controller: addressController,
),
),
SizedBox(height: 36.h),
CustomFilledButton(
width: double.infinity,
onTap: (){}, label: "Create Account")
],
),
),
),
);
}
}

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