23 Commits

Author SHA1 Message Date
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
226 changed files with 21604 additions and 6453 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.

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 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,116 @@
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;
StripePaymentBloc({
StripeService? stripeService,
}) : _stripeService = stripeService ?? StripeService(),
super(const StripePaymentInitial()) {
on<InitiatePayment>(_onInitiatePayment);
on<InitiatePaymentWithClientSecret>(_onInitiatePaymentWithClientSecret);
on<ResetPaymentState>(_onResetPaymentState);
}
Future<void> _onInitiatePayment(
InitiatePayment event,
Emitter<StripePaymentState> emit,
) async {
try {
emit(const StripePaymentLoading());
/// 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,
);
// 2⃣ Init Payment Sheet
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: clientSecret,
merchantDisplayName: "CityCards",
style: ThemeMode.light,
),
);
// 3⃣ Show Payment Sheet
await Stripe.instance.presentPaymentSheet();
// ✅ SUCCESS
emit(const StripePaymentSuccess());
} on StripeException catch (e) {
// Handle Stripe-specific errors
if (e.error.code == FailureCode.Canceled) {
emit(StripePaymentCancelled(
message: e.error.localizedMessage ?? 'Payment Cancelled',
));
} else {
emit(StripePaymentFailure(
error: e.error.localizedMessage ?? 'Payment failed',
));
}
} catch (e) {
emit(StripePaymentFailure(
error: e.toString(),
));
}
}
/// 🆕 NEW: Handle payment with clientSecret directly from backend
Future<void> _onInitiatePaymentWithClientSecret(
InitiatePaymentWithClientSecret event,
Emitter<StripePaymentState> emit,
) async {
try {
emit(const StripePaymentLoading());
// 1⃣ Init Payment Sheet with clientSecret from backend
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: event.clientSecret,
merchantDisplayName: "CityCards",
style: ThemeMode.light,
),
);
// 2⃣ Show Payment Sheet
await Stripe.instance.presentPaymentSheet();
// ✅ SUCCESS
emit(const StripePaymentSuccess());
} on StripeException catch (e) {
// Handle Stripe-specific errors
if (e.error.code == FailureCode.Canceled) {
emit(StripePaymentCancelled(
message: e.error.localizedMessage ?? 'Payment Cancelled',
));
} else {
emit(StripePaymentFailure(
error: e.error.localizedMessage ?? 'Payment failed',
));
}
} catch (e) {
emit(StripePaymentFailure(
error: e.toString(),
));
}
}
void _onResetPaymentState(
ResetPaymentState event,
Emitter<StripePaymentState> emit,
) {
emit(const StripePaymentInitial());
}
}

View File

@@ -0,0 +1,37 @@
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];
}
/// 🆕 NEW: 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];
}
class ResetPaymentState extends StripePaymentEvent {
const ResetPaymentState();
}

View File

@@ -0,0 +1,49 @@
import 'package:equatable/equatable.dart';
abstract class StripePaymentState extends Equatable {
const StripePaymentState();
@override
List<Object?> get props => [];
}
class StripePaymentInitial extends StripePaymentState {
const StripePaymentInitial();
}
class StripePaymentLoading extends StripePaymentState {
const StripePaymentLoading();
}
class StripePaymentSuccess extends StripePaymentState {
final String message;
const StripePaymentSuccess({
this.message = 'Payment Successful',
});
@override
List<Object?> get props => [message];
}
class StripePaymentFailure extends StripePaymentState {
final String error;
const StripePaymentFailure({
required this.error,
});
@override
List<Object?> get props => [error];
}
class StripePaymentCancelled extends StripePaymentState {
final String message;
const StripePaymentCancelled({
this.message = 'Payment Cancelled',
});
@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,230 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/stripe_payment_bloc.dart';
import '../bloc/stripe_payment_event.dart';
import '../bloc/stripe_payment_state.dart';
import '../repository/stripe_service.dart';
class StripePaymentView extends StatelessWidget {
const StripePaymentView({super.key});
@override
Widget build(BuildContext context) {
final args =
ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
final double amount = args['amount'];
final String currency = args['currency'];
return BlocProvider(
create: (context) => StripePaymentBloc(
stripeService: StripeService(),
),
child: StripePaymentViewContent(
amount: amount,
currency: currency,
),
);
}
}
class StripePaymentViewContent extends StatefulWidget {
final double amount;
final String currency;
const StripePaymentViewContent({
super.key,
required this.amount,
required this.currency,
});
@override
State<StripePaymentViewContent> createState() =>
_StripePaymentViewContentState();
}
class _StripePaymentViewContentState extends State<StripePaymentViewContent> {
@override
void initState() {
super.initState();
// Automatically initiate payment when screen loads
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<StripePaymentBloc>().add(
InitiatePayment(
amount: widget.amount,
currency: widget.currency,
),
);
});
}
@override
Widget build(BuildContext context) {
return BlocListener<StripePaymentBloc, StripePaymentState>(
listener: (context, state) {
if (state is StripePaymentSuccess) {
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
// Return success to previous screen
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
Navigator.pop(context, true);
}
});
} else if (state is StripePaymentFailure) {
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
// Go back to checkout on error
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
Navigator.pop(context, false);
}
});
} else if (state is StripePaymentCancelled) {
// Show cancellation message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
// Go back to checkout on cancellation
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
Navigator.pop(context, false);
}
});
}
},
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text("Processing Payment"),
backgroundColor: Colors.white,
elevation: 0,
automaticallyImplyLeading: false, // Remove back button during processing
centerTitle: true,
),
body: BlocBuilder<StripePaymentBloc, StripePaymentState>(
builder: (context, state) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Loading Indicator
if (state is StripePaymentLoading) ...[
const CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFFF95F62),
),
),
const SizedBox(height: 24),
const Text(
"Preparing secure payment...",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF333333),
),
),
const SizedBox(height: 12),
Text(
"Please wait",
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
// Amount Display
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFE0E0E0),
),
),
child: Column(
children: [
Text(
"Payment Amount",
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
"\$${widget.amount.toStringAsFixed(2)}",
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
const SizedBox(height: 4),
Text(
widget.currency.toUpperCase(),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(height: 32),
// Security Badge
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.lock_outline,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 6),
Text(
"Secured by Stripe",
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
],
),
),
);
},
),
),
);
}
}

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: ["India", "USA", "UK", "Canada"]
.map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
SizedBox(height: 24.h),
// Option 1: Pass empty function when disabled (doesn't change button appearance)
CustomFilledButton(
onTap: () => _handleSubmit(context, isSubmitting),
label: isSubmitting ? "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

@@ -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,304 @@
/* -------------------- 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 int cityXid;
final int cardTypeXid;
final int partnerXid;
final String productCode;
final bool isBookingRequired;
final bool isPartnerAccess;
final String bookingEmail;
final String bookingPhoneNumber;
final double latitudeCoordinate;
final double longitudeCoordinate;
final String address;
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 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?)?.toDouble() ?? 0.0,
longitudeCoordinate:
(json['longitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
address: json['address'] ?? '',
ticketPriceAdult: (json['ticketPriceAdult'] as num?)?.toDouble(),
ticketPriceChild: (json['ticketPriceChild'] as num?)?.toDouble(),
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 int cardTypeXid;
final int adultPrice;
final int 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,90 @@ 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,
);
},
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),
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
SizedBox(height: 6.h),
Text(
attraction.address,
style: GoogleFonts.poppins(
fontSize: 12.sp,
fontWeight: FontWeight.w400,
color: const Color(0xff464646),
),
),
SizedBox(height: 6.h),
Text.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 +102,70 @@ class AttractionCard extends StatelessWidget {
],
),
),
const SizedBox(height: 6),
SizedBox(height: 6.h),
/// TAGS (CARD TITLES)
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,
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(),
)
: Container(
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: const Color(0xffC1D2F8),
border: Border.all(
color: const Color(0xff2563EB),
),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
"Booking Required",
style: GoogleFonts.poppins(
fontSize: 11.sp,
color: const Color(0xff1A1A1A),
fontWeight: FontWeight.w400,
),
),
),
],
),
),
@@ -139,4 +174,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,304 @@
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 {
final City city;
final List<Offer> offers;
final List<CardPass> cards;
final List<Attraction> attractions;
BuyPassModel({
required this.city,
required this.offers,
required this.cards,
required this.attractions,
});
factory BuyPassModel.fromJson(Map<String, dynamic> json) {
return BuyPassModel(
city: City.fromJson(json['city']),
offers: List<Offer>.from(
json['offers'].map((x) => Offer.fromJson(x)),
),
cards: List<CardPass>.from(
json['cards'].map((x) => CardPass.fromJson(x)),
),
attractions: List<Attraction>.from(
json['attractions'].map((x) => Attraction.fromJson(x)),
),
);
}
Map<String, dynamic> toJson() => {
"city": city.toJson(),
"offers": offers.map((x) => x.toJson()).toList(),
"cards": cards.map((x) => x.toJson()).toList(),
"attractions": attractions.map((x) => x.toJson()).toList(),
};
}
/// ---------- CITY ----------
class City {
final int id;
final String name;
final String slug;
final String tagLine;
final String description;
final String bestTimeToVisit;
final String priceRange;
final num individualTicketAmount; // Changed from int to num
final num cityCardTicketAmount; // Changed from int to num
final HeroBanner heroBanner;
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) {
return City(
id: json['id'],
name: json['name'],
slug: json['slug'],
tagLine: json['tagLine'],
description: json['description'],
bestTimeToVisit: json['bestTimeToVisit'],
priceRange: json['priceRange'],
individualTicketAmount: json['individualTicketAmount'],
cityCardTicketAmount: json['cityCardTicketAmount'],
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 {
final String title;
final String image;
HeroBanner({
required this.title,
required this.image,
});
factory HeroBanner.fromJson(Map<String, dynamic> json) {
return HeroBanner(
title: json['title'],
image: json['image'],
);
}
Map<String, dynamic> toJson() => {
"title": title,
"image": image,
};
}
/// ---------- OFFER ----------
class Offer {
final int id;
final String title;
final String offerCode;
final String? description; // ✅ optional
final String? redemptionLink; // ✅ optional
final String websiteBannerImage;
final String mobileBannerImage;
final String passType;
final DateTime startDateTime;
final DateTime endDateTime;
final String offerStatus;
final bool applyToPasses;
Offer({
required this.id,
required this.title,
required this.offerCode,
this.description,
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) {
return Offer(
id: json['id'],
title: json['title'],
offerCode: json['offerCode'],
description: json['description'], // ✅
redemptionLink: json['redemptionLink'], // ✅
websiteBannerImage: json['websiteBannerImage'],
mobileBannerImage: json['mobileBannerImage'],
passType: json['passType'],
startDateTime: DateTime.parse(json['startDateTime']),
endDateTime: DateTime.parse(json['endDateTime']),
offerStatus: json['offerStatus'],
applyToPasses: json['applyToPasses'],
);
}
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 {
final int id;
final String title;
final String description;
final int validityDuration;
final num adultPrice; // Changed from int to num
final num childPrice; // Changed from int to num
final int minNumber; // ✅ NEW
final int maxNumber; // ✅ NEW
final CardType cardType;
final List<Offer> offers;
CardPass({
required this.id,
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) {
return CardPass(
id: json['id'],
title: json['title'],
description: json['description'],
validityDuration: json['validityDuration'],
adultPrice: json['adultPrice'],
childPrice: json['childPrice'],
minNumber: json['minNumber'], // ✅
maxNumber: json['maxNumber'], // ✅
cardType: CardType.fromJson(json['cardType']),
offers: List<Offer>.from(
json['offers'].map((x) => Offer.fromJson(x)),
),
);
}
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((x) => x.toJson()).toList(),
};
}
/// ---------- CARD TYPE ----------
class CardType {
final int id;
final String name;
final String displayName;
CardType({
required this.id,
required this.name,
required this.displayName,
});
factory CardType.fromJson(Map<String, dynamic> json) {
return CardType(
id: json['id'],
name: json['name'],
displayName: json['displayName'],
);
}
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"displayName": displayName,
};
}
/// ---------- ATTRACTION ----------
class Attraction {
final int id;
final String title;
final String slug;
final String thumbnail;
final num? startingFrom; // Changed from int? to num?
Attraction({
required this.id,
required this.title,
required this.slug,
required this.thumbnail,
this.startingFrom,
});
factory Attraction.fromJson(Map<String, dynamic> json) {
return Attraction(
id: json['id'],
title: json['title'],
slug: json['slug'],
thumbnail: json['thumbnail'],
startingFrom: json['startingFrom'],
);
}
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,51 @@
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,
}) async {
try {
final response = await _apiService.postApi(
url: ApiUrls.addToCartPasses, // add this key in ApiUrls
data: {
"cityXid": cityXid,
"cardTypeXid": cardTypeXid,
"cardXid": cardXid,
"cardMode": cardMode,
"totalAdult": totalAdult,
"totalChild": totalChild,
"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,
// );
},
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,325 @@
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 Card",
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' : 'fixed',
totalAdult: adults,
totalChild: children,
noOfAttractions: isSelectivePass ? selectedValue : 0,
noOfDays: isUnlimitedCard ? selectedValue : 0,
);
// ✅ Extract bookingId from response
final int bookingId = response['id'];
// ✅ Navigate to checkout with bookingId
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => CheckoutView(bookingId: bookingId),
settings: RouteSettings(
arguments: checkoutData,
),
),
);
}
} 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 +340,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,73 @@
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<FetchPassCartEvent>(_onFetchPassCart);
on<ClearPassCartEvent>(_onClearPassCart);
}
/// Handle fetching pass cart data
Future<void> _onFetchPassCart(
FetchPassCartEvent event,
Emitter<MyPassCartState> emit,
) async {
try {
if (kDebugMode) {
print('🔄 [BLOC] Fetching pass cart...');
}
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,18 @@
import 'package:equatable/equatable.dart';
abstract class MyPassCartEvent extends Equatable {
const MyPassCartEvent();
@override
List<Object?> get props => [];
}
/// 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,43 @@
import 'package:equatable/equatable.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
class MyPassCartLoaded extends MyPassCartState {
final Map<String, dynamic> cartData;
const MyPassCartLoaded({required this.cartData});
@override
List<Object?> get props => [cartData];
}
/// 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

@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import '../../localPreference/local_preference.dart';
class MyPassCartRepository {
/// 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;
}
}
}

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,486 @@
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 '../../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;
@override
void initState() {
super.initState();
// Fetch cart data when page loads
context.read<MyPassCartBloc>().add(const FetchPassCartEvent());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<MyPassCartBloc, MyPassCartState>(
builder: (context, state) {
if (state is MyPassCartLoading) {
return const Center(child: CircularProgressIndicator());
} 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 taxRate = 0.05; // 5% tax
final double totalBeforeTax = subtotal - discountAmount;
final double taxAmount = totalBeforeTax * taxRate;
final double finalTotal = totalBeforeTax + taxAmount;
// Determine if unlimited card
final bool isUnlimitedCard = cardTypeName == "unlimited_card";
final String validityLabel = isUnlimitedCard
? "$validityDuration Days"
: "$validityDuration Attractions";
return Column(
children: [
SizedBox(height: 22.h),
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: Color(themeColor).withOpacity(0.2),
),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
child: heroImage.isNotEmpty
? Image.network(
heroImage,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
);
},
)
: Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
),
),
SizedBox(width: 6.66.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: cityName,
weight: FontWeight.w500,
size: 16.sp,
),
SizedBox(height: 5.h),
CustomText(
text: validityLabel,
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(height: 5.h),
SizedBox(
width: MediaQuery.of(context).size.width * .5,
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Image.asset(
'assets/icons/adult.png',
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "$adultCount ${adultCount == 1 ? 'adult' : 'adults'}",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
],
),
Row(
children: [
Image.asset(
'assets/icons/qty.png',
scale: 4,
),
SizedBox(width: 4.w),
Text.rich(
TextSpan(
children: [
TextSpan(
text: "Qty:",
style: TextStyle(
color: Color(0xFF8E8E8E),
fontSize: 12.sp,
),
),
TextSpan(
text: " ${adultCount + childCount}",
style: TextStyle(
color: Color(0xFF000000),
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 5.h),
Row(
children: [
Image.asset(
"assets/icons/kid.png",
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "$childCount ${childCount == 1 ? 'Kid' : 'Kids'}",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(width: 53.w),
CustomText(
text: "\$${totalPrice.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
color: Color(0xFFF95F62),
),
],
),
],
),
],
),
Container(
width: 35.w,
height: 123.h,
decoration: BoxDecoration(
color: Color(themeColor),
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
),
),
child: RotatedBox(
quarterTurns: -1,
child: Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: "$cardDisplayName ",
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
),
// TextSpan(
// text: "Card",
// style: TextStyle(
// color: Colors.white,
// fontSize: 12.sp,
// ),
// ),
],
),
),
),
),
),
],
),
),
SizedBox(height: 15.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: Color(0xFFBB474A).withOpacity(0.4),
width: 0.8,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Get 10% off on your first trip",
color: Color(0xFF262626),
size: 14.sp,
),
SizedBox(height: 7.h),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => AllCouponsBottomsheet(),
);
},
child: CustomText(
text: "View all coupons",
color: Color(0xFFF95F62),
size: 12,
),
),
SizedBox(width: 3.w),
Icon(Icons.arrow_right, color: Color(0xFFF95F62)),
],
),
],
),
const Spacer(),
GestureDetector(
onTap: () {
setState(() {
if (appliedCouponCode == null) {
appliedCouponCode = "FIRST10";
discountPercentage = 10.0;
} else {
appliedCouponCode = null;
discountPercentage = 0.0;
}
});
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 10.h,
),
decoration: BoxDecoration(
border: Border.all(color: Color(0xFFF95F62)),
borderRadius: BorderRadius.circular(8.r),
),
child: CustomText(
text: appliedCouponCode != null ? "Remove" : "Apply",
color: Color(0xFFF95F62),
size: 14.sp,
),
),
),
],
),
),
SizedBox(height: 15.h),
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Subtotal", size: 14.sp),
CustomText(
text: "\$${subtotal.toStringAsFixed(2)}",
size: 14.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 14.h),
if (discountPercentage > 0) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Discount", size: 14.sp),
CustomText(
text: "-\$${discountAmount.toStringAsFixed(2)} (${discountPercentage.toStringAsFixed(0)}%)",
size: 14.sp,
weight: FontWeight.w500,
color: Colors.green,
),
],
),
SizedBox(height: 14.h),
],
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: 'Total', size: 14.sp),
SizedBox(height: 4.h),
CustomText(
text: "Including \$${taxAmount.toStringAsFixed(2)} in taxes",
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
],
),
),
CustomText(
text: "\$${finalTotal.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 150.h),
// FutureBuilder for login check
FutureBuilder<bool>(
future: LocalPreference.getLogin(),
builder: (context, snapshot) {
final isLoggedIn = snapshot.data ?? false;
return CustomFilledButton(
onTap: () {
if (!isLoggedIn) {
showModalBottomSheet(
backgroundColor: Colors.white,
context: context,
isScrollControlled: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => const LoginEmailBottomsheet(),
);
} else {
// Handle checkout logic for logged in user
// You can navigate to checkout or payment screen
print("✅ User is logged in, proceed to checkout");
}
},
width: double.infinity,
label: isLoggedIn ? "Checkout" : "Login to Checkout",
);
},
),
SizedBox(height: 25.h),
],
);
} else if (state is MyPassCartEmpty) {
return Center(
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,185 @@
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<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));
}
}
void _onRemoveCoupon(
RemoveCouponEvent event,
Emitter<CheckoutState> emit,
) {
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(clearAppliedCoupon: true));
}
}
/// 🆕 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 {
// Show loading state
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(
isConfirmingPayment: true,
confirmationError: null,
isPaymentConfirmed: false,
));
} 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(),
));
} else {
emit(CheckoutPaymentConfirmationErrorState(
error: e.toString(),
));
}
}
}
}

View File

@@ -0,0 +1,34 @@
import '../../models/all_coupons_model.dart';
abstract class CheckoutEvent {}
class FetchCheckoutCouponsEvent extends CheckoutEvent {}
class ApplyCouponEvent extends CheckoutEvent {
final AllCouponsModel coupon;
ApplyCouponEvent({required this.coupon});
}
class RemoveCouponEvent extends CheckoutEvent {}
/// 🆕 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,109 @@
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;
// 🆕 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
CheckoutCouponsLoadedState({
required this.coupons,
this.appliedCoupon,
this.isInitiatingPayment = false,
this.clientSecret,
this.bookingId,
this.paymentError,
this.isConfirmingPayment = false,
this.isPaymentConfirmed = false,
this.confirmationError,
this.bookingDetails,
});
CheckoutCouponsLoadedState copyWith({
List<AllCouponsModel>? coupons,
AllCouponsModel? appliedCoupon,
bool clearAppliedCoupon = false,
bool? isInitiatingPayment,
String? clientSecret,
int? bookingId,
String? paymentError,
bool? isConfirmingPayment,
bool? isPaymentConfirmed,
String? confirmationError,
bool clearClientSecret = false,
Map<String, dynamic>? bookingDetails,
}) {
return CheckoutCouponsLoadedState(
coupons: coupons ?? this.coupons,
appliedCoupon: clearAppliedCoupon ? null : (appliedCoupon ?? this.appliedCoupon),
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,
);
}
}
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,
'recipientName': recipientFirstName ?? '',
// 'recipientLastName': recipientLastName ?? '',
'recipientEmail': recipientEmail ?? '',
'recipientPhone': recipientPhone ?? '',
// 'city': city ?? '',
// 'country': country ?? '',
};
log('📦 Request Body: $requestBody');
// Send POST request
final response = await _apiServices.putApi(
url: url,
data: requestBody,
);
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 = "Selective";
}

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,11 @@ 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/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 +28,14 @@ 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 '../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 {
@@ -146,9 +150,10 @@ class AppRouter {
);
case RouteConstants.attractionDetails:
final attractionId = settings.arguments as Attraction;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView();
return AttractionDetailsView(attractionId: attractionId.id,);
},
);
@@ -160,12 +165,15 @@ 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 +185,32 @@ class AppRouter {
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => OffersBloc(),
child: SearchOffersWithListing(),
create: (_) => OffersBloc(OffersRepository()),
child: OffersScreen(),
);
},
);
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 +231,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,3 +1,4 @@
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';
@@ -5,22 +6,25 @@ 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/views/booking_page_view.dart';
import '../my_pass/views/booking_successful_page_view.dart';
import '../my_pass/views/qr_pass_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 '../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,6 +43,11 @@ 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;
@@ -47,9 +56,10 @@ Widget buildOffstageNavigator(
);
case RouteConstants.attractionDetails:
final attraction = settings.arguments as Attraction;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView();
return AttractionDetailsView(attractionId: attraction.id);
},
);
@@ -72,16 +82,20 @@ 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(),
);
},
);
@@ -147,16 +161,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 +182,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

@@ -0,0 +1,66 @@
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,
);
final userModel = UserRegisteredModel.fromJson(response['data'] ?? {});
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: response['message'] ?? 'Account created successfully',
userData: response['data'] ?? {},
));
} catch (e) {
emit(CreateAccountFailure(
errorMessage: e.toString().replaceAll('Exception: ', ''),
));
}
}
void _onCreateAccountReset(
CreateAccountReset event,
Emitter<CreateAccountState> emit,
) {
emit(const CreateAccountInitial());
}
}

View File

@@ -0,0 +1,40 @@
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;
const CreateAccountSubmitted({
required this.firstName,
required this.lastName,
required this.emailAddress,
required this.mobileNumber,
required this.address1,
required this.address2,
});
@override
List<Object?> get props => [
firstName,
lastName,
emailAddress,
mobileNumber,
address1,
address2,
];
}
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")
],
),
),
),
);
}
}

View File

@@ -0,0 +1,90 @@
class UserRegisteredModel {
final bool verified;
final bool userExists;
final String accessToken;
final String refreshToken;
final int refreshTokenMaxAge;
final User user;
UserRegisteredModel({
required this.verified,
required this.userExists,
required this.accessToken,
required this.refreshToken,
required this.refreshTokenMaxAge,
required this.user,
});
factory UserRegisteredModel.fromJson(Map<String, dynamic> json) {
return UserRegisteredModel(
verified: json['verified'] ?? false,
userExists: json['userExists'] ?? false,
accessToken: json['accessToken'] ?? '',
refreshToken: json['refreshToken'] ?? '',
refreshTokenMaxAge: json['refreshTokenMaxAge'] ?? 0,
user: User.fromJson(json['user'] ?? {}),
);
}
Map<String, dynamic> toJson() {
return {
'verified': verified,
'userExists': userExists,
'accessToken': accessToken,
'refreshToken': refreshToken,
'refreshTokenMaxAge': refreshTokenMaxAge,
'user': user.toJson(),
};
}
}
/// ------------------------------------------------------------
/// User Model (Nested)
/// ------------------------------------------------------------
class User {
final int id;
final String firstName;
final String lastName;
final String fullName;
final String emailAddress;
final String profileImage; // ✅ newly added
final String role;
final int roleId;
User({
required this.id,
required this.firstName,
required this.lastName,
required this.fullName,
required this.emailAddress,
required this.profileImage,
required this.role,
required this.roleId,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] ?? 0,
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
fullName: json['fullName'] ?? '',
emailAddress: json['emailAddress'] ?? '',
profileImage: json['profileImage'] ?? '',
role: json['role'] ?? '',
roleId: json['roleId'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'firstName': firstName,
'lastName': lastName,
'fullName': fullName,
'emailAddress': emailAddress,
'profileImage': profileImage,
'role': role,
'roleId': roleId,
};
}
}

View File

@@ -0,0 +1,33 @@
import 'package:citycards_customer/networkApiServices/api_urls.dart';
import 'package:citycards_customer/networkApiServices/network_api_services.dart';
class CreateAccountRepository {
final NetworkApiService _apiServices = NetworkApiService();
Future<Map<String, dynamic>> registerUser({
required String firstName,
required String lastName,
required String emailAddress,
required String mobileNumber,
required String address1,
required String address2,
}) async {
try {
final response = await _apiServices.postApi(
url: ApiUrls.createAccount,
data: {
'firstName': firstName,
'lastName': lastName,
'emailAddress': emailAddress,
'mobileNumber': mobileNumber,
'address1': address1,
'address2': address2,
},
);
return response.data as Map<String, dynamic>;
} catch (e) {
throw Exception('Failed to create account: $e');
}
}
}

View File

@@ -0,0 +1,208 @@
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';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../localPreference/local_preference.dart';
import '../../profile/bloc/profile/profile_bloc.dart';
import '../../profile/bloc/profile/profile_event.dart';
import '../bloc/create_account_bloc.dart';
import '../bloc/create_account_event.dart';
import '../bloc/create_account_state.dart';
import '../repository/create_account_repository.dart';
class CreateAccountView extends StatelessWidget {
final String email;
CreateAccountView({super.key, required this.email});
final TextEditingController firstNameController = TextEditingController();
final TextEditingController lastNameController = TextEditingController();
final TextEditingController emailController = TextEditingController();
final TextEditingController phoneController = TextEditingController();
final TextEditingController addressController = TextEditingController();
void _submitForm(BuildContext context) {
if (firstNameController.text.trim().isEmpty ||
lastNameController.text.trim().isEmpty ||
emailController.text.trim().isEmpty ||
phoneController.text.trim().isEmpty ||
addressController.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Please fill all fields')));
return;
}
context.read<CreateAccountBloc>().add(
CreateAccountSubmitted(
firstName: firstNameController.text.trim(),
lastName: lastNameController.text.trim(),
emailAddress: emailController.text.trim(),
mobileNumber: phoneController.text.trim(),
address1: addressController.text.trim(),
address2: '',
),
);
}
@override
Widget build(BuildContext context) {
emailController.text = email;
return BlocProvider(
create: (context) =>
CreateAccountBloc(repository: CreateAccountRepository()),
child: BlocListener<CreateAccountBloc, CreateAccountState>(
listener: (ctx, state) async {
if (state is CreateAccountSuccess) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.message)));
await LocalPreference.setLogin(true);
final userId = await LocalPreference.getUserId();
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId!));
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
Navigator.pop(context);
} else if (state is CreateAccountFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage),
backgroundColor: Colors.red,
),
);
}
},
child: Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showCart: false,
showDivider: true,
),
),
/// 🔹 Scrollable content starts here
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: const Icon(Icons.arrow_back),
),
SizedBox(width: 8.w),
CustomText(
text: "Create your account",
size: 12.sp,
),
],
),
SizedBox(height: 26.h),
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,
enabled: false,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Phone Number",
hint: "Enter your phone number",
controller: phoneController,
),
),
SizedBox(height: 12.h),
CustomText(
text: "Location Details",
size: 18.sp,
weight: FontWeight.w500,
),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Address 1",
hint: "Enter address manually or tap to search",
controller: addressController,
),
),
SizedBox(height: 20.h),
BlocBuilder<CreateAccountBloc, CreateAccountState>(
builder: (context, state) {
if (state is CreateAccountLoading) {
return CustomFilledButton(
width: double.infinity,
onTap: () {},
label: "Creating...",
);
}
return CustomFilledButton(
width: double.infinity,
onTap: () => _submitForm(context),
label: "Create Account",
);
},
),
SizedBox(height: 20.h),
],
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -1,172 +0,0 @@
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/back_widget.dart';
import 'package:citycards_customer/common_packages/custom_textfield.dart';
import 'package:flutter/material.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class EditProfilePage extends StatelessWidget {
const EditProfilePage({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 addressController = TextEditingController();
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Header
CommonAppBar(isWhiteLogo: false, isProfilePage: true,showDivider: true,),
// Back + title
backWidget(context,"Edit Profile", Colors.black),
SizedBox(height: 33.h),
// Profile Image
CircleAvatar(
radius: 38.r,
backgroundImage: AssetImage("assets/images/profile_img.png"),
),
SizedBox(height: 18.h),
Text(
"Change Profile Picture",
style: TextStyle(
fontSize: 12.sp,
color: Color(0xFFF95F62),
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 40.h),
// Personal Information
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: 26.h),
// Buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFF95F62),
side: const BorderSide(color: Colors.transparent),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(38.r),
),
padding: EdgeInsets.symmetric(vertical: 12.h),
),
onPressed: () {},
child: Text(
"Cancel",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
),
),
SizedBox(width: 16.w),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFF95F62),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(38.r),
),
padding: EdgeInsets.symmetric(vertical: 6.h),
),
onPressed: () {},
child: Text(
"Save",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
),
],
),
SizedBox(height: 20.h),
],
),
),
),
);
}
}

View File

@@ -1,155 +0,0 @@
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/back_widget.dart';
import 'package:citycards_customer/common_packages/custom_expansion_tile.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class FaqPage extends StatelessWidget {
const FaqPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(isWhiteLogo: false, isProfilePage: true, showDivider: true,),
backWidget(context,"FAQ", Colors.black),
SizedBox(height: 34.h),
FAQSection(title: "🧭 General FAQs", faqs: generalFAQs),
SizedBox(height: 20.h),
FAQSection(title: "✈️ Booking & Planning", faqs: bookingFaq),
SizedBox(height: 20.h),
FAQSection(title: "🌍 Discover & Explore", faqs: discoverFAQs),
],
),
),
),
),
);
}
}
// Model for FAQ
class FAQItem {
final String question;
final String answer;
FAQItem({required this.question, required this.answer});
}
// Sample FAQ data
final List<FAQItem> generalFAQs = [
FAQItem(
question: "What is CityCards?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
FAQItem(
question: "Is the app free to use?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
FAQItem(
question: "Do I need an account to use the app?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
];
final List<FAQItem> discoverFAQs = [
FAQItem(
question: "How does the app recommend destinations?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
FAQItem(
question: "Can I create a custom itinerary?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
FAQItem(
question: "Does the app work offline?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
];
final List<FAQItem> bookingFaq = [
FAQItem(
question: "Can I modify or cancel my bookings?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
FAQItem(
question: "Can I plan multi-city trips?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
FAQItem(
question: "Can I book hotels through the app?",
answer:
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
),
];
// Widget for FAQ section
Widget FAQSection({required String title, required List<FAQItem> faqs}) {
return Container(
padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 8.w),
decoration: BoxDecoration(
border: Border.all(color: Color(0xFFF95F62)),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section heading
CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: Color(0xFF212121),
),
SizedBox(height: 12.h),
// Dynamic list of questions
Column(
children: faqs.map((faq) {
int index = faqs.indexOf(faq);
return Column(
children: [
CustomExpansionTile(
minTileHeight: 42.h,
borderRadius: BorderRadius.circular(5.r),
backgroundColor: Color(0xFFFEE7E7),
collapsedBackgroundColor: Color(0xFFFEE7E7),
tilePadding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 0,
),
childrenPadding: EdgeInsets.only(left: 12.w,right: 12.w, bottom: 12.h),
title: Text(faq.question, style: TextStyle(fontSize: 14.sp)),
children: [
Text(
faq.answer,
style: TextStyle(color: Color(0xFF5C5C5C), fontSize: 14.sp),
),
],
),
if (index != faqs.length - 1) SizedBox(height: 8.h), // spacing
],
);
}).toList(),
),
],
),
);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/first_time_user_home_repository.dart';
import '../../model/city_list_model.dart';
import 'first_time_user_home_event.dart';
import 'first_time_user_home_state.dart';
class FirstTimeUserHomeBloc
extends Bloc<FirstTimeUserHomeEvent, FirstTimeUserHomeState> {
final FirstTimeUserHomeRepository repository;
FirstTimeUserHomeBloc(this.repository)
: super(FirstTimeUserHomeInitial()) {
on<FetchFirstTimeUserHomeEvent>(_onFetchFirstTimeUserHome);
}
Future<void> _onFetchFirstTimeUserHome(
FetchFirstTimeUserHomeEvent event,
Emitter<FirstTimeUserHomeState> emit,
) async {
emit(FirstTimeUserHomeLoading());
try {
final CityList homeData =
await repository.fetchFirstTimeUserHome();
emit(
FirstTimeUserHomeLoaded(
cities: homeData.cities ?? [],
upcomingCities: homeData.upcomingCities ?? [],
),
);
} catch (e) {
emit(FirstTimeUserHomeError(e.toString()));
}
}
}

View File

@@ -0,0 +1,3 @@
abstract class FirstTimeUserHomeEvent {}
class FetchFirstTimeUserHomeEvent extends FirstTimeUserHomeEvent {}

View File

@@ -0,0 +1,28 @@
import '../../model/city_list_model.dart';
/// Base State
abstract class FirstTimeUserHomeState {}
/// Initial State
class FirstTimeUserHomeInitial extends FirstTimeUserHomeState {}
/// Loading State
class FirstTimeUserHomeLoading extends FirstTimeUserHomeState {}
/// Success State
class FirstTimeUserHomeLoaded extends FirstTimeUserHomeState {
final List<Cities> cities;
final List<UpcomingCities> upcomingCities;
FirstTimeUserHomeLoaded({
required this.cities,
required this.upcomingCities,
});
}
/// Error State
class FirstTimeUserHomeError extends FirstTimeUserHomeState {
final String message;
FirstTimeUserHomeError(this.message);
}

View File

@@ -1,42 +0,0 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// --- Events ---
abstract class AppStartEvent {}
class CheckFirstTimeUser extends AppStartEvent {}
class MarkUserAsRegistered extends AppStartEvent {}
/// --- States ---
abstract class AppStartState {}
class AppStartLoading extends AppStartState {}
class AppStartFirstTime extends AppStartState {}
class AppStartRegistered extends AppStartState {}
/// --- Bloc ---
class AppStartBloc extends Bloc<AppStartEvent, AppStartState> {
AppStartBloc() : super(AppStartLoading()) {
on<CheckFirstTimeUser>(_onCheckFirstTimeUser);
on<MarkUserAsRegistered>(_onMarkUserAsRegistered);
}
Future<void> _onCheckFirstTimeUser(
CheckFirstTimeUser event, Emitter<AppStartState> emit) async {
emit(AppStartLoading());
final prefs = await SharedPreferences.getInstance();
final isFirstTime = prefs.getBool('isFirstTimeUser') ?? true;
if (isFirstTime) {
emit(AppStartFirstTime());
} else {
emit(AppStartRegistered());
}
}
Future<void> _onMarkUserAsRegistered(
MarkUserAsRegistered event, Emitter<AppStartState> emit) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isFirstTimeUser', false);
emit(AppStartRegistered());
}
}

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