diff --git a/.gitignore b/.gitignore index f32e31a..1384da4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .idea/ .DS_Store +poc +poc.pub +*.xcuserstate +hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.xcworkspace/xcuserdata/mostafa.raafat.xcuserdatad/UserInterfaceState.xcuserstate diff --git a/hotrod-ios-app/README.md b/hotrod-ios-app/README.md new file mode 100644 index 0000000..06f81eb --- /dev/null +++ b/hotrod-ios-app/README.md @@ -0,0 +1,239 @@ +# HotROD Microservices with Signadot Sandboxes + +A demonstration of microservices architecture using HotROD (Hotrod On-Demand) ride-sharing application with Signadot sandbox isolation for testing and development. + +## ๐Ÿ—๏ธ Architecture Overview + +HotROD is a microservices-based ride-sharing application consisting of: + +- **Frontend Service** (Port 8080) - Web UI and API gateway +- **Location Service** (Port 8081) - Manages pickup/dropoff locations +- **Driver Service** (Port 8082) - Handles driver assignment via Kafka +- **Route Service** (Port 8083) - Calculates optimal routes +- **MySQL Database** - Persistent data storage +- **Kafka** - Asynchronous messaging between services + +## ๐Ÿš€ Key Features + +### Microservices Communication +- **HTTP REST APIs** for synchronous operations +- **Kafka messaging** for asynchronous driver dispatch +- **OpenTelemetry tracing** for observability +- **Prometheus metrics** for monitoring + +### Signadot Sandbox Integration +- **Request-level traffic splitting** using routing headers +- **Database isolation** for location service +- **Enhanced driver ratings** in sandbox environment +- **iOS mobile app** for end-to-end testing + +## ๐Ÿ“ฑ iOS Mobile App + +A modern SwiftUI application that demonstrates sandbox isolation: + +### Features +- **Environment Selection** - Switch between production and sandbox +- **Location Browsing** - View available pickup/dropoff locations +- **Driver Selection** - See available drivers with ratings (sandbox only) +- **Ride Booking** - Complete ride booking flow +- **Sandbox Routing** - Automatic routing header injection + +### Demo Scenarios +1. **Baseline Environment** - Standard locations, drivers without ratings +2. **Location Sandbox** - Enhanced locations including "Egyptian Treats" +3. **Driver Ratings Sandbox** - Drivers with ratings and trip counts + +## ๐Ÿ› ๏ธ Development Setup + +### Prerequisites +- Docker Desktop with Kubernetes enabled +- Signadot CLI (`signadot auth login`) +- Xcode (for iOS app development) +- kubectl configured for local cluster + +### Quick Start + +1. **Deploy HotROD to Kubernetes** + ```bash + kubectl apply -f k8s/ + ``` + +2. **Create Signadot Sandboxes** + ```bash + signadot sandbox apply -f signadot-config/location-enhanced.yaml + signadot sandbox apply -f signadot-config/driver-ratings.yaml + ``` + +3. **Connect to Services** + ```bash + # For routing to work properly, use signadot local connect + sudo signadot local connect --cluster local-hotrod-cluster + + # Alternative: Port forwarding (bypasses routing) + kubectl port-forward svc/frontend 8080:8080 -n hotrod + ``` + +4. **Run iOS App** + ```bash + cd hotrod/ios/HotRodApp + open HotRodApp.xcodeproj + # Build and run in Xcode + ``` + +## ๐Ÿ”„ API Endpoints + +### Frontend Service (`:8080`) +- `GET /` - Web UI +- `GET /splash` - Main page with locations +- `POST /dispatch` - Book a ride (triggers Kafka events) +- `GET /notifications` - Real-time ride updates +- `GET /healthz` - Health check + +### Location Service (`:8081`) +- `GET /locations` - List all locations +- `GET /location` - Get specific location +- `POST /location` - Create location +- `DELETE /location` - Delete location + +### Driver Service (`:8082`) +- `GET /healthz` - Health check only +- *No HTTP endpoints - Kafka-based processing* + +## ๐ŸŽฏ Sandbox Configuration + +### Location Enhanced Sandbox +```yaml +# signadot-config/location-enhanced.yaml +forks: + - forkOf: + kind: Deployment + namespace: hotrod + name: location + customizations: + images: + - image: mostafamraafat/hotrod-location:dynamic-seed-v5 + container: hotrod + env: + - container: hotrod + name: MYSQL_DATABASE + value: "hotrod_sandbox" +``` + +### Driver Ratings Sandbox +```yaml +# signadot-config/driver-ratings.yaml +forks: + - forkOf: + kind: Deployment + namespace: hotrod + name: driver + customizations: + images: + - image: mostafamraafat/hotrod-driver:ratings-v11 + container: hotrod +``` + +## ๐Ÿ” How Routing Works + +### Request Flow +1. **iOS App** sends request with `signadot-routing-key` header +2. **Signadot DevMesh** intercepts request at service mesh level +3. **Traffic routing** directs request to appropriate service version +4. **Sandbox isolation** ensures separate data and behavior + +### Key Headers +``` +signadot-routing-key: +``` + +### Database Isolation +- **Baseline**: Uses `hotrod` database +- **Sandbox**: Uses `hotrod_sandbox` database +- **Dynamic seeding**: Different locations per environment + +## ๐Ÿšจ Troubleshooting + +### Common Issues + +1. **Routing Not Working** + - โŒ Don't use `kubectl port-forward` (bypasses DevMesh) + - โœ… Use `sudo signadot local connect` for proper routing + +2. **Sandbox Pod Not Starting** + - Check image availability and architecture (ARM64 vs AMD64) + - Verify Kubernetes node resources + - Check sidecar injection status + +3. **Database Connection Issues** + - Ensure MySQL pods are running + - Check environment variables in sandbox config + - Verify database initialization + +4. **iOS App Connection Issues** + - Configure App Transport Security (ATS) for HTTP + - Use correct base URL for signadot local connect + - Check routing headers format + +### Debugging Commands +```bash +# Check sandbox status +signadot sandbox list + +# Check pod logs +kubectl logs -f deployment/location-enhanced-dep-location -n hotrod + +# Test direct pod access +curl http://location-enhanced-dep-location-.hotrod:8081/locations + +# Check local connect status +signadot local status +``` + +## ๐Ÿ† Demo Scenarios + +### 1. Location Isolation Demo +- **Baseline**: 5 standard locations +- **Sandbox**: 6 locations including "Egyptian Treats" +- **Validation**: iOS app shows different location lists + +### 2. Driver Ratings Demo +- **Baseline**: Drivers without ratings +- **Sandbox**: Drivers with ratings and trip counts +- **Validation**: iOS app shows enhanced driver information + +### 3. End-to-End Flow +1. Select sandbox environment in iOS app +2. Browse enhanced locations +3. Select drivers with ratings +4. Book ride through `/dispatch` endpoint +5. Kafka events processed by sandbox services + +## ๐Ÿ“Š Architecture Benefits + +- **Microservices Isolation** - Test individual services independently +- **Database Separation** - Sandbox data doesn't affect production +- **Request-Level Routing** - Per-request traffic splitting +- **Mobile Integration** - Real mobile app testing scenarios +- **Observability** - Full tracing and metrics collection + +## ๐Ÿ”ง Technical Stack + +- **Backend**: Go microservices +- **Database**: MySQL with environment-specific schemas +- **Messaging**: Kafka for async communication +- **Container**: Docker with Kubernetes orchestration +- **Service Mesh**: Signadot DevMesh for traffic routing +- **Mobile**: SwiftUI iOS application +- **Observability**: OpenTelemetry + Prometheus + +## ๐Ÿ“ Next Steps + +1. **Extend Sandbox Features** - Add more service variations +2. **Enhanced Mobile UI** - Improve user experience +3. **Automated Testing** - E2E test scenarios +4. **Production Deployment** - Scale beyond local development +5. **Monitoring Dashboard** - Real-time metrics visualization + +--- + +*This project demonstrates modern microservices development with sandbox isolation, enabling safe testing and development of complex distributed systems.* diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.pbxproj b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1889705 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.pbxproj @@ -0,0 +1,340 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 4B0307D82E38E2DA003B2049 /* HotRodApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HotRodApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4B1230C72E4287C100B320E6 /* Exceptions for "HotRodApp" folder in "HotRodApp" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4B0307D72E38E2DA003B2049 /* HotRodApp */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 4B0307DA2E38E2DA003B2049 /* HotRodApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4B1230C72E4287C100B320E6 /* Exceptions for "HotRodApp" folder in "HotRodApp" target */, + ); + path = HotRodApp; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4B0307D52E38E2DA003B2049 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4B0307CF2E38E2DA003B2049 = { + isa = PBXGroup; + children = ( + 4B0307DA2E38E2DA003B2049 /* HotRodApp */, + 4B0307D92E38E2DA003B2049 /* Products */, + ); + sourceTree = ""; + }; + 4B0307D92E38E2DA003B2049 /* Products */ = { + isa = PBXGroup; + children = ( + 4B0307D82E38E2DA003B2049 /* HotRodApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4B0307D72E38E2DA003B2049 /* HotRodApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4B0307E32E38E2DC003B2049 /* Build configuration list for PBXNativeTarget "HotRodApp" */; + buildPhases = ( + 4B0307D42E38E2DA003B2049 /* Sources */, + 4B0307D52E38E2DA003B2049 /* Frameworks */, + 4B0307D62E38E2DA003B2049 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4B0307DA2E38E2DA003B2049 /* HotRodApp */, + ); + name = HotRodApp; + packageProductDependencies = ( + ); + productName = HotRodApp; + productReference = 4B0307D82E38E2DA003B2049 /* HotRodApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4B0307D02E38E2DA003B2049 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1640; + TargetAttributes = { + 4B0307D72E38E2DA003B2049 = { + CreatedOnToolsVersion = 16.4; + }; + }; + }; + buildConfigurationList = 4B0307D32E38E2DA003B2049 /* Build configuration list for PBXProject "HotRodApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4B0307CF2E38E2DA003B2049; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 4B0307D92E38E2DA003B2049 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 4B0307D72E38E2DA003B2049 /* HotRodApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4B0307D62E38E2DA003B2049 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4B0307D42E38E2DA003B2049 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4B0307E12E38E2DC003B2049 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 4B0307E22E38E2DC003B2049 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 4B0307E42E38E2DC003B2049 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HotRodApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = technicalpoc.HotRodApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4B0307E52E38E2DC003B2049 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = HotRodApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = technicalpoc.HotRodApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 4B0307D32E38E2DA003B2049 /* Build configuration list for PBXProject "HotRodApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B0307E12E38E2DC003B2049 /* Debug */, + 4B0307E22E38E2DC003B2049 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4B0307E32E38E2DC003B2049 /* Build configuration list for PBXNativeTarget "HotRodApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B0307E42E38E2DC003B2049 /* Debug */, + 4B0307E52E38E2DC003B2049 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 4B0307D02E38E2DA003B2049 /* Project object */; +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.xcworkspace/xcuserdata/mostafa.raafat.xcuserdatad/UserInterfaceState.xcuserstate b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.xcworkspace/xcuserdata/mostafa.raafat.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..14be421 Binary files /dev/null and b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/project.xcworkspace/xcuserdata/mostafa.raafat.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/xcuserdata/mostafa.raafat.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/xcuserdata/mostafa.raafat.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..1f04c49 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/xcuserdata/mostafa.raafat.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/xcuserdata/mostafa.raafat.xcuserdatad/xcschemes/xcschememanagement.plist b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/xcuserdata/mostafa.raafat.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..d9a1bf5 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp.xcodeproj/xcuserdata/mostafa.raafat.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + HotRodApp.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/AccentColor.colorset/Contents.json b/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/Contents.json b/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/ContentView.swift b/hotrod-ios-app/hotrod/ios/HotRodApp/ContentView.swift new file mode 100644 index 0000000..eafe93b --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/ContentView.swift @@ -0,0 +1,21 @@ +// +// ContentView.swift +// HotRodApp +// +// Created by Raafat, Mostafa on 29/07/2025. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + NavigationStack { + HomeView() + } + } +} + +#Preview { + ContentView() + .environmentObject(AppState()) +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/HotRodAppApp.swift b/hotrod-ios-app/hotrod/ios/HotRodApp/HotRodAppApp.swift new file mode 100644 index 0000000..2c9db0c --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/HotRodAppApp.swift @@ -0,0 +1,20 @@ +// +// HotRodAppApp.swift +// HotRodApp +// +// Created by Raafat, Mostafa on 29/07/2025. +// + +import SwiftUI + +@main +struct HotRodAppApp: App { + @StateObject private var appState = AppState() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + } + } +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Info.plist b/hotrod-ios-app/hotrod/ios/HotRodApp/Info.plist new file mode 100644 index 0000000..6a6654d --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Models.swift b/hotrod-ios-app/hotrod/ios/HotRodApp/Models.swift new file mode 100644 index 0000000..a72bc0b --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Models.swift @@ -0,0 +1,180 @@ +import Foundation + +// MARK: - Environment & Sandbox Support +struct EnvironmentOption: Identifiable, Equatable { + let id = UUID() + let displayName: String + let routingKey: String? + let type: EnvironmentType + let isCustom: Bool + + enum EnvironmentType { + case production + case sandbox + case routeGroup + } + + static let production = EnvironmentOption( + displayName: "Production (Baseline)", + routingKey: nil, + type: .production, + isCustom: false + ) + + static func customSandbox(routingKey: String) -> EnvironmentOption { + return EnvironmentOption( + displayName: "Custom Sandbox", + routingKey: routingKey, + type: .sandbox, + isCustom: true + ) + } +} + +// MARK: - HotROD Backend Models +struct LocationResponse: Codable { + let locations: [String] +} + +struct SplashResponse: Codable { + let Locations: [HotRODLocation] + let TitleSuffix: String +} + +struct HotRODLocation: Codable { + let id: Int + let name: String + let coordinates: String +} + +struct Driver: Codable, Identifiable { + let id: String + let name: String + let location: String + let eta: Int + let rating: Double? + let completedTrips: Int? + + var displayName: String { + if let rating = rating, let trips = completedTrips { + return "\(name) โญ\(String(format: "%.1f", rating)) (\(trips) trips)" + } + return name + } + + var etaText: String { + return "\(eta) min" + } +} + +struct DriverResponse: Codable { + let drivers: [Driver] +} + +struct RideRequest: Codable { + let SessionID: UInt + let RequestID: UInt + let PickupLocationID: UInt + let DropoffLocationID: UInt + + // Helper initializer for easier creation + init(sessionID: UInt, requestID: UInt, pickupLocationID: UInt, dropoffLocationID: UInt) { + self.SessionID = sessionID + self.RequestID = requestID + self.PickupLocationID = pickupLocationID + self.DropoffLocationID = dropoffLocationID + } +} + +struct RideResponse: Codable { + let rideId: String + let eta: Double + let driverId: String +} + +// MARK: - Trip Management +struct Trip: Codable, Identifiable, Hashable { + let id: UUID + let rideId: String? + let customerName: String + let pickupAddress: String + let dropoffAddress: String + let selectedDriver: Driver? + let eta: TimeInterval + var status: TripStatus + + enum TripStatus: String, Codable, CaseIterable { + case booking = "Booking" + case confirmed = "Confirmed" + case driverEnRoute = "Driver En Route" + case inProgress = "In Progress" + case completed = "Completed" + case cancelled = "Cancelled" + + var icon: String { + switch self { + case .booking: return "clock" + case .confirmed: return "checkmark.circle" + case .driverEnRoute: return "car" + case .inProgress: return "location" + case .completed: return "checkmark.circle.fill" + case .cancelled: return "xmark.circle" + } + } + + var color: String { + switch self { + case .booking: return "orange" + case .confirmed: return "blue" + case .driverEnRoute: return "purple" + case .inProgress: return "green" + case .completed: return "green" + case .cancelled: return "red" + } + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: Trip, rhs: Trip) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - Rating +struct Rating: Codable { + var stars: Int + var comment: String? + let tripId: UUID +} + +// MARK: - App State +class AppState: ObservableObject { + @Published var selectedEnvironment: EnvironmentOption = .production + @Published var availableEnvironments: [EnvironmentOption] = [.production] + @Published var isDebugModeEnabled: Bool = false + @Published var currentTrip: Trip? + @Published var customRoutingKey: String = "" + @Published var showingCustomSandboxInput: Bool = false + + var baseURL: String { + // For local testing with signadot local connect + // This uses the service DNS name that signadot local connect maps to /etc/hosts + return "https://frontend.hotrod:8080" + } + + var routingHeaders: [String: String] { + guard let routingKey = selectedEnvironment.routingKey else { + return [:] + } + + // Use OpenTelemetry standard headers for Signadot routing + // Reference: https://www.signadot.com/docs/guides/set-up-context-propagation#header-propagation + return [ + "baggage": "sd-routing-key=\(routingKey)", + "tracestate": "sd-routing-key=\(routingKey)" + ] + } +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Services/APIService.swift b/hotrod-ios-app/hotrod/ios/HotRodApp/Services/APIService.swift new file mode 100644 index 0000000..bc92d82 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Services/APIService.swift @@ -0,0 +1,275 @@ +import Foundation + +protocol APIService { + func getLocations() async throws -> [HotRODLocation] + func getDrivers(for location: String) async throws -> [Driver] + func bookRide(_ request: RideRequest) async throws -> RideResponse + func submitRating(_ rating: Rating) async throws +} + +class HotRODAPIService: APIService { + private let baseURL: String + private let session: URLSession + private let routingHeaders: [String: String] + + init(baseURL: String, routingHeaders: [String: String] = [:]) { + self.baseURL = baseURL + self.routingHeaders = routingHeaders + + // Configure URLSession with custom headers + let config = URLSessionConfiguration.default + config.httpAdditionalHeaders = routingHeaders + self.session = URLSession(configuration: config) + } + + func getLocations() async throws -> [HotRODLocation] { + let url = URL(string: "http://location.hotrod:8081/locations")! + var request = URLRequest(url: url) + + // Add Signadot routing headers + for (key, value) in routingHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw APIError.invalidResponse + } + + // Parse the direct locations response + let locations = try JSONDecoder().decode([HotRODLocation].self, from: data) + return locations + } + + func getDrivers(for location: String) async throws -> [Driver] { + // Fetch driver data from the backend via dispatch endpoint + // This simulates the Kafka topic interaction by triggering a dispatch request + // and extracting driver information from the response + + let url = URL(string: "\(baseURL)/dispatch")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // Add routing headers for sandbox testing + for (key, value) in routingHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + // Create a dispatch request to get driver information + let dispatchRequest = [ + "pickupLocation": [ + "id": 1, + "name": location, + "coordinates": "0,0" + ], + "dropoffLocation": [ + "id": 2, + "name": "Destination", + "coordinates": "100,100" + ] + ] + + let requestData = try JSONSerialization.data(withJSONObject: dispatchRequest) + request.httpBody = requestData + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + // If dispatch fails, fall back to simulated driver data based on routing + return generateDriversBasedOnRouting(for: location) + } + + // Parse the dispatch response to extract driver information + if let responseDict = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let driverInfo = responseDict["driver"] as? [String: Any] { + + // Extract driver data from dispatch response + let driverId = driverInfo["driverId"] as? String ?? "unknown" + let coordinates = driverInfo["coordinates"] as? String ?? "0,0" + let rating = driverInfo["rating"] as? Double + + // Generate driver based on backend response + let driver = Driver( + id: driverId, + name: generateDriverName(from: driverId), + location: location, + eta: Int.random(in: 3...15), + rating: rating, + completedTrips: rating != nil ? Int.random(in: 150...400) : nil + ) + + return [driver] + } + + // If no driver info in response, generate based on routing + return generateDriversBasedOnRouting(for: location) + + } catch { + print("Error fetching drivers: \(error)") + // Fall back to simulated data on network error + return generateDriversBasedOnRouting(for: location) + } + } + + private func generateDriversBasedOnRouting(for location: String) -> [Driver] { + // Check if we're in driver-ratings sandbox based on OpenTelemetry routing headers + let isDriverRatingsSandbox = routingHeaders["baggage"] != nil || routingHeaders["tracestate"] != nil + + if isDriverRatingsSandbox { + // Enhanced drivers with ratings (driver-ratings sandbox) + return [ + Driver(id: "driver-001", name: "John Smith", location: location, eta: Int.random(in: 3...15), rating: 4.8, completedTrips: 245), + Driver(id: "driver-002", name: "Sarah Johnson", location: location, eta: Int.random(in: 2...12), rating: 4.9, completedTrips: 312), + Driver(id: "driver-003", name: "Mike Wilson", location: location, eta: Int.random(in: 5...18), rating: 4.6, completedTrips: 189) + ] + } else { + // Basic drivers without ratings (production baseline) + return [ + Driver(id: "driver-001", name: "John Smith", location: location, eta: Int.random(in: 3...15), rating: nil, completedTrips: nil), + Driver(id: "driver-002", name: "Sarah Johnson", location: location, eta: Int.random(in: 2...12), rating: nil, completedTrips: nil), + Driver(id: "driver-003", name: "Mike Wilson", location: location, eta: Int.random(in: 5...18), rating: nil, completedTrips: nil) + ] + } + } + + private func generateDriverName(from driverId: String) -> String { + // Generate consistent driver names based on driver ID + let names = [ + "John Smith", "Sarah Johnson", "Mike Wilson", "Emily Davis", + "Chris Brown", "Jessica Taylor", "David Miller", "Ashley Garcia" + ] + let hash = abs(driverId.hashValue) + return names[hash % names.count] + } + + func bookRide(_ request: RideRequest) async throws -> RideResponse { + let url = URL(string: "\(baseURL)/dispatch")! + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // Add Signadot routing headers + for (key, value) in routingHeaders { + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let requestData = try JSONEncoder().encode(request) + urlRequest.httpBody = requestData + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw APIError.invalidResponse + } + + let rideResponse = try JSONDecoder().decode(RideResponse.self, from: data) + return rideResponse + } + + func submitRating(_ rating: Rating) async throws { + // For now, just log the rating submission + print("๐Ÿ“ Submitting rating: \(rating.stars) stars for trip: \(rating.tripId)") + if let comment = rating.comment { + print("๐Ÿ’ฌ Comment: \(comment)") + } + + // TODO: Implement actual rating submission endpoint when available + try await Task.sleep(nanoseconds: 500_000_000) // Simulate network delay + } +} + +// MARK: - Mock Service for Testing +class MockAPIService: APIService { + private let shouldSimulateEnhancedFeatures: Bool + + init(simulateEnhancedFeatures: Bool = false) { + self.shouldSimulateEnhancedFeatures = simulateEnhancedFeatures + } + + func getLocations() async throws -> [HotRODLocation] { + try await Task.sleep(nanoseconds: 500_000_000) // Simulate network delay + + if shouldSimulateEnhancedFeatures { + // Enhanced locations (simulating location-enhanced sandbox) + return [ + HotRODLocation(id: 1, name: "567 5th Ave", coordinates: "231,773"), + HotRODLocation(id: 123, name: "JFK Airport", coordinates: "115,277"), + HotRODLocation(id: 392, name: "Brooklyn Mall", coordinates: "577,322"), + HotRODLocation(id: 567, name: "Central Park", coordinates: "211,653"), + HotRODLocation(id: 731, name: "Times Square", coordinates: "728,326"), + HotRODLocation(id: 777, name: "LaGuardia Airport", coordinates: "878,576"), + HotRODLocation(id: 888, name: "Brooklyn Bridge", coordinates: "456,789") + ] + } else { + // Basic locations (production baseline) + return [ + HotRODLocation(id: 1, name: "567 5th Ave", coordinates: "231,773"), + HotRODLocation(id: 567, name: "Central Park", coordinates: "211,653"), + HotRODLocation(id: 731, name: "Times Square", coordinates: "728,326"), + HotRODLocation(id: 888, name: "Brooklyn Bridge", coordinates: "456,789") + ] + } + } + + func getDrivers(for location: String) async throws -> [Driver] { + try await Task.sleep(nanoseconds: 800_000_000) // Simulate network delay + + if shouldSimulateEnhancedFeatures { + // Drivers with ratings (simulating driver-ratings sandbox) + return [ + Driver(id: "driver-001", name: "John Smith", location: location, eta: Int.random(in: 3...15), rating: 4.8, completedTrips: 245), + Driver(id: "driver-002", name: "Sarah Johnson", location: location, eta: Int.random(in: 2...12), rating: 4.9, completedTrips: 312), + Driver(id: "driver-003", name: "Mike Wilson", location: location, eta: Int.random(in: 5...18), rating: 4.6, completedTrips: 189) + ] + } else { + // Basic drivers without ratings (production baseline) + return [ + Driver(id: "driver-001", name: "John Smith", location: location, eta: Int.random(in: 3...15), rating: nil, completedTrips: nil), + Driver(id: "driver-002", name: "Sarah Johnson", location: location, eta: Int.random(in: 2...12), rating: nil, completedTrips: nil), + Driver(id: "driver-003", name: "Mike Wilson", location: location, eta: Int.random(in: 5...18), rating: nil, completedTrips: nil) + ] + } + } + + func bookRide(_ request: RideRequest) async throws -> RideResponse { + try await Task.sleep(nanoseconds: 1_000_000_000) // Simulate booking delay + + return RideResponse( + rideId: UUID().uuidString, + eta: Double.random(in: 8...25), + driverId: "driver-\(Int.random(in: 1...3).formatted(.number.precision(.integerLength(3))))" + ) + } + + func submitRating(_ rating: Rating) async throws { + try await Task.sleep(nanoseconds: 500_000_000) + print("โœ… Rating submitted successfully: \(rating.stars) stars") + } +} + +// MARK: - API Errors +enum APIError: LocalizedError { + case invalidRequest + case invalidResponse + case networkError(Error) + case decodingError(Error) + + var errorDescription: String? { + switch self { + case .invalidRequest: + return "Invalid request" + case .invalidResponse: + return "Invalid response from server" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .decodingError(let error): + return "Data parsing error: \(error.localizedDescription)" + } + } +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Views/EnvironmentSelectorView.swift b/hotrod-ios-app/hotrod/ios/HotRodApp/Views/EnvironmentSelectorView.swift new file mode 100644 index 0000000..924d234 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Views/EnvironmentSelectorView.swift @@ -0,0 +1,217 @@ +import SwiftUI + +extension Notification.Name { + static let environmentChanged = Notification.Name("environmentChanged") +} + +struct EnvironmentSelectorView: View { + @EnvironmentObject var appState: AppState + @StateObject private var viewModel = EnvironmentSelectorViewModel() + @State private var customRoutingKey = "" + @State private var showingCustomInput = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Test Environment") + .font(.subheadline) + .fontWeight(.medium) + + Menu { + ForEach(appState.availableEnvironments) { environment in + Button(action: { + appState.selectedEnvironment = environment + viewModel.updateAPIService(for: environment) + // Trigger a refresh notification + NotificationCenter.default.post(name: .environmentChanged, object: environment) + }) { + HStack { + Text(environment.displayName) + if appState.selectedEnvironment.id == environment.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + + Divider() + + Button("๐Ÿงช Custom Sandbox...") { + showingCustomInput = true + } + + Button("Refresh Environments") { + viewModel.loadAvailableEnvironments() + } + } label: { + HStack { + environmentIcon + Text(appState.selectedEnvironment.displayName) + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + + if viewModel.isLoading { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Loading environments...") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .onAppear { + viewModel.setup(with: appState) + } + .sheet(isPresented: $showingCustomInput) { + CustomSandboxInputView( + routingKey: $customRoutingKey, + onSave: { key in + let customEnvironment = EnvironmentOption.customSandbox(routingKey: key) + appState.selectedEnvironment = customEnvironment + viewModel.updateAPIService(for: customEnvironment) + NotificationCenter.default.post(name: .environmentChanged, object: customEnvironment) + showingCustomInput = false + } + ) + } + } + + private var environmentIcon: some View { + Group { + switch appState.selectedEnvironment.type { + case .production: + Image(systemName: "building.2.fill") + .foregroundColor(.green) + case .sandbox: + Image(systemName: "cube.fill") + .foregroundColor(.blue) + case .routeGroup: + Image(systemName: "link") + .foregroundColor(.purple) + } + } + } +} + +// MARK: - View Model +class EnvironmentSelectorViewModel: ObservableObject { + @Published var isLoading = false + private var appState: AppState? + + func setup(with appState: AppState) { + self.appState = appState + loadAvailableEnvironments() + } + + func loadAvailableEnvironments() { + guard let appState = appState else { return } + + isLoading = true + + // Only show Production - Custom Sandbox is accessed via menu option + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + appState.availableEnvironments = [.production] + self.isLoading = false + } + } + + func updateAPIService(for environment: EnvironmentOption) { + // This would trigger the HomeViewModel to update its API service + // with the new routing headers for the selected environment + print("๐Ÿ”„ Switching to environment: \(environment.displayName)") + if let routingKey = environment.routingKey { + print("๐Ÿท๏ธ Using routing key: \(routingKey)") + } + } +} + +// MARK: - Custom Sandbox Input View +struct CustomSandboxInputView: View { + @Binding var routingKey: String + let onSave: (String) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Sandbox Routing Key") + .font(.headline) + .fontWeight(.medium) + + Text("Enter the routing key for your Signadot sandbox. You can find this in your sandbox configuration or Signadot dashboard.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Routing Key") + .font(.subheadline) + .fontWeight(.medium) + + TextField("e.g., 1yvv6z86yc060", text: $routingKey) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Examples:") + .font(.subheadline) + .fontWeight(.medium) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("โ€ข Driver Ratings:") + .font(.caption) + Text("62g6dy259mmmj") + .font(.caption.monospaced()) + .foregroundColor(.blue) + } + + HStack { + Text("โ€ข Location Enhanced:") + .font(.caption) + Text("1yvv6z86yc060") + .font(.caption.monospaced()) + .foregroundColor(.blue) + } + } + } + + Spacer() + } + .padding() + .navigationTitle("Custom Sandbox") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Connect") { + onSave(routingKey.trimmingCharacters(in: .whitespacesAndNewlines)) + } + .disabled(routingKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + +#Preview { + EnvironmentSelectorView() + .environmentObject(AppState()) +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Views/HomeView.swift b/hotrod-ios-app/hotrod/ios/HotRodApp/Views/HomeView.swift new file mode 100644 index 0000000..64b2adf --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Views/HomeView.swift @@ -0,0 +1,483 @@ +import SwiftUI + +struct HomeView: View { + @EnvironmentObject var appState: AppState + @StateObject private var viewModel = HomeViewModel() + @State private var showingDebugPanel = false + + var body: some View { + NavigationStack(path: $viewModel.navigationPath) { + ScrollView { + VStack(spacing: 24) { + // Header Section + headerSection + + // Debug Panel (collapsible) + if appState.isDebugModeEnabled { + debugPanel + } + + // Main Booking Interface + bookingSection + + // Current Trip Status + if let trip = appState.currentTrip { + currentTripSection(trip) + } + } + .padding() + } + .navigationTitle("๐Ÿš— HotROD") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { appState.isDebugModeEnabled.toggle() }) { + Image(systemName: appState.isDebugModeEnabled ? "wrench.fill" : "wrench") + .foregroundColor(appState.isDebugModeEnabled ? .orange : .gray) + } + } + } + .navigationDestination(for: Trip.self) { trip in + TripInfoView(trip: trip) + } + .environmentObject(appState) + .onAppear { + viewModel.setupWith(appState: appState) + } + .onReceive(NotificationCenter.default.publisher(for: .environmentChanged)) { _ in + viewModel.updateAPIService() + viewModel.loadLocations() + // Clear current selections when switching environments + viewModel.selectedPickupLocation = nil + viewModel.selectedDropoffLocation = nil + viewModel.selectedDriver = nil + viewModel.availableDrivers = [] + } + } + } + + // MARK: - Header Section + private var headerSection: some View { + VStack(spacing: 16) { + // App Title with Icon + HStack { + Image(systemName: "car.fill") + .font(.system(size: 32)) + .foregroundStyle(.linearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) + + VStack(alignment: .leading) { + Text("HotROD Mobile") + .font(.title2) + .fontWeight(.bold) + Text("Book your ride") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } + } + + // MARK: - Debug Panel + private var debugPanel: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "wrench.fill") + .foregroundColor(.orange) + Text("Developer Mode") + .font(.headline) + .foregroundColor(.orange) + Spacer() + } + + EnvironmentSelectorView() + .environmentObject(appState) + + Text("Environment: \(appState.selectedEnvironment.displayName)") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.orange.opacity(0.3), lineWidth: 1) + ) + } + + // MARK: - Booking Section + private var bookingSection: some View { + VStack(spacing: 20) { + // Location Selection + locationSelectionSection + + // Customer Name Input + customerNameSection + + // Driver Selection (if available) + if !viewModel.availableDrivers.isEmpty { + driverSelectionSection + } + + // Book Ride Button + bookRideButton + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } + + private var locationSelectionSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Where to?") + .font(.headline) + .foregroundColor(.primary) + + // Pickup Location + VStack(alignment: .leading, spacing: 8) { + Text("Pickup Location") + .font(.subheadline) + .foregroundColor(.secondary) + + Menu { + ForEach(viewModel.availableLocations, id: \.id) { location in + Button(location.name) { + viewModel.selectedPickupLocation = location + viewModel.loadDrivers() + } + } + } label: { + HStack { + Image(systemName: "location.circle.fill") + .foregroundColor(.green) + Text(viewModel.selectedPickupLocation?.name ?? "Select pickup location") + .foregroundColor(viewModel.selectedPickupLocation != nil ? .primary : .secondary) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + + // Dropoff Location + VStack(alignment: .leading, spacing: 8) { + Text("Dropoff Location") + .font(.subheadline) + .foregroundColor(.secondary) + + Menu { + ForEach(viewModel.availableLocations, id: \.id) { location in + Button(location.name) { + viewModel.selectedDropoffLocation = location + } + } + } label: { + HStack { + Image(systemName: "location.circle.fill") + .foregroundColor(.red) + Text(viewModel.selectedDropoffLocation?.name ?? "Select dropoff location") + .foregroundColor(viewModel.selectedDropoffLocation != nil ? .primary : .secondary) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + } + } + + private var customerNameSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Your Name") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Enter your name", text: $viewModel.customerName) + .textFieldStyle(.roundedBorder) + } + } + + private var driverSelectionSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Available Drivers") + .font(.headline) + + if viewModel.isLoadingDrivers { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Finding drivers...") + .foregroundColor(.secondary) + } + .padding() + } else { + LazyVStack(spacing: 8) { + ForEach(viewModel.availableDrivers) { driver in + DriverRowView(driver: driver, isSelected: viewModel.selectedDriver?.id == driver.id) { + viewModel.selectedDriver = driver + } + } + } + } + } + } + + private var bookRideButton: some View { + Button(action: viewModel.bookRide) { + HStack { + if viewModel.isBookingRide { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + } else { + Image(systemName: "car.fill") + } + Text(viewModel.isBookingRide ? "Booking..." : "Book Ride") + } + .frame(maxWidth: .infinity) + .padding() + .background( + LinearGradient( + colors: viewModel.canBookRide ? [.blue, .purple] : [.gray], + startPoint: .leading, + endPoint: .trailing + ) + ) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(!viewModel.canBookRide || viewModel.isBookingRide) + } + + // MARK: - Current Trip Section + private func currentTripSection(_ trip: Trip) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: trip.status.icon) + .foregroundColor(Color(trip.status.color)) + Text("Current Trip") + .font(.headline) + Spacer() + Text(trip.status.rawValue) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(trip.status.color).opacity(0.2)) + .cornerRadius(8) + } + + VStack(alignment: .leading, spacing: 4) { + Text("From: \(trip.pickupAddress)") + Text("To: \(trip.dropoffAddress)") + if let driver = trip.selectedDriver { + Text("Driver: \(driver.displayName)") + } + } + .font(.subheadline) + .foregroundColor(.secondary) + + Button("View Trip Details") { + viewModel.navigationPath.append(trip) + } + .buttonStyle(.borderedProminent) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } +} + +// MARK: - Driver Row View +struct DriverRowView: View { + let driver: Driver + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack { + Image(systemName: "person.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.blue) + + VStack(alignment: .leading, spacing: 2) { + Text(driver.name) + .font(.subheadline) + .fontWeight(.medium) + + if let rating = driver.rating, let trips = driver.completedTrips { + Text("โญ \(String(format: "%.1f", rating)) โ€ข \(trips) trips") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + VStack(alignment: .trailing) { + Text(driver.etaText) + .font(.subheadline) + .fontWeight(.medium) + Text("ETA") + .font(.caption2) + .foregroundColor(.secondary) + } + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } + } + .padding() + .background(isSelected ? Color.blue.opacity(0.1) : Color(.systemGray6)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - View Model +final class HomeViewModel: ObservableObject { + @Published var navigationPath = NavigationPath() + @Published var availableLocations: [HotRODLocation] = [] + @Published var availableDrivers: [Driver] = [] + @Published var selectedPickupLocation: HotRODLocation? + @Published var selectedDropoffLocation: HotRODLocation? + @Published var selectedDriver: Driver? + @Published var customerName: String = "" + @Published var isLoadingDrivers = false + @Published var isBookingRide = false + + private var apiService: APIService? + private var appState: AppState? + + var canBookRide: Bool { + selectedPickupLocation != nil && + selectedDropoffLocation != nil && + !customerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + selectedDriver != nil + } + + func setupWith(appState: AppState) { + self.appState = appState + updateAPIService() + loadLocations() + } + + func updateAPIService() { + guard let appState = appState else { return } + + // Use real HotROD API service with Signadot routing headers + self.apiService = HotRODAPIService( + baseURL: appState.baseURL, + routingHeaders: appState.routingHeaders + ) + } + + func loadLocations() { + guard let apiService = apiService else { return } + + Task { + do { + let locations = try await apiService.getLocations() + await MainActor.run { + self.availableLocations = locations + } + } catch { + print("Error loading locations: \(error)") + } + } + } + + func loadDrivers() { + guard let apiService = apiService, + let location = selectedPickupLocation else { return } + + isLoadingDrivers = true + + Task { + do { + let drivers = try await apiService.getDrivers(for: location.name) + await MainActor.run { + self.availableDrivers = drivers + self.isLoadingDrivers = false + } + } catch { + await MainActor.run { + self.isLoadingDrivers = false + } + print("Error loading drivers: \(error)") + } + } + } + + func bookRide() { + guard let apiService = apiService, + let pickup = selectedPickupLocation, + let dropoff = selectedDropoffLocation, + let driver = selectedDriver, + !customerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + isBookingRide = true + + Task { + do { + let request = RideRequest( + sessionID: UInt.random(in: 1...1000), + requestID: UInt.random(in: 1...10000), + pickupLocationID: UInt(pickup.id), + dropoffLocationID: UInt(dropoff.id) + ) + + let response = try await apiService.bookRide(request) + + let trip = Trip( + id: UUID(), + rideId: response.rideId, + customerName: customerName.trimmingCharacters(in: .whitespacesAndNewlines), + pickupAddress: pickup.name, + dropoffAddress: dropoff.name, + selectedDriver: driver, + eta: response.eta * 60, // Convert minutes to seconds + status: .confirmed + ) + + await MainActor.run { + self.appState?.currentTrip = trip + self.isBookingRide = false + self.navigationPath.append(trip) + } + } catch { + await MainActor.run { + self.isBookingRide = false + } + print("Error booking ride: \(error)") + } + } + } +} + +#Preview { + HomeView() + .environmentObject(AppState()) +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Views/RatingView.swift b/hotrod-ios-app/hotrod/ios/HotRodApp/Views/RatingView.swift new file mode 100644 index 0000000..ef53c62 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Views/RatingView.swift @@ -0,0 +1,148 @@ +import SwiftUI + +struct RatingView: View { + @StateObject private var viewModel: RatingViewModel + @Environment(\.dismiss) private var dismiss + + init(tripId: UUID) { + _viewModel = StateObject(wrappedValue: RatingViewModel(tripId: tripId)) + } + + var body: some View { + VStack(spacing: 20) { + VStack(spacing: 20) { + // Trip Info Header + HStack { + Image(systemName: "car.fill") + .font(.system(size: 24)) + VStack(alignment: .leading) { + Text("Trip Rating") + .font(.headline) + Text("Please rate your experience") + .font(.subheadline) + .foregroundColor(.gray) + } + } + .padding() + + // Feedback State + if let feedbackState = viewModel.feedbackState { + switch feedbackState { + case .loading: + ProgressView() + .progressViewStyle(.circular) + .padding() + case .success: + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Rating submitted successfully!") + .foregroundColor(.green) + } + .padding() + case .error(let message): + VStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(message) + .foregroundColor(.red) + } + .padding() + } + } + + // Rating Stars + HStack(spacing: 10) { + ForEach(1...5, id: \.self) { index in + Button { + viewModel.rating.stars = index + } label: { + Image(systemName: viewModel.rating.stars >= index ? "star.fill" : "star") + .font(.system(size: 30)) + .foregroundColor(viewModel.rating.stars >= index ? .yellow : .gray) + } + } + } + .padding() + + // Comment Field + TextEditor(text: Binding( + get: { viewModel.rating.comment ?? "" }, + set: { viewModel.rating.comment = $0.isEmpty ? nil : $0 } + )) + .frame(height: 100) + .cornerRadius(8) + .padding() + + // Submit Button + Button(action: viewModel.submitRating) { + Text(viewModel.feedbackState == .loading ? "Submitting..." : "Submit Rating") + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.feedbackState == .loading) + .padding() + + Spacer() + } + .navigationTitle("Rate Your Trip") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done") { + dismiss() + } + } + } + .onChange(of: viewModel.shouldDismiss) { + if viewModel.shouldDismiss { + dismiss() + } + } + } + } +} + +final class RatingViewModel: ObservableObject { + @Published var rating: Rating + @Published var feedbackState: FeedbackState? + @Published var shouldDismiss = false + private let apiService: APIService + + enum FeedbackState: Equatable { + case success + case error(String) + case loading + } + + init(tripId: UUID) { + self.rating = Rating(stars: 0, comment: nil, tripId: tripId) + // Use mock service for now - in production, get from environment + self.apiService = MockAPIService(simulateEnhancedFeatures: true) + } + + func submitRating() { + feedbackState = .loading + Task { + do { + try await apiService.submitRating(rating) + await MainActor.run { + feedbackState = .success + } + // After a brief delay, trigger dismiss + try await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds + await MainActor.run { + shouldDismiss = true + } + } catch { + await MainActor.run { + feedbackState = .error("Failed to submit rating: \(error.localizedDescription)") + } + } + } + } +} + +#Preview { + RatingView(tripId: UUID()) +} diff --git a/hotrod-ios-app/hotrod/ios/HotRodApp/Views/TripInfoView.swift b/hotrod-ios-app/hotrod/ios/HotRodApp/Views/TripInfoView.swift new file mode 100644 index 0000000..1311e51 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/HotRodApp/Views/TripInfoView.swift @@ -0,0 +1,333 @@ +import SwiftUI + +struct TripInfoView: View { + @StateObject private var viewModel: TripInfoViewModel + @Environment(\.dismiss) private var dismiss + + init(trip: Trip) { + _viewModel = StateObject(wrappedValue: TripInfoViewModel(trip: trip)) + } + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Trip Status Header + tripStatusHeader + + // Customer & Driver Info + customerDriverSection + + // Trip Details + tripDetailsSection + + // Action Buttons + actionButtonsSection + + Spacer(minLength: 50) + } + .padding() + } + .navigationTitle("Trip Details") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + .background( + NavigationLink( + destination: RatingView(tripId: viewModel.trip.id), + isActive: $viewModel.shouldNavigateToRating + ) { + EmptyView() + } + .hidden() + ) + } + + // MARK: - Trip Status Header + private var tripStatusHeader: some View { + VStack(spacing: 16) { + HStack { + Image(systemName: viewModel.trip.status.icon) + .font(.system(size: 32)) + .foregroundColor(Color(viewModel.trip.status.color)) + + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.trip.status.rawValue) + .font(.title2) + .fontWeight(.bold) + + if let rideId = viewModel.trip.rideId { + Text("Ride ID: \(rideId.prefix(8))...") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + VStack(alignment: .trailing) { + Text("\(Int(viewModel.trip.eta / 60)) min") + .font(.title3) + .fontWeight(.semibold) + Text("ETA") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } + + // MARK: - Customer & Driver Section + private var customerDriverSection: some View { + VStack(spacing: 16) { + // Customer Info + HStack { + Image(systemName: "person.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.blue) + + VStack(alignment: .leading, spacing: 2) { + Text("Customer") + .font(.caption) + .foregroundColor(.secondary) + Text(viewModel.trip.customerName) + .font(.headline) + } + + Spacer() + } + + // Driver Info (if available) + if let driver = viewModel.trip.selectedDriver { + Divider() + + HStack { + Image(systemName: "car.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.green) + + VStack(alignment: .leading, spacing: 2) { + Text("Driver") + .font(.caption) + .foregroundColor(.secondary) + Text(driver.name) + .font(.headline) + + if let rating = driver.rating, let trips = driver.completedTrips { + Text("โญ \(String(format: "%.1f", rating)) โ€ข \(trips) completed trips") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + VStack(alignment: .trailing) { + Text(driver.etaText) + .font(.subheadline) + .fontWeight(.medium) + Text("Original ETA") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } + + // MARK: - Trip Details Section + private var tripDetailsSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Trip Route") + .font(.headline) + + VStack(spacing: 12) { + // Pickup Location + HStack { + Image(systemName: "location.circle.fill") + .foregroundColor(.green) + .font(.system(size: 20)) + + VStack(alignment: .leading, spacing: 2) { + Text("Pickup") + .font(.caption) + .foregroundColor(.secondary) + Text(viewModel.trip.pickupAddress) + .font(.subheadline) + .fontWeight(.medium) + } + + Spacer() + } + + // Route Line + HStack { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 2, height: 20) + .offset(x: 9) + Spacer() + } + + // Dropoff Location + HStack { + Image(systemName: "location.circle.fill") + .foregroundColor(.red) + .font(.system(size: 20)) + + VStack(alignment: .leading, spacing: 2) { + Text("Dropoff") + .font(.caption) + .foregroundColor(.secondary) + Text(viewModel.trip.dropoffAddress) + .font(.subheadline) + .fontWeight(.medium) + } + + Spacer() + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } + + // MARK: - Action Buttons Section + private var actionButtonsSection: some View { + VStack(spacing: 12) { + if viewModel.trip.status == .booking || viewModel.trip.status == .confirmed { + Button(action: viewModel.startRide) { + HStack { + Image(systemName: "play.circle.fill") + Text("Start Trip") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(12) + } + } else if viewModel.trip.status == .driverEnRoute || viewModel.trip.status == .inProgress { + Button(action: viewModel.endTrip) { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Complete Trip") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + } else if viewModel.trip.status == .completed { + Button(action: { viewModel.shouldNavigateToRating = true }) { + HStack { + Image(systemName: "star.fill") + Text("Rate Trip") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(12) + } + } + + if viewModel.trip.status != .completed && viewModel.trip.status != .cancelled { + Button(action: viewModel.cancelTrip) { + HStack { + Image(systemName: "xmark.circle") + Text("Cancel Trip") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.red.opacity(0.1)) + .foregroundColor(.red) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.red, lineWidth: 1) + ) + } + } + } + } +} + +final class TripInfoViewModel: ObservableObject { + @Published var trip: Trip + @Published var shouldNavigateToRating = false + private let apiService: APIService + + init(trip: Trip) { + self.trip = trip + // Use mock service for now - in production, get from environment + self.apiService = MockAPIService(simulateEnhancedFeatures: true) + } + + func startRide() { + Task { + await MainActor.run { + trip.status = .driverEnRoute + } + + // Simulate driver arriving + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + + await MainActor.run { + trip.status = .inProgress + } + } + } + + func endTrip() { + Task { + await MainActor.run { + trip.status = .completed + shouldNavigateToRating = true + } + } + } + + func cancelTrip() { + Task { + await MainActor.run { + trip.status = .cancelled + } + } + } +} + +#Preview { + TripInfoView(trip: Trip( + id: UUID(), + rideId: "ride-12345", + customerName: "John Doe", + pickupAddress: "Central Park", + dropoffAddress: "Times Square", + selectedDriver: Driver( + id: "driver-001", + name: "Sarah Johnson", + location: "Central Park", + eta: 8, + rating: 4.9, + completedTrips: 312 + ), + eta: 15 * 60, + status: .confirmed + )) +} diff --git a/hotrod-ios-app/hotrod/ios/Package.swift b/hotrod-ios-app/hotrod/ios/Package.swift new file mode 100644 index 0000000..5c820f8 --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "HotRodApp", + platforms: [ + .iOS(.v17) + ], + products: [ + .library( + name: "HotRodApp", + targets: ["HotRodApp"]), + ], + dependencies: [ + // Add any external dependencies here if needed + ], + targets: [ + .target( + name: "HotRodApp", + dependencies: []), + .testTarget( + name: "HotRodAppTests", + dependencies: ["HotRodApp"]), + ] +) diff --git a/hotrod-ios-app/hotrod/ios/README.md b/hotrod-ios-app/hotrod/ios/README.md new file mode 100644 index 0000000..e580bca --- /dev/null +++ b/hotrod-ios-app/hotrod/ios/README.md @@ -0,0 +1,184 @@ +# HotROD iOS App - Signadot Sandbox Testing + +A modern, beautiful iOS app built with SwiftUI to test HotROD microservices using Signadot sandboxes. This app demonstrates how mobile developers can test backend changes in isolation before they reach production. + +## ๐Ÿš€ Features + +### ๐ŸŽฏ Core Functionality +- **Ride Booking Interface**: Complete ride booking flow with location selection, driver selection, and trip management +- **Signadot Sandbox Integration**: Switch between production, sandbox, and route group environments +- **Enhanced Location Service Testing**: Test new pickup locations (airports, malls) via location-enhanced sandbox +- **Driver Ratings Feature Testing**: Test driver ratings and trip history via driver-ratings sandbox +- **Combined Feature Testing**: Test multiple features together using route groups + +### ๐ŸŽจ Modern UI/UX +- **Beautiful SwiftUI Interface**: Modern, clean design with gradient buttons and card layouts +- **Developer Debug Panel**: Toggle-able debug interface for environment switching +- **Real-time Status Updates**: Live trip status with animated state changes +- **Comprehensive Trip Details**: Detailed trip information with driver ratings and route visualization +- **Rating System**: Post-trip rating interface with star ratings and comments + +### ๐Ÿ”ง Technical Features +- **Environment Switching**: Seamless switching between production and sandbox environments +- **Routing Header Support**: Automatic injection of Signadot routing headers +- **Mock & Real API Support**: Both mock data for testing and real HotROD API integration +- **State Management**: Centralized app state with ObservableObject pattern +- **Error Handling**: Comprehensive error handling with user-friendly messages + +## ๐Ÿ“ฑ App Structure + +``` +HotRodApp/ +โ”œโ”€โ”€ HotRodAppApp.swift # Main app entry point +โ”œโ”€โ”€ ContentView.swift # Root navigation view +โ”œโ”€โ”€ Models.swift # Data models and app state +โ”œโ”€โ”€ Services/ +โ”‚ โ””โ”€โ”€ APIService.swift # API integration (HotROD + Mock) +โ””โ”€โ”€ Views/ + โ”œโ”€โ”€ HomeView.swift # Main ride booking interface + โ”œโ”€โ”€ EnvironmentSelectorView.swift # Sandbox environment selector + โ”œโ”€โ”€ TripInfoView.swift # Trip details and management + โ””โ”€โ”€ RatingView.swift # Post-trip rating interface +``` + +## ๐Ÿ—๏ธ Architecture + +### Models +- **EnvironmentOption**: Represents different testing environments (production, sandbox, route group) +- **Driver**: Driver information with optional ratings and trip history +- **Trip**: Complete trip information with status tracking +- **AppState**: Centralized app state management + +### Services +- **HotRODAPIService**: Real API integration with Signadot routing header support +- **MockAPIService**: Mock service for testing enhanced features locally + +### Views +- **HomeView**: Main interface with location selection, driver selection, and booking +- **EnvironmentSelectorView**: Debug panel for switching between environments +- **TripInfoView**: Comprehensive trip details with status management +- **RatingView**: Post-trip rating interface + +## ๐Ÿงช Testing Scenarios + +### 1. Enhanced Location Service +**Sandbox**: `location-enhanced` +- **Test**: New pickup locations (JFK Airport, Brooklyn Mall, LaGuardia Airport) +- **Expected**: Additional location options appear in pickup/dropoff menus +- **Validation**: Compare with production baseline (fewer locations) + +### 2. Driver Ratings Feature +**Sandbox**: `driver-ratings` +- **Test**: Driver selection shows ratings and completed trips +- **Expected**: Drivers display "โญ 4.8 (245 trips)" format +- **Validation**: Production shows drivers without ratings + +### 3. Combined Features +**Route Group**: `combined-features` +- **Test**: Both enhanced locations AND driver ratings +- **Expected**: New locations + driver ratings work together +- **Validation**: Full feature integration testing + +## ๐Ÿšฆ Usage Instructions + +### 1. Enable Debug Mode +- Tap the wrench icon in the top-right corner +- Debug panel appears with environment selector + +### 2. Select Testing Environment +- **๐Ÿญ Production (Baseline)**: Standard HotROD functionality +- **๐Ÿ“ฆ driver-ratings**: Test driver ratings feature +- **๐Ÿ“ฆ location-enhanced**: Test enhanced location service +- **๐Ÿ”— combined-features**: Test both features together + +### 3. Book a Ride +1. Select pickup location (note new options in enhanced mode) +2. Select dropoff location +3. Enter your name +4. Choose a driver (note ratings in enhanced mode) +5. Tap "Book Ride" + +### 4. Manage Trip +- View trip details with comprehensive information +- Start trip when ready +- Complete trip and provide rating + +## ๐Ÿ”— Signadot Integration + +### Routing Headers +The app automatically injects Signadot routing headers when a sandbox is selected: +```swift +"baggage": "sd-routing-key=\(routingKey)" +"ot-baggage-sd-routing-key": routingKey +``` + +### Environment Configuration +```swift +// Production (no routing key) +baseURL: "http://localhost:8080" +routingHeaders: [:] + +// Sandbox (with routing key) +baseURL: "http://localhost:8080" +routingHeaders: [ + "baggage": "sd-routing-key=driver-ratings-routing-key", + "ot-baggage-sd-routing-key": "driver-ratings-routing-key" +] +``` + +## ๐Ÿ› ๏ธ Development Setup + +### Prerequisites +- Xcode 15.0+ +- iOS 17.0+ +- HotROD backend running locally or accessible via network + +### Configuration +1. Update `baseURL` in `AppState` to point to your HotROD frontend service +2. Configure routing keys to match your actual Signadot sandbox routing keys +3. For production use, replace `MockAPIService` with `HotRODAPIService` + +### Real API Integration +To use real HotROD APIs instead of mock data: + +```swift +// In HomeViewModel.updateAPIService() +self.apiService = HotRODAPIService( + baseURL: appState.baseURL, + routingHeaders: appState.routingHeaders +) +``` + +## ๐ŸŽฏ Key Benefits + +### For Mobile Developers +- **Isolated Testing**: Test backend changes without affecting other developers +- **Feature Validation**: Validate new features before production deployment +- **Integration Testing**: Test multiple backend changes together +- **Rapid Iteration**: Quick feedback loop for backend API changes + +### For Backend Developers +- **Mobile Validation**: Get mobile app feedback on API changes +- **User Experience Testing**: See how changes affect real user workflows +- **Cross-team Collaboration**: Enable mobile team to test backend PRs + +### For QA Teams +- **Comprehensive Testing**: Test individual features and combinations +- **Regression Testing**: Compare new features against production baseline +- **User Journey Testing**: End-to-end testing with real mobile interface + +## ๐Ÿš€ Next Steps + +1. **Real API Integration**: Connect to actual HotROD backend services +2. **Signadot API Integration**: Dynamically load available sandboxes from Signadot API +3. **Enhanced Error Handling**: Add comprehensive error states and retry mechanisms +4. **Offline Support**: Cache data for offline testing scenarios +5. **Analytics Integration**: Track feature usage and performance metrics + +## ๐Ÿ“ Notes + +- Currently uses mock data to demonstrate features - replace with real API calls for production +- Routing keys are hardcoded - integrate with Signadot API for dynamic loading +- Designed for local testing with port forwarding or direct network access to HotROD services + +This iOS app provides a comprehensive testing platform for HotROD microservices using Signadot sandboxes, enabling mobile developers to validate backend changes in isolation with a beautiful, modern interface. diff --git a/hotrod-ios-app/signadot-config/driver-ratings.yaml b/hotrod-ios-app/signadot-config/driver-ratings.yaml new file mode 100644 index 0000000..f8909f7 --- /dev/null +++ b/hotrod-ios-app/signadot-config/driver-ratings.yaml @@ -0,0 +1,16 @@ +name: driver-ratings +spec: + description: "Preview of updated driver service using ratings-v10" + cluster: local-hotrod-cluster + labels: + feature: driver-ratings + team: backend + forks: + - forkOf: + kind: Deployment + namespace: hotrod + name: driver + customizations: + images: + - container: hotrod + image: /hotrod-driver:ratings-v11 \ No newline at end of file diff --git a/hotrod-ios-app/signadot-config/location-enhanced.yaml b/hotrod-ios-app/signadot-config/location-enhanced.yaml new file mode 100644 index 0000000..4897977 --- /dev/null +++ b/hotrod-ios-app/signadot-config/location-enhanced.yaml @@ -0,0 +1,20 @@ +name: location-enhanced +spec: + description: "Enhanced location service with new pickup points" + cluster: "local-hotrod-cluster" + labels: + feature: enhanced-locations + team: backend + forks: + - forkOf: + kind: Deployment + namespace: hotrod + name: location + customizations: + images: + - image: mostafamraafat/hotrod-location:dynamic-seed-v5 + container: hotrod + env: + - container: hotrod + name: MYSQL_DATABASE + value: "hotrod_sandbox"