From d1a7a791f947db9e37a5982563ef25d33a183ad4 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 16 Jan 2026 10:35:19 +0700 Subject: [PATCH] feat(app-client-base-swift): Add iOS Swift client app with auth UI - Add SplashView, AuthContainerView with Login/Register/ForgotPassword - Add AuthViewModel with form validation - Add HomeView, ProfileView, ExploreView screens - Add APIService, AuthManager for networking - Add multi-language support (en/vi) - Add User model and extensions --- apps/app-client-base-swift/AppClientBaseSwift | 1 - .../project.pbxproj | 338 ++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/WorkspaceSettings.xcsettings | 5 + .../UserInterfaceState.xcuserstate | Bin 0 -> 41772 bytes .../WorkspaceSettings.xcsettings | 16 + .../xcschemes/AppClientBaseSwift.xcscheme | 78 ++++ .../xcschemes/xcschememanagement.plist | 22 ++ .../AppClientBaseSwiftApp.swift | 20 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../AppClientBaseSwift/ContentView.swift | 184 +++++++++ .../Core/Constants/Constants.swift | 163 ++++++++ .../Core/Extensions/String+Extensions.swift | 158 ++++++++ .../Core/Extensions/View+Extensions.swift | 148 +++++++ .../AppClientBaseSwift/Models/User.swift | 129 +++++++ .../Resources/en.lproj/Localizable.strings | 75 ++++ .../Resources/vi.lproj/Localizable.strings | 75 ++++ .../Services/APIService.swift | 235 +++++++++++ .../Services/AuthManager.swift | 333 ++++++++++++++++ .../ViewModels/AuthViewModel.swift | 280 ++++++++++++++ .../ViewModels/HomeViewModel.swift | 191 +++++++++ .../ViewModels/ProfileViewModel.swift | 221 +++++++++++ .../Views/Auth/AuthContainerView.swift | 76 ++++ .../Views/Auth/ForgotPasswordView.swift | 238 ++++++++++++ .../Views/Auth/LoginView.swift | 364 ++++++++++++++++++ .../Views/Auth/RegisterView.swift | 283 ++++++++++++++ .../Views/Screens/ExploreView.swift | 309 +++++++++++++++ .../Views/Screens/HomeView.swift | 247 ++++++++++++ .../Views/Screens/ProfileView.swift | 191 +++++++++ .../Views/Screens/SplashView.swift | 178 +++++++++ .../Views/Screens/WelcomeView.swift | 207 ++++++++++ 33 files changed, 4823 insertions(+), 1 deletion(-) delete mode 160000 apps/app-client-base-swift/AppClientBaseSwift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.pbxproj create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/WorkspaceSettings.xcsettings create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/xcshareddata/xcschemes/AppClientBaseSwift.xcscheme create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/xcuserdata/velikho.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/AppClientBaseSwiftApp.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/Contents.json create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ContentView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/Constants.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Extensions/String+Extensions.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Extensions/View+Extensions.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Models/User.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Resources/en.lproj/Localizable.strings create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Resources/vi.lproj/Localizable.strings create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Services/APIService.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Services/AuthManager.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/AuthViewModel.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/HomeViewModel.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/ProfileViewModel.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/AuthContainerView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/ForgotPasswordView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/LoginView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/RegisterView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/ExploreView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/HomeView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/ProfileView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/SplashView.swift create mode 100644 apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/WelcomeView.swift diff --git a/apps/app-client-base-swift/AppClientBaseSwift b/apps/app-client-base-swift/AppClientBaseSwift deleted file mode 160000 index 9f3cfadc..00000000 --- a/apps/app-client-base-swift/AppClientBaseSwift +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9f3cfadcc31a96c4dc40437b186a0af22e83e465 diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.pbxproj b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.pbxproj new file mode 100644 index 00000000..617fd7fe --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.pbxproj @@ -0,0 +1,338 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 901363EA2F19DDEB0097E0A7 /* AppClientBaseSwift.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppClientBaseSwift.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 901363EC2F19DDEB0097E0A7 /* AppClientBaseSwift */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AppClientBaseSwift; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 901363E72F19DDEB0097E0A7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 901363E12F19DDEB0097E0A7 = { + isa = PBXGroup; + children = ( + 901363EC2F19DDEB0097E0A7 /* AppClientBaseSwift */, + 901363EB2F19DDEB0097E0A7 /* Products */, + ); + sourceTree = ""; + }; + 901363EB2F19DDEB0097E0A7 /* Products */ = { + isa = PBXGroup; + children = ( + 901363EA2F19DDEB0097E0A7 /* AppClientBaseSwift.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 901363E92F19DDEB0097E0A7 /* AppClientBaseSwift */ = { + isa = PBXNativeTarget; + buildConfigurationList = 901363F52F19DDEC0097E0A7 /* Build configuration list for PBXNativeTarget "AppClientBaseSwift" */; + buildPhases = ( + 901363E62F19DDEB0097E0A7 /* Sources */, + 901363E72F19DDEB0097E0A7 /* Frameworks */, + 901363E82F19DDEB0097E0A7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 901363EC2F19DDEB0097E0A7 /* AppClientBaseSwift */, + ); + name = AppClientBaseSwift; + packageProductDependencies = ( + ); + productName = AppClientBaseSwift; + productReference = 901363EA2F19DDEB0097E0A7 /* AppClientBaseSwift.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 901363E22F19DDEB0097E0A7 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + 901363E92F19DDEB0097E0A7 = { + CreatedOnToolsVersion = 26.2; + }; + }; + }; + buildConfigurationList = 901363E52F19DDEB0097E0A7 /* Build configuration list for PBXProject "AppClientBaseSwift" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + vi, + ); + mainGroup = 901363E12F19DDEB0097E0A7; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 901363EB2F19DDEB0097E0A7 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 901363E92F19DDEB0097E0A7 /* AppClientBaseSwift */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 901363E82F19DDEB0097E0A7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 901363E62F19DDEB0097E0A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 901363F32F19DDEC0097E0A7 /* 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; + DEVELOPMENT_TEAM = HNRNJ72UT5; + 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 = 26.2; + 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; + }; + 901363F42F19DDEC0097E0A7 /* 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"; + DEVELOPMENT_TEAM = HNRNJ72UT5; + 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 = 26.2; + 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; + }; + 901363F62F19DDEC0097E0A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = HNRNJ72UT5; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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 = vn.goodgo.AppClientBaseSwift; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 901363F72F19DDEC0097E0A7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = HNRNJ72UT5; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + 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 = vn.goodgo.AppClientBaseSwift; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 901363E52F19DDEB0097E0A7 /* Build configuration list for PBXProject "AppClientBaseSwift" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 901363F32F19DDEC0097E0A7 /* Debug */, + 901363F42F19DDEC0097E0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 901363F52F19DDEC0097E0A7 /* Build configuration list for PBXNativeTarget "AppClientBaseSwift" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 901363F62F19DDEC0097E0A7 /* Debug */, + 901363F72F19DDEC0097E0A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 901363E22F19DDEB0097E0A7 /* Project object */; +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/UserInterfaceState.xcuserstate b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..d0aeae0ab3a2763fc85de3a58ce4bc6d6ad24abd GIT binary patch literal 41772 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%0AJ%Y}%2d$4cI|`d-)X+h=y) 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 + + + + BuildLocationStyle + UseAppPreferences + CompilationCachingSetting + Default + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/xcshareddata/xcschemes/AppClientBaseSwift.xcscheme b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/xcshareddata/xcschemes/AppClientBaseSwift.xcscheme new file mode 100644 index 00000000..6a266af8 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/xcshareddata/xcschemes/AppClientBaseSwift.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/xcuserdata/velikho.xcuserdatad/xcschemes/xcschememanagement.plist b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/xcuserdata/velikho.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..3ba7357d --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/xcuserdata/velikho.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + AppClientBaseSwift.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 901363E92F19DDEB0097E0A7 + + primary + + + + + diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/AppClientBaseSwiftApp.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/AppClientBaseSwiftApp.swift new file mode 100644 index 00000000..a13e1fbf --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/AppClientBaseSwiftApp.swift @@ -0,0 +1,20 @@ +// +// AppClientBaseSwiftApp.swift +// AppClientBaseSwift +// +// Main app entry point with splash screen +// Entry point chính của app với splash screen +// + +import SwiftUI + +@main +struct AppClientBaseSwiftApp: App { + var body: some Scene { + WindowGroup { + SplashView { + ContentView() + } + } + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/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/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/Contents.json b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ContentView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ContentView.swift new file mode 100644 index 00000000..8b2d3b78 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ContentView.swift @@ -0,0 +1,184 @@ +// +// ContentView.swift +// AppClientBaseSwift +// +// Main tab container with TabView +// Container tab chính với TabView +// + +import SwiftUI + +// MARK: - Content View +// View Content + +/// Main content view with tab-based navigation +/// View content chính với điều hướng tab +struct ContentView: View { + + // MARK: - Properties + + /// Currently selected tab + /// Tab đang được chọn + @State private var selectedTab: Tab = .home + + /// Auth manager for authentication state + /// Quản lý xác thực cho trạng thái authentication + @StateObject private var authManager = AuthManager.shared + + /// Whether to show welcome screen + /// Có hiển thị màn hình welcome không + @AppStorage(StorageKeys.isFirstLaunch) private var isFirstLaunch: Bool = true + + // MARK: - Tab Enum + + /// Available tabs + /// Các tab có sẵn + enum Tab: String, CaseIterable { + case home + case explore + case profile + + /// Tab title + /// Tiêu đề tab + var title: String { + switch self { + case .home: + return "tab_home".localized + case .explore: + return "tab_explore".localized + case .profile: + return "tab_profile".localized + } + } + + /// Tab icon + /// Icon tab + var icon: String { + switch self { + case .home: + return "house" + case .explore: + return "magnifyingglass" + case .profile: + return "person" + } + } + + /// Selected tab icon + /// Icon tab khi được chọn + var selectedIcon: String { + switch self { + case .home: + return "house.fill" + case .explore: + return "magnifyingglass" + case .profile: + return "person.fill" + } + } + } + + // MARK: - Body + + var body: some View { + Group { + switch authManager.authState { + case .unknown: + // Show loading while checking auth state + // Hiển thị loading khi đang kiểm tra trạng thái auth + loadingView + case .unauthenticated: + // Show auth flow + // Hiển thị luồng auth + AuthContainerView() + case .authenticated: + // Show main app content + // Hiển thị nội dung app chính + if isFirstLaunch { + WelcomeView { + withAnimation { + isFirstLaunch = false + } + } + } else { + mainTabView + } + } + } + .task { + await authManager.initialize() + } + } + + // MARK: - Subviews + + /// Loading view while checking auth + /// View loading khi đang kiểm tra auth + private var loadingView: some View { + ZStack { + Color(red: 0.05, green: 0.05, blue: 0.12) + .ignoresSafeArea() + + ProgressView() + .scaleEffect(1.5) + .tint(.white) + } + } + + // MARK: - Subviews + + /// Main tab view container + /// Container tab view chính + private var mainTabView: some View { + TabView(selection: $selectedTab) { + // Home Tab + // Tab Home + HomeView() + .tabItem { + Label { + Text(Tab.home.title) + } icon: { + Image( + systemName: selectedTab == .home ? Tab.home.selectedIcon : Tab.home.icon + ) + } + } + .tag(Tab.home) + + // Explore Tab + // Tab Khám phá + ExploreView() + .tabItem { + Label { + Text(Tab.explore.title) + } icon: { + Image( + systemName: selectedTab == .explore + ? Tab.explore.selectedIcon : Tab.explore.icon) + } + } + .tag(Tab.explore) + + // Profile Tab + // Tab Profile + ProfileView() + .tabItem { + Label { + Text(Tab.profile.title) + } icon: { + Image( + systemName: selectedTab == .profile + ? Tab.profile.selectedIcon : Tab.profile.icon) + } + } + .tag(Tab.profile) + } + .tint(.accentColor) + } +} + +// MARK: - Preview + +#Preview { + ContentView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/Constants.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/Constants.swift new file mode 100644 index 00000000..9b5041a9 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Constants/Constants.swift @@ -0,0 +1,163 @@ +// +// Constants.swift +// AppClientBaseSwift +// +// App-wide constants and configuration +// Hằng số và cấu hình toàn ứng dụng +// + +import SwiftUI + +// MARK: - API Configuration +// Cấu hình API endpoints + +/// API configuration constants +/// Các hằng số cấu hình API +enum APIConfig { + /// Base URL for API requests + /// URL gốc cho các request API + static let baseURL = "https://api.goodgo.vn" + + /// API version prefix + /// Tiền tố phiên bản API + static let apiVersion = "/api/v1" + + /// Request timeout in seconds + /// Thời gian timeout request (giây) + static let timeout: TimeInterval = 30.0 +} + +// MARK: - App Constants +// Hằng số ứng dụng + +/// General app constants +/// Các hằng số chung của ứng dụng +enum AppConstants { + /// App name + /// Tên ứng dụng + static let appName = "GoodGo" + + /// Bundle identifier + /// Định danh bundle + static let bundleId = "vn.goodgo.client" + + /// Keychain service name + /// Tên service Keychain + static let keychainService = "vn.goodgo.keychain" + + /// Default animation duration + /// Thời gian animation mặc định + static let animationDuration: Double = 0.3 +} + +// MARK: - Storage Keys +// Khóa lưu trữ + +/// UserDefaults and Keychain keys +/// Các khóa cho UserDefaults và Keychain +enum StorageKeys { + /// Access token key + /// Khóa access token + static let accessToken = "access_token" + + /// Refresh token key + /// Khóa refresh token + static let refreshToken = "refresh_token" + + /// User data key + /// Khóa dữ liệu người dùng + static let userData = "user_data" + + /// Is first launch key + /// Khóa kiểm tra lần chạy đầu tiên + static let isFirstLaunch = "is_first_launch" + + /// Selected language key + /// Khóa ngôn ngữ đã chọn + static let selectedLanguage = "selected_language" +} + +// MARK: - Design System +// Hệ thống thiết kế + +/// Design system constants for consistent UI +/// Hằng số hệ thống thiết kế cho UI nhất quán +enum DesignSystem { + + // MARK: Spacing + // Khoảng cách + + /// Extra small spacing (4pt) + static let spacingXS: CGFloat = 4 + + /// Small spacing (8pt) + static let spacingSM: CGFloat = 8 + + /// Medium spacing (16pt) + static let spacingMD: CGFloat = 16 + + /// Large spacing (24pt) + static let spacingLG: CGFloat = 24 + + /// Extra large spacing (32pt) + static let spacingXL: CGFloat = 32 + + // MARK: Corner Radius + // Bo góc + + /// Small corner radius + static let cornerRadiusSM: CGFloat = 8 + + /// Medium corner radius + static let cornerRadiusMD: CGFloat = 12 + + /// Large corner radius + static let cornerRadiusLG: CGFloat = 16 + + /// Circular corner radius + static let cornerRadiusCircle: CGFloat = 999 + + // MARK: Font Sizes + // Kích thước font + + /// Caption font size (12pt) + static let fontCaption: CGFloat = 12 + + /// Body font size (14pt) + static let fontBody: CGFloat = 14 + + /// Subhead font size (16pt) + static let fontSubhead: CGFloat = 16 + + /// Headline font size (18pt) + static let fontHeadline: CGFloat = 18 + + /// Title font size (24pt) + static let fontTitle: CGFloat = 24 + + /// Large title font size (32pt) + static let fontLargeTitle: CGFloat = 32 +} + +// MARK: - Colors +// Màu sắc + +/// App color palette +/// Bảng màu ứng dụng +extension Color { + /// Primary brand color + /// Màu chính thương hiệu + static let brandPrimary = Color("BrandPrimary", bundle: nil) + + /// Secondary brand color + /// Màu phụ thương hiệu + static let brandSecondary = Color("BrandSecondary", bundle: nil) + + /// Background color + /// Màu nền + static let appBackground = Color("AppBackground", bundle: nil) + + /// Card background color + /// Màu nền card + static let cardBackground = Color("CardBackground", bundle: nil) +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Extensions/String+Extensions.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Extensions/String+Extensions.swift new file mode 100644 index 00000000..2e7fc572 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Core/Extensions/String+Extensions.swift @@ -0,0 +1,158 @@ +// +// String+Extensions.swift +// AppClientBaseSwift +// +// String utility extensions +// Các extension tiện ích cho String +// + +import Foundation + +// MARK: - Localization +// Đa ngôn ngữ + +extension String { + + /// Get localized string + /// Lấy chuỗi đã bản địa hóa + var localized: String { + NSLocalizedString(self, comment: "") + } + + /// Get localized string with arguments + /// Lấy chuỗi đã bản địa hóa với tham số + /// - Parameter arguments: Format arguments / Các tham số format + /// - Returns: Localized formatted string / Chuỗi đã format và bản địa hóa + func localized(with arguments: CVarArg...) -> String { + String(format: localized, arguments: arguments) + } +} + +// MARK: - Validation +// Kiểm tra hợp lệ + +extension String { + + /// Check if string is valid email + /// Kiểm tra chuỗi có phải email hợp lệ + var isValidEmail: Bool { + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + return emailPredicate.evaluate(with: self) + } + + /// Check if string is valid phone number (Vietnam) + /// Kiểm tra chuỗi có phải số điện thoại hợp lệ (Việt Nam) + var isValidVietnamesePhone: Bool { + let phoneRegex = "^(0|\\+84)(3|5|7|8|9)[0-9]{8}$" + let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex) + return phonePredicate.evaluate(with: self) + } + + /// Check if string is valid password (min 8 chars, 1 uppercase, 1 lowercase, 1 number) + /// Kiểm tra mật khẩu hợp lệ (tối thiểu 8 ký tự, 1 chữ hoa, 1 chữ thường, 1 số) + var isValidPassword: Bool { + let passwordRegex = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d@$!%*?&]{8,}$" + let passwordPredicate = NSPredicate(format: "SELF MATCHES %@", passwordRegex) + return passwordPredicate.evaluate(with: self) + } + + /// Trimmed string (no leading/trailing whitespace) + /// Chuỗi đã loại bỏ khoảng trắng đầu/cuối + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Check if string is empty or whitespace only + /// Kiểm tra chuỗi rỗng hoặc chỉ có khoảng trắng + var isBlank: Bool { + trimmed.isEmpty + } +} + +// MARK: - Formatting +// Định dạng + +extension String { + + /// Mask email for privacy (e.g., "j***@example.com") + /// Ẩn email cho bảo mật (vd: "j***@example.com") + var maskedEmail: String { + guard isValidEmail else { return self } + + let components = split(separator: "@") + guard components.count == 2 else { return self } + + let localPart = String(components[0]) + let domain = String(components[1]) + + if localPart.count <= 2 { + return "\(localPart.prefix(1))***@\(domain)" + } + + return "\(localPart.prefix(1))***\(localPart.suffix(1))@\(domain)" + } + + /// Mask phone number for privacy (e.g., "****1234") + /// Ẩn số điện thoại cho bảo mật (vd: "****1234") + var maskedPhone: String { + guard count >= 4 else { return self } + let lastFour = suffix(4) + let masked = String(repeating: "*", count: count - 4) + return masked + lastFour + } + + /// Format as currency (Vietnamese Dong) + /// Định dạng tiền tệ (Đồng Việt Nam) + /// - Parameter amount: Amount to format / Số tiền cần định dạng + /// - Returns: Formatted currency string / Chuỗi tiền tệ đã định dạng + static func formatVND(_ amount: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "VND" + formatter.currencySymbol = "₫" + formatter.maximumFractionDigits = 0 + return formatter.string(from: NSNumber(value: amount)) ?? "\(Int(amount))₫" + } +} + +// MARK: - URL Helpers +// Các helper URL + +extension String { + + /// Convert to URL if valid + /// Chuyển đổi thành URL nếu hợp lệ + var asURL: URL? { + URL(string: self) + } + + /// URL encoded string + /// Chuỗi đã mã hóa URL + var urlEncoded: String { + addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? self + } +} + +// MARK: - Subscript +// Truy cập ký tự + +extension String { + + /// Safe subscript access by index + /// Truy cập an toàn theo index + /// - Parameter index: Character index / Index ký tự + subscript(safe index: Int) -> Character? { + guard index >= 0, index < count else { return nil } + return self[self.index(startIndex, offsetBy: index)] + } + + /// Subscript access by range + /// Truy cập theo range + /// - Parameter range: Range of characters / Range các ký tự + subscript(range: Range) -> String { + let startIndex = self.index(self.startIndex, offsetBy: max(0, range.lowerBound)) + let endIndex = self.index(self.startIndex, offsetBy: min(count, range.upperBound)) + return String(self[startIndex.. some View { + background(Color.white) + .cornerRadius(cornerRadius) + .shadow(color: Color.black.opacity(0.1), radius: shadowRadius, x: 0, y: 2) + } + + /// Apply loading overlay + /// Áp dụng overlay loading + /// - Parameter isLoading: Loading state / Trạng thái loading + /// - Returns: Modified view / View đã được sửa đổi + func loadingOverlay(_ isLoading: Bool) -> some View { + overlay { + if isLoading { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView() + .scaleEffect(1.5) + .tint(.white) + } + } + } + } + + /// Conditional modifier + /// Modifier có điều kiện + /// - Parameters: + /// - condition: Condition to check / Điều kiện kiểm tra + /// - transform: Transform to apply / Transform cần áp dụng + /// - Returns: Modified view / View đã được sửa đổi + @ViewBuilder + func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } + + /// Apply shimmer effect for loading placeholder + /// Áp dụng hiệu ứng shimmer cho placeholder loading + /// - Parameter isActive: Whether shimmer is active / Shimmer có đang hoạt động không + /// - Returns: Modified view / View đã được sửa đổi + func shimmer(isActive: Bool = true) -> some View { + modifier(ShimmerModifier(isActive: isActive)) + } + + /// Hide keyboard + /// Ẩn bàn phím + func hideKeyboard() { + #if os(iOS) + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + #endif + } +} + +// MARK: - Shimmer Modifier +// Modifier hiệu ứng Shimmer + +/// Shimmer loading effect modifier +/// Modifier hiệu ứng loading shimmer +struct ShimmerModifier: ViewModifier { + let isActive: Bool + @State private var phase: CGFloat = 0 + + func body(content: Content) -> some View { + if isActive { + content + .overlay( + GeometryReader { geometry in + LinearGradient( + colors: [ + Color.white.opacity(0.1), + Color.white.opacity(0.5), + Color.white.opacity(0.1), + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geometry.size.width * 2) + .offset(x: -geometry.size.width + (geometry.size.width * 2 * phase)) + } + .mask(content) + ) + .onAppear { + withAnimation( + .linear(duration: 1.5) + .repeatForever(autoreverses: false) + ) { + phase = 1 + } + } + } else { + content + } + } +} + +// MARK: - Navigation Helpers +// Các helper điều hướng + +extension View { + /// Navigate to destination on tap + /// Điều hướng đến destination khi tap + /// - Parameters: + /// - destination: Destination view / View đích + /// - Returns: NavigationLink / NavigationLink + func navigateTo(@ViewBuilder destination: @escaping () -> Destination) + -> some View + { + NavigationLink { + destination() + } label: { + self + } + .buttonStyle(.plain) + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Models/User.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Models/User.swift new file mode 100644 index 00000000..6e16ba31 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Models/User.swift @@ -0,0 +1,129 @@ +// +// User.swift +// AppClientBaseSwift +// +// User data model +// Model dữ liệu người dùng +// + +import Foundation + +// MARK: - User Model +// Model người dùng + +/// User entity representing authenticated user +/// Entity người dùng đại diện cho user đã xác thực +struct User: Codable, Identifiable, Equatable { + + /// Unique user identifier + /// Định danh người dùng duy nhất + let id: String + + /// User email address + /// Địa chỉ email người dùng + let email: String + + /// User display name + /// Tên hiển thị người dùng + let name: String + + /// User avatar URL + /// URL ảnh đại diện người dùng + let avatarUrl: String? + + /// User phone number + /// Số điện thoại người dùng + let phoneNumber: String? + + /// Whether email is verified + /// Email đã được xác minh chưa + let isEmailVerified: Bool + + /// Account creation date + /// Ngày tạo tài khoản + let createdAt: Date? + + /// Last update date + /// Ngày cập nhật cuối + let updatedAt: Date? + + // MARK: - CodingKeys + + enum CodingKeys: String, CodingKey { + case id + case email + case name + case avatarUrl = "avatar_url" + case phoneNumber = "phone_number" + case isEmailVerified = "is_email_verified" + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +// MARK: - User Extensions +// Các extension của User + +extension User { + + /// Get user initials for avatar placeholder + /// Lấy chữ cái đầu tên cho placeholder avatar + var initials: String { + let components = name.split(separator: " ") + let firstInitial = components.first?.prefix(1) ?? "" + let lastInitial = components.count > 1 ? components.last?.prefix(1) ?? "" : "" + return "\(firstInitial)\(lastInitial)".uppercased() + } + + /// Get display name (first name only) + /// Lấy tên hiển thị (chỉ tên đầu) + var firstName: String { + String(name.split(separator: " ").first ?? Substring(name)) + } + + /// Get masked email for privacy display + /// Lấy email ẩn để hiển thị bảo mật + var maskedEmail: String { + email.maskedEmail + } + + /// Get masked phone for privacy display + /// Lấy số điện thoại ẩn để hiển thị bảo mật + var maskedPhone: String? { + phoneNumber?.maskedPhone + } +} + +// MARK: - Mock Data +// Dữ liệu mẫu + +#if DEBUG + extension User { + + /// Sample user for previews and testing + /// Người dùng mẫu cho preview và testing + static let sample = User( + id: "user_123456", + email: "john.doe@example.com", + name: "John Doe", + avatarUrl: "https://api.dicebear.com/7.x/avataaars/png?seed=john", + phoneNumber: "0912345678", + isEmailVerified: true, + createdAt: Date(), + updatedAt: Date() + ) + + /// Sample user without avatar + /// Người dùng mẫu không có avatar + static let sampleNoAvatar = User( + id: "user_789012", + email: "jane.doe@example.com", + name: "Jane Doe", + avatarUrl: nil, + phoneNumber: nil, + isEmailVerified: false, + createdAt: Date(), + updatedAt: nil + ) + } +#endif diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Resources/en.lproj/Localizable.strings b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..27df4c50 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Resources/en.lproj/Localizable.strings @@ -0,0 +1,75 @@ +/* + Localizable.strings (English) + AppClientBaseSwift + + English localization strings + Các chuỗi bản địa hóa tiếng Anh +*/ + +// MARK: - Common +"common_ok" = "OK"; +"common_cancel" = "Cancel"; +"common_done" = "Done"; +"common_save" = "Save"; +"common_delete" = "Delete"; +"common_edit" = "Edit"; +"common_close" = "Close"; +"common_back" = "Back"; +"common_next" = "Next"; +"common_skip" = "Skip"; +"common_see_all" = "See All"; +"common_loading" = "Loading..."; +"common_retry" = "Retry"; +"common_error" = "Error"; +"common_success" = "Success"; + +// MARK: - Tabs +"tab_home" = "Home"; +"tab_explore" = "Explore"; +"tab_profile" = "Profile"; + +// MARK: - Home +"home_subtitle" = "Discover new experiences today"; +"home_featured" = "Featured"; +"home_explore" = "Explore"; + +// MARK: - Explore +"explore_search_placeholder" = "Search places, services..."; +"explore_popular" = "Popular"; +"explore_nearby" = "Nearby"; +"explore_no_results" = "No results found"; +"explore_try_different" = "Try different keywords"; + +// MARK: - Profile +"profile_edit" = "Edit Profile"; +"profile_notifications" = "Notifications"; +"profile_security" = "Security"; +"profile_language" = "Language"; +"profile_help" = "Help Center"; +"profile_about" = "About"; +"profile_logout" = "Log Out"; +"profile_logout_title" = "Log Out"; +"profile_logout_message" = "Are you sure you want to log out?"; +"profile_verified" = "Verified"; + +// MARK: - Welcome +"welcome_discover_title" = "Discover Amazing Places"; +"welcome_discover_desc" = "Find the best restaurants, cafes, and attractions near you"; +"welcome_rewards_title" = "Earn Rewards"; +"welcome_rewards_desc" = "Collect points with every visit and redeem exclusive benefits"; +"welcome_community_title" = "Join the Community"; +"welcome_community_desc" = "Connect with others and share your experiences"; +"welcome_get_started" = "Get Started"; + +// MARK: - Auth +"auth_login" = "Log In"; +"auth_register" = "Sign Up"; +"auth_email" = "Email"; +"auth_password" = "Password"; +"auth_forgot_password" = "Forgot Password?"; + +// MARK: - Errors +"error_title" = "Error"; +"error_network" = "Network error. Please check your connection."; +"error_server" = "Server error. Please try again later."; +"error_unknown" = "An unexpected error occurred."; diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Resources/vi.lproj/Localizable.strings b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Resources/vi.lproj/Localizable.strings new file mode 100644 index 00000000..2dde8816 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Resources/vi.lproj/Localizable.strings @@ -0,0 +1,75 @@ +/* + Localizable.strings (Vietnamese) + AppClientBaseSwift + + Vietnamese localization strings + Các chuỗi bản địa hóa tiếng Việt +*/ + +// MARK: - Common +"common_ok" = "OK"; +"common_cancel" = "Hủy"; +"common_done" = "Xong"; +"common_save" = "Lưu"; +"common_delete" = "Xóa"; +"common_edit" = "Sửa"; +"common_close" = "Đóng"; +"common_back" = "Quay lại"; +"common_next" = "Tiếp theo"; +"common_skip" = "Bỏ qua"; +"common_see_all" = "Xem tất cả"; +"common_loading" = "Đang tải..."; +"common_retry" = "Thử lại"; +"common_error" = "Lỗi"; +"common_success" = "Thành công"; + +// MARK: - Tabs +"tab_home" = "Trang chủ"; +"tab_explore" = "Khám phá"; +"tab_profile" = "Cá nhân"; + +// MARK: - Home +"home_subtitle" = "Khám phá trải nghiệm mới hôm nay"; +"home_featured" = "Nổi bật"; +"home_explore" = "Khám phá"; + +// MARK: - Explore +"explore_search_placeholder" = "Tìm địa điểm, dịch vụ..."; +"explore_popular" = "Phổ biến"; +"explore_nearby" = "Gần đây"; +"explore_no_results" = "Không tìm thấy kết quả"; +"explore_try_different" = "Thử từ khóa khác"; + +// MARK: - Profile +"profile_edit" = "Chỉnh sửa hồ sơ"; +"profile_notifications" = "Thông báo"; +"profile_security" = "Bảo mật"; +"profile_language" = "Ngôn ngữ"; +"profile_help" = "Trung tâm trợ giúp"; +"profile_about" = "Giới thiệu"; +"profile_logout" = "Đăng xuất"; +"profile_logout_title" = "Đăng xuất"; +"profile_logout_message" = "Bạn có chắc chắn muốn đăng xuất?"; +"profile_verified" = "Đã xác minh"; + +// MARK: - Welcome +"welcome_discover_title" = "Khám phá địa điểm tuyệt vời"; +"welcome_discover_desc" = "Tìm những nhà hàng, quán cà phê và điểm đến tốt nhất gần bạn"; +"welcome_rewards_title" = "Tích điểm thưởng"; +"welcome_rewards_desc" = "Tích điểm mỗi lần ghé thăm và đổi ưu đãi độc quyền"; +"welcome_community_title" = "Tham gia cộng đồng"; +"welcome_community_desc" = "Kết nối với mọi người và chia sẻ trải nghiệm của bạn"; +"welcome_get_started" = "Bắt đầu"; + +// MARK: - Auth +"auth_login" = "Đăng nhập"; +"auth_register" = "Đăng ký"; +"auth_email" = "Email"; +"auth_password" = "Mật khẩu"; +"auth_forgot_password" = "Quên mật khẩu?"; + +// MARK: - Errors +"error_title" = "Lỗi"; +"error_network" = "Lỗi mạng. Vui lòng kiểm tra kết nối."; +"error_server" = "Lỗi máy chủ. Vui lòng thử lại sau."; +"error_unknown" = "Đã xảy ra lỗi không mong muốn."; diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Services/APIService.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Services/APIService.swift new file mode 100644 index 00000000..c6624fc6 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Services/APIService.swift @@ -0,0 +1,235 @@ +// +// APIService.swift +// AppClientBaseSwift +// +// HTTP client service using URLSession +// Dịch vụ HTTP client sử dụng URLSession +// + +import Foundation + +// MARK: - API Error +// Lỗi API + +/// API error types +/// Các loại lỗi API +enum APIError: Error, LocalizedError { + case invalidURL + case noData + case decodingError(Error) + case networkError(Error) + case serverError(statusCode: Int, message: String?) + case unauthorized + case forbidden + case notFound + case rateLimited + case unknown + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL / URL không hợp lệ" + case .noData: + return "No data received / Không nhận được dữ liệu" + case .decodingError(let error): + return "Decoding error: \(error.localizedDescription)" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .serverError(let code, let message): + return "Server error (\(code)): \(message ?? "Unknown")" + case .unauthorized: + return "Unauthorized / Chưa xác thực" + case .forbidden: + return "Access forbidden / Truy cập bị từ chối" + case .notFound: + return "Resource not found / Không tìm thấy tài nguyên" + case .rateLimited: + return "Rate limited / Giới hạn request" + case .unknown: + return "Unknown error / Lỗi không xác định" + } + } +} + +// MARK: - HTTP Method +// Phương thức HTTP + +/// HTTP request methods +/// Các phương thức request HTTP +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" +} + +// MARK: - API Service Protocol +// Protocol dịch vụ API + +/// API service protocol for dependency injection +/// Protocol dịch vụ API cho dependency injection +protocol APIServiceProtocol { + func request( + endpoint: String, + method: HTTPMethod, + body: Encodable?, + headers: [String: String]? + ) async throws -> T +} + +// MARK: - API Service +// Dịch vụ API + +/// Main API service for network requests +/// Dịch vụ API chính cho các request network +final class APIService: APIServiceProtocol { + + // MARK: - Properties + + /// Shared singleton instance + /// Instance singleton dùng chung + static let shared = APIService() + + /// URLSession for requests + /// URLSession cho các request + private let session: URLSession + + /// JSON encoder + /// Bộ mã hóa JSON + private let encoder: JSONEncoder + + /// JSON decoder + /// Bộ giải mã JSON + private let decoder: JSONDecoder + + // MARK: - Init + + /// Initialize API service + /// Khởi tạo dịch vụ API + /// - Parameter session: URLSession instance / Instance URLSession + init(session: URLSession = .shared) { + self.session = session + + self.encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 + + self.decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + } + + // MARK: - Public Methods + + /// Perform network request + /// Thực hiện request network + /// - Parameters: + /// - endpoint: API endpoint path / Đường dẫn endpoint API + /// - method: HTTP method / Phương thức HTTP + /// - body: Request body / Body request + /// - headers: Additional headers / Headers bổ sung + /// - Returns: Decoded response / Response đã giải mã + func request( + endpoint: String, + method: HTTPMethod = .get, + body: Encodable? = nil, + headers: [String: String]? = nil + ) async throws -> T { + + // Build URL + // Xây dựng URL + guard let url = URL(string: APIConfig.baseURL + APIConfig.apiVersion + endpoint) else { + throw APIError.invalidURL + } + + // Create request + // Tạo request + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.timeoutInterval = APIConfig.timeout + + // Set default headers + // Đặt headers mặc định + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + // Add auth token if available + // Thêm token xác thực nếu có + if let token = AuthManager.shared.accessToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + // Add custom headers + // Thêm headers tùy chỉnh + headers?.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + + // Set body + // Đặt body + if let body = body { + request.httpBody = try encoder.encode(body) + } + + // Perform request + // Thực hiện request + let (data, response) = try await session.data(for: request) + + // Handle response + // Xử lý response + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.unknown + } + + // Check status code + // Kiểm tra status code + switch httpResponse.statusCode { + case 200...299: + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error) + } + case 401: + await AuthManager.shared.handleUnauthorized() + throw APIError.unauthorized + case 403: + throw APIError.forbidden + case 404: + throw APIError.notFound + case 429: + throw APIError.rateLimited + default: + let message = String(data: data, encoding: .utf8) + throw APIError.serverError(statusCode: httpResponse.statusCode, message: message) + } + } + + // MARK: - Convenience Methods + // Các phương thức tiện ích + + /// GET request + /// Request GET + func get(endpoint: String) async throws -> T { + try await request(endpoint: endpoint, method: .get, body: nil as String?, headers: nil) + } + + /// POST request + /// Request POST + func post(endpoint: String, body: B) async throws -> T { + try await request(endpoint: endpoint, method: .post, body: body, headers: nil) + } + + /// PUT request + /// Request PUT + func put(endpoint: String, body: B) async throws -> T { + try await request(endpoint: endpoint, method: .put, body: body, headers: nil) + } + + /// DELETE request + /// Request DELETE + func delete(endpoint: String) async throws -> T { + try await request(endpoint: endpoint, method: .delete, body: nil as String?, headers: nil) + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Services/AuthManager.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Services/AuthManager.swift new file mode 100644 index 00000000..c9ed56d2 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Services/AuthManager.swift @@ -0,0 +1,333 @@ +// +// AuthManager.swift +// AppClientBaseSwift +// +// Authentication state management +// Quản lý trạng thái xác thực +// + +import Foundation +import Security +import Combine + +// MARK: - Auth State +// Trạng thái xác thực + +/// Authentication state enumeration +/// Enum trạng thái xác thực +enum AuthState: Equatable { + case unknown + case unauthenticated + case authenticated(User) + + var isAuthenticated: Bool { + if case .authenticated = self { + return true + } + return false + } + + var user: User? { + if case .authenticated(let user) = self { + return user + } + return nil + } +} + +// MARK: - Auth Manager +// Quản lý xác thực + +/// Main authentication manager +/// Quản lý xác thực chính +final class AuthManager: ObservableObject { + + // MARK: - Properties + + /// Shared singleton instance + /// Instance singleton dùng chung + @MainActor static let shared = AuthManager() + + /// Current authentication state + /// Trạng thái xác thực hiện tại + @MainActor @Published private(set) var authState: AuthState = .unknown + + /// Access token from Keychain + /// Access token từ Keychain + @MainActor var accessToken: String? { + KeychainHelper.read(key: StorageKeys.accessToken) + } + + /// Refresh token from Keychain + /// Refresh token từ Keychain + @MainActor var refreshToken: String? { + KeychainHelper.read(key: StorageKeys.refreshToken) + } + + /// Whether user is currently authenticated + /// Người dùng có đang xác thực không + @MainActor var isAuthenticated: Bool { + authState.isAuthenticated + } + + /// Current authenticated user + /// Người dùng đã xác thực hiện tại + @MainActor var currentUser: User? { + authState.user + } + + // MARK: - Init + + private init() {} + + // MARK: - Public Methods + + /// Set authenticated state with user (for mock login) + /// Đặt trạng thái authenticated với user (cho mock login) + @MainActor func setAuthenticated(user: User) { + authState = .authenticated(user) + } + + // MARK: - Public Methods + + /// Initialize auth state on app launch + /// Khởi tạo trạng thái xác thực khi app khởi động + @MainActor func initialize() async { + guard accessToken != nil else { + authState = .unauthenticated + return + } + + // Try to load cached user + // Thử tải user đã cache + if let userData = UserDefaults.standard.data(forKey: StorageKeys.userData), + let user = try? JSONDecoder().decode(User.self, from: userData) + { + authState = .authenticated(user) + } else { + // Fetch user from API + // Lấy user từ API + await refreshCurrentUser() + } + } + + /// Login with email and password + /// Đăng nhập với email và mật khẩu + /// - Parameters: + /// - email: User email / Email người dùng + /// - password: User password / Mật khẩu người dùng + @MainActor func login(email: String, password: String) async throws { + struct LoginRequest: Encodable { + let email: String + let password: String + } + + struct LoginResponse: Decodable { + let accessToken: String + let refreshToken: String + let user: User + } + + let request = LoginRequest(email: email, password: password) + let response: LoginResponse = try await APIService.shared.post( + endpoint: "/auth/login", body: request) + + // Save tokens to Keychain + // Lưu tokens vào Keychain + KeychainHelper.save(key: StorageKeys.accessToken, value: response.accessToken) + KeychainHelper.save(key: StorageKeys.refreshToken, value: response.refreshToken) + + // Cache user data + // Cache dữ liệu user + if let userData = try? JSONEncoder().encode(response.user) { + UserDefaults.standard.set(userData, forKey: StorageKeys.userData) + } + + authState = .authenticated(response.user) + } + + /// Register new user + /// Đăng ký người dùng mới + /// - Parameters: + /// - name: User name / Tên người dùng + /// - email: User email / Email người dùng + /// - password: User password / Mật khẩu người dùng + @MainActor func register(name: String, email: String, password: String) async throws { + struct RegisterRequest: Encodable { + let name: String + let email: String + let password: String + } + + struct RegisterResponse: Decodable { + let accessToken: String + let refreshToken: String + let user: User + } + + let request = RegisterRequest(name: name, email: email, password: password) + let response: RegisterResponse = try await APIService.shared.post( + endpoint: "/auth/register", body: request) + + // Save tokens to Keychain + // Lưu tokens vào Keychain + KeychainHelper.save(key: StorageKeys.accessToken, value: response.accessToken) + KeychainHelper.save(key: StorageKeys.refreshToken, value: response.refreshToken) + + // Cache user data + // Cache dữ liệu user + if let userData = try? JSONEncoder().encode(response.user) { + UserDefaults.standard.set(userData, forKey: StorageKeys.userData) + } + + authState = .authenticated(response.user) + } + + /// Logout current user + /// Đăng xuất người dùng hiện tại + @MainActor func logout() { + // Clear tokens from Keychain + // Xóa tokens khỏi Keychain + KeychainHelper.delete(key: StorageKeys.accessToken) + KeychainHelper.delete(key: StorageKeys.refreshToken) + + // Clear cached user data + // Xóa dữ liệu user đã cache + UserDefaults.standard.removeObject(forKey: StorageKeys.userData) + + authState = .unauthenticated + } + + /// Handle unauthorized response (401) + /// Xử lý response unauthorized (401) + @MainActor func handleUnauthorized() { + // Try refresh token first, then logout + // Thử refresh token trước, sau đó logout + Task { + let success = await refreshTokens() + if !success { + logout() + } + } + } + + /// Refresh current user from API + /// Làm mới thông tin user từ API + @MainActor func refreshCurrentUser() async { + do { + let user: User = try await APIService.shared.get(endpoint: "/auth/me") + + // Cache user data + // Cache dữ liệu user + if let userData = try? JSONEncoder().encode(user) { + UserDefaults.standard.set(userData, forKey: StorageKeys.userData) + } + + authState = .authenticated(user) + } catch { + print("Failed to refresh user: \(error)") + authState = .unauthenticated + } + } + + // MARK: - Private Methods + + /// Refresh access token using refresh token + /// Làm mới access token sử dụng refresh token + /// - Returns: Whether refresh was successful / Refresh có thành công không + @MainActor private func refreshTokens() async -> Bool { + guard let refreshToken = refreshToken else { + return false + } + + struct RefreshRequest: Encodable { + let refreshToken: String + } + + struct RefreshResponse: Decodable { + let accessToken: String + let refreshToken: String + } + + do { + let request = RefreshRequest(refreshToken: refreshToken) + let response: RefreshResponse = try await APIService.shared.post( + endpoint: "/auth/refresh", body: request) + + // Save new tokens + // Lưu tokens mới + KeychainHelper.save(key: StorageKeys.accessToken, value: response.accessToken) + KeychainHelper.save(key: StorageKeys.refreshToken, value: response.refreshToken) + + return true + } catch { + print("Token refresh failed: \(error)") + return false + } + } +} + +// MARK: - Keychain Helper +// Helper Keychain + +/// Helper for Keychain operations +/// Helper cho các thao tác Keychain +enum KeychainHelper { + + /// Save value to Keychain + /// Lưu giá trị vào Keychain + static func save(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: AppConstants.keychainService, + kSecAttrAccount as String: key, + kSecValueData as String: data, + ] + + // Delete existing + // Xóa existing + SecItemDelete(query as CFDictionary) + + // Add new + // Thêm mới + SecItemAdd(query as CFDictionary, nil) + } + + /// Read value from Keychain + /// Đọc giá trị từ Keychain + static func read(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: AppConstants.keychainService, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + guard status == errSecSuccess, + let data = dataTypeRef as? Data, + let value = String(data: data, encoding: .utf8) + else { + return nil + } + + return value + } + + /// Delete value from Keychain + /// Xóa giá trị khỏi Keychain + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: AppConstants.keychainService, + kSecAttrAccount as String: key, + ] + + SecItemDelete(query as CFDictionary) + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/AuthViewModel.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/AuthViewModel.swift new file mode 100644 index 00000000..04cefaa5 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/AuthViewModel.swift @@ -0,0 +1,280 @@ +// +// AuthViewModel.swift +// AppClientBaseSwift +// +// ViewModel for authentication UI state management +// ViewModel quản lý state UI xác thực +// + +import SwiftUI +import Combine + +// MARK: - Auth Screen +// Màn hình Auth + +/// Available auth screens +/// Các màn hình auth có sẵn +enum AuthScreen { + case login + case register + case forgotPassword +} + +// MARK: - Auth View Model +// ViewModel Auth + +/// ViewModel for authentication UI +/// ViewModel cho UI xác thực +@MainActor +final class AuthViewModel: ObservableObject { + + // MARK: - Published Properties + + /// Current auth screen + /// Màn hình auth hiện tại + @Published var currentScreen: AuthScreen = .login + + /// Login email + /// Email đăng nhập + @Published var loginEmail = "" + + /// Login password + /// Mật khẩu đăng nhập + @Published var loginPassword = "" + + /// Register name + /// Tên đăng ký + @Published var registerName = "" + + /// Register email + /// Email đăng ký + @Published var registerEmail = "" + + /// Register password + /// Mật khẩu đăng ký + @Published var registerPassword = "" + + /// Register confirm password + /// Xác nhận mật khẩu đăng ký + @Published var registerConfirmPassword = "" + + /// Forgot password email + /// Email quên mật khẩu + @Published var forgotEmail = "" + + /// Is loading state + /// Trạng thái đang tải + @Published var isLoading = false + + /// Error message to display + /// Thông báo lỗi hiển thị + @Published var errorMessage: String? + + /// Success message to display + /// Thông báo thành công hiển thị + @Published var successMessage: String? + + /// Agreed to terms + /// Đã đồng ý điều khoản + @Published var agreedToTerms = false + + // MARK: - Validation + + /// Validate login form + /// Kiểm tra form đăng nhập + var isLoginValid: Bool { + loginEmail.isValidEmail && !loginPassword.isEmpty + } + + /// Validate register form + /// Kiểm tra form đăng ký + var isRegisterValid: Bool { + !registerName.trimmed.isEmpty && + registerEmail.isValidEmail && + registerPassword.isValidPassword && + registerPassword == registerConfirmPassword && + agreedToTerms + } + + /// Validate forgot password form + /// Kiểm tra form quên mật khẩu + var isForgotPasswordValid: Bool { + forgotEmail.isValidEmail + } + + /// Password strength (0-4) + /// Độ mạnh mật khẩu (0-4) + var passwordStrength: Int { + var strength = 0 + let password = registerPassword + + if password.count >= 8 { strength += 1 } + if password.contains(where: { $0.isUppercase }) { strength += 1 } + if password.contains(where: { $0.isLowercase }) { strength += 1 } + if password.contains(where: { $0.isNumber }) { strength += 1 } + + return strength + } + + /// Password strength text + /// Text độ mạnh mật khẩu + var passwordStrengthText: String { + switch passwordStrength { + case 0: return "Rất yếu" + case 1: return "Yếu" + case 2: return "Trung bình" + case 3: return "Mạnh" + case 4: return "Rất mạnh" + default: return "" + } + } + + /// Password strength color + /// Màu độ mạnh mật khẩu + var passwordStrengthColor: Color { + switch passwordStrength { + case 0: return .red + case 1: return .orange + case 2: return .yellow + case 3: return .green + case 4: return .blue + default: return .gray + } + } + + // MARK: - Actions + + // MARK: Mock Credentials (for testing) + // Thông tin mock để test + private let mockEmail = "admin@goodgo.com" + private let mockPassword = "123456" + + /// Perform login + /// Thực hiện đăng nhập + func login() async { + guard isLoginValid else { + errorMessage = "Vui lòng nhập email và mật khẩu hợp lệ" + return + } + + isLoading = true + errorMessage = nil + + // Mock login for testing + // Đăng nhập mock để test + if loginEmail.lowercased() == mockEmail && loginPassword == mockPassword { + // Simulate network delay + // Giả lập delay mạng + try? await Task.sleep(nanoseconds: 1_000_000_000) + + // Create mock user and authenticate + // Tạo mock user và xác thực + let mockUser = User( + id: "admin-001", + email: mockEmail, + fullName: "Admin GoodGo", + avatarURL: nil, + phoneNumber: "+84901234567", + createdAt: Date(), + isEmailVerified: true, + isPhoneVerified: true + ) + + // Save mock tokens and user + // Lưu mock tokens và user + KeychainHelper.save(key: StorageKeys.accessToken, value: "mock_access_token_\(UUID().uuidString)") + KeychainHelper.save(key: StorageKeys.refreshToken, value: "mock_refresh_token_\(UUID().uuidString)") + + if let userData = try? JSONEncoder().encode(mockUser) { + UserDefaults.standard.set(userData, forKey: StorageKeys.userData) + } + + // Update auth state + // Cập nhật trạng thái auth + await MainActor.run { + AuthManager.shared.setAuthenticated(user: mockUser) + } + + isLoading = false + return + } + + // Real API login + // Đăng nhập API thật + do { + try await AuthManager.shared.login(email: loginEmail, password: loginPassword) + } catch { + errorMessage = "Đăng nhập thất bại: \(error.localizedDescription)" + } + + isLoading = false + } + + /// Perform registration + /// Thực hiện đăng ký + func register() async { + guard isRegisterValid else { + errorMessage = "Vui lòng kiểm tra lại thông tin đăng ký" + return + } + + isLoading = true + errorMessage = nil + + do { + try await AuthManager.shared.register( + name: registerName, + email: registerEmail, + password: registerPassword + ) + } catch { + errorMessage = "Đăng ký thất bại: \(error.localizedDescription)" + } + + isLoading = false + } + + /// Send forgot password email + /// Gửi email quên mật khẩu + func forgotPassword() async { + guard isForgotPasswordValid else { + errorMessage = "Vui lòng nhập email hợp lệ" + return + } + + isLoading = true + errorMessage = nil + + // Simulate API call + // Giả lập gọi API + try? await Task.sleep(nanoseconds: 1_500_000_000) + + successMessage = "Đã gửi link đặt lại mật khẩu đến \(forgotEmail)" + isLoading = false + } + + /// Navigate to screen + /// Điều hướng đến màn hình + func navigateTo(_ screen: AuthScreen) { + withAnimation(.easeInOut(duration: 0.3)) { + currentScreen = screen + errorMessage = nil + successMessage = nil + } + } + + /// Clear all fields + /// Xóa tất cả các field + func clearFields() { + loginEmail = "" + loginPassword = "" + registerName = "" + registerEmail = "" + registerPassword = "" + registerConfirmPassword = "" + forgotEmail = "" + agreedToTerms = false + errorMessage = nil + successMessage = nil + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/HomeViewModel.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/HomeViewModel.swift new file mode 100644 index 00000000..9559534e --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/HomeViewModel.swift @@ -0,0 +1,191 @@ +// +// HomeViewModel.swift +// AppClientBaseSwift +// +// ViewModel for Home screen with MVVM pattern +// ViewModel cho màn hình Home theo pattern MVVM +// + +import Combine +import SwiftUI + +// MARK: - Home Item Model +// Model item Home + +/// Item model for home screen display +/// Model item để hiển thị trên màn hình home +struct HomeItem: Identifiable, Equatable { + let id: String + let title: String + let subtitle: String + let imageUrl: String? + let category: String + + #if DEBUG + static let samples: [HomeItem] = [ + HomeItem( + id: "1", + title: "Khám phá địa điểm mới", + subtitle: "Tìm kiếm những trải nghiệm tuyệt vời", + imageUrl: nil, + category: "explore" + ), + HomeItem( + id: "2", + title: "Ưu đãi hôm nay", + subtitle: "Giảm đến 50% cho thành viên", + imageUrl: nil, + category: "promo" + ), + HomeItem( + id: "3", + title: "Điểm thưởng của bạn", + subtitle: "1,250 điểm có thể sử dụng", + imageUrl: nil, + category: "points" + ), + HomeItem( + id: "4", + title: "Đề xuất cho bạn", + subtitle: "Dựa trên sở thích cá nhân", + imageUrl: nil, + category: "recommend" + ), + ] + #endif +} + +// MARK: - Home ViewModel +// ViewModel Home + +/// ViewModel for Home screen +/// ViewModel cho màn hình Home +@MainActor +final class HomeViewModel: ObservableObject { + + // MARK: - Published Properties + + /// Loading state + /// Trạng thái loading + @Published var isLoading: Bool = false + + /// Refreshing state (pull-to-refresh) + /// Trạng thái refreshing (kéo để làm mới) + @Published var isRefreshing: Bool = false + + /// Error message if any + /// Thông báo lỗi nếu có + @Published var errorMessage: String? + + /// List of home items + /// Danh sách items trang home + @Published var items: [HomeItem] = [] + + /// Featured items for carousel + /// Items nổi bật cho carousel + @Published var featuredItems: [HomeItem] = [] + + /// Greeting message based on time + /// Lời chào dựa trên thời gian + @Published var greeting: String = "" + + // MARK: - Dependencies + + /// API service for network requests + /// Dịch vụ API cho các request network + private let apiService: APIServiceProtocol + + /// Auth manager for user info + /// Quản lý xác thực cho thông tin user + private let authManager: AuthManager + + // MARK: - Init + + /// Initialize ViewModel + /// Khởi tạo ViewModel + /// - Parameters: + /// - apiService: API service instance / Instance dịch vụ API + /// - authManager: Auth manager instance / Instance quản lý xác thực + init( + apiService: APIServiceProtocol = APIService.shared, + authManager: AuthManager = .shared + ) { + self.apiService = apiService + self.authManager = authManager + updateGreeting() + } + + // MARK: - Public Methods + + /// Load home data + /// Tải dữ liệu home + func loadData() async { + guard !isLoading else { return } + + isLoading = true + errorMessage = nil + + defer { isLoading = false } + + do { + // Simulate API call with mock data for now + // Mô phỏng gọi API với mock data tạm thời + try await Task.sleep(nanoseconds: 500_000_000) // 0.5s + + #if DEBUG + items = HomeItem.samples + featuredItems = Array(HomeItem.samples.prefix(2)) + #else + // Real API call + // Gọi API thật + // let response: HomeResponse = try await apiService.get(endpoint: "/home") + // items = response.items + // featuredItems = response.featured + #endif + + } catch { + errorMessage = error.localizedDescription + print("HomeViewModel.loadData error: \(error)") + } + } + + /// Refresh data (pull-to-refresh) + /// Làm mới dữ liệu (kéo để làm mới) + func refresh() async { + guard !isRefreshing else { return } + + isRefreshing = true + defer { isRefreshing = false } + + await loadData() + } + + /// Handle item tap + /// Xử lý khi tap item + /// - Parameter item: Tapped item / Item được tap + func handleItemTap(_ item: HomeItem) { + // Navigate or perform action based on item + // Điều hướng hoặc thực hiện action dựa trên item + print("Item tapped: \(item.title)") + } + + // MARK: - Private Methods + + /// Update greeting based on current time + /// Cập nhật lời chào dựa trên thời gian hiện tại + private func updateGreeting() { + let hour = Calendar.current.component(.hour, from: Date()) + let userName = authManager.currentUser?.firstName ?? "bạn" + + switch hour { + case 5..<12: + greeting = "Chào buổi sáng, \(userName)! ☀️" + case 12..<17: + greeting = "Chào buổi chiều, \(userName)! 🌤️" + case 17..<21: + greeting = "Chào buổi tối, \(userName)! 🌆" + default: + greeting = "Xin chào, \(userName)! 🌙" + } + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/ProfileViewModel.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..88f44951 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/ViewModels/ProfileViewModel.swift @@ -0,0 +1,221 @@ +// +// ProfileViewModel.swift +// AppClientBaseSwift +// +// ViewModel for Profile screen with MVVM pattern +// ViewModel cho màn hình Profile theo pattern MVVM +// + +import Combine +import SwiftUI + +// MARK: - Profile Menu Item +// Item menu Profile + +/// Profile menu item model +/// Model item menu profile +struct ProfileMenuItem: Identifiable, Equatable { + let id: String + let title: String + let subtitle: String? + let icon: String + let action: ProfileMenuAction + + static func == (lhs: ProfileMenuItem, rhs: ProfileMenuItem) -> Bool { + lhs.id == rhs.id + } +} + +/// Profile menu actions +/// Các action menu profile +enum ProfileMenuAction: String { + case editProfile + case notifications + case security + case language + case help + case about + case logout +} + +// MARK: - Profile ViewModel +// ViewModel Profile + +/// ViewModel for Profile screen +/// ViewModel cho màn hình Profile +@MainActor +final class ProfileViewModel: ObservableObject { + + // MARK: - Published Properties + + /// Current user + /// Người dùng hiện tại + @Published var user: User? + + /// Loading state + /// Trạng thái loading + @Published var isLoading: Bool = false + + /// Error message if any + /// Thông báo lỗi nếu có + @Published var errorMessage: String? + + /// Show logout confirmation alert + /// Hiển thị alert xác nhận đăng xuất + @Published var showLogoutAlert: Bool = false + + /// Menu items for profile screen + /// Các item menu cho màn hình profile + @Published var menuItems: [ProfileMenuItem] = [] + + // MARK: - Dependencies + + /// Auth manager for user operations + /// Quản lý xác thực cho các thao tác user + private let authManager: AuthManager + + // MARK: - Init + + /// Initialize ViewModel + /// Khởi tạo ViewModel + /// - Parameter authManager: Auth manager instance / Instance quản lý xác thực + init(authManager: AuthManager = .shared) { + self.authManager = authManager + loadUser() + setupMenuItems() + } + + // MARK: - Public Methods + + /// Load current user data + /// Tải dữ liệu người dùng hiện tại + func loadUser() { + user = authManager.currentUser + + #if DEBUG + if user == nil { + user = User.sample + } + #endif + } + + /// Refresh user data from API + /// Làm mới dữ liệu user từ API + func refreshUser() async { + guard !isLoading else { return } + + isLoading = true + errorMessage = nil + + defer { isLoading = false } + + await authManager.refreshCurrentUser() + loadUser() + } + + /// Handle menu item tap + /// Xử lý khi tap menu item + /// - Parameter item: Tapped menu item / Item menu được tap + func handleMenuTap(_ item: ProfileMenuItem) { + switch item.action { + case .editProfile: + // Navigate to edit profile + // Điều hướng đến chỉnh sửa profile + print("Navigate to edit profile") + + case .notifications: + // Navigate to notification settings + // Điều hướng đến cài đặt thông báo + print("Navigate to notifications") + + case .security: + // Navigate to security settings + // Điều hướng đến cài đặt bảo mật + print("Navigate to security") + + case .language: + // Navigate to language settings + // Điều hướng đến cài đặt ngôn ngữ + print("Navigate to language") + + case .help: + // Navigate to help center + // Điều hướng đến trung tâm trợ giúp + print("Navigate to help") + + case .about: + // Navigate to about page + // Điều hướng đến trang giới thiệu + print("Navigate to about") + + case .logout: + // Show logout confirmation + // Hiển thị xác nhận đăng xuất + showLogoutAlert = true + } + } + + /// Confirm logout action + /// Xác nhận action đăng xuất + func confirmLogout() { + authManager.logout() + } + + // MARK: - Private Methods + + /// Setup menu items + /// Thiết lập các item menu + private func setupMenuItems() { + menuItems = [ + ProfileMenuItem( + id: "edit_profile", + title: "profile_edit".localized, + subtitle: nil, + icon: "person.circle", + action: .editProfile + ), + ProfileMenuItem( + id: "notifications", + title: "profile_notifications".localized, + subtitle: nil, + icon: "bell", + action: .notifications + ), + ProfileMenuItem( + id: "security", + title: "profile_security".localized, + subtitle: nil, + icon: "lock.shield", + action: .security + ), + ProfileMenuItem( + id: "language", + title: "profile_language".localized, + subtitle: "Tiếng Việt", + icon: "globe", + action: .language + ), + ProfileMenuItem( + id: "help", + title: "profile_help".localized, + subtitle: nil, + icon: "questionmark.circle", + action: .help + ), + ProfileMenuItem( + id: "about", + title: "profile_about".localized, + subtitle: "v1.0.0", + icon: "info.circle", + action: .about + ), + ProfileMenuItem( + id: "logout", + title: "profile_logout".localized, + subtitle: nil, + icon: "rectangle.portrait.and.arrow.right", + action: .logout + ), + ] + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/AuthContainerView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/AuthContainerView.swift new file mode 100644 index 00000000..eb19037a --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/AuthContainerView.swift @@ -0,0 +1,76 @@ +// +// AuthContainerView.swift +// AppClientBaseSwift +// +// Container view for authentication flows +// View container cho các luồng xác thực +// + +import SwiftUI +import Combine + +// MARK: - Auth Container View +// View Container Auth + +/// Container that manages navigation between auth screens +/// Container quản lý điều hướng giữa các màn hình auth +struct AuthContainerView: View { + + // MARK: - Properties + + /// Auth view model + /// ViewModel Auth + @StateObject private var viewModel = AuthViewModel() + + // MARK: - Body + + var body: some View { + ZStack { + // Background gradient + // Gradient nền + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.05, blue: 0.12), + Color(red: 0.1, green: 0.08, blue: 0.18) + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + // Content + // Nội dung + VStack(spacing: 0) { + // Current screen + // Màn hình hiện tại + switch viewModel.currentScreen { + case .login: + LoginView(viewModel: viewModel) + .transition(.asymmetric( + insertion: .move(edge: .leading), + removal: .move(edge: .trailing) + )) + case .register: + RegisterView(viewModel: viewModel) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + case .forgotPassword: + ForgotPasswordView(viewModel: viewModel) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } + } + } + .loadingOverlay(viewModel.isLoading) + } +} + +// MARK: - Preview + +#Preview { + AuthContainerView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/ForgotPasswordView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/ForgotPasswordView.swift new file mode 100644 index 00000000..dd0e4845 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/ForgotPasswordView.swift @@ -0,0 +1,238 @@ +// +// ForgotPasswordView.swift +// AppClientBaseSwift +// +// Forgot password screen view +// View màn hình quên mật khẩu +// + +import SwiftUI +import Combine + +// MARK: - Forgot Password View +// View Quên mật khẩu + +/// Forgot password screen with email reset +/// Màn hình quên mật khẩu với reset email +struct ForgotPasswordView: View { + + // MARK: - Properties + + /// Auth view model + /// ViewModel Auth + @ObservedObject var viewModel: AuthViewModel + + /// Focus state for email field + /// State focus cho field email + @FocusState private var isEmailFocused: Bool + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(spacing: DesignSystem.spacingXL) { + // Back button + Header + headerSection + .padding(.top, 20) + + // Illustration + illustrationSection + .padding(.top, DesignSystem.spacingLG) + + // Form + formSection + .padding(.horizontal, DesignSystem.spacingLG) + + // Reset button + resetButton + .padding(.horizontal, DesignSystem.spacingLG) + + // Back to login link + backSection + .padding(.top, DesignSystem.spacingMD) + + Spacer(minLength: 40) + } + } + .scrollDismissesKeyboard(.interactively) + .alert("Lỗi", isPresented: .init( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + .alert("Thành công", isPresented: .init( + get: { viewModel.successMessage != nil }, + set: { if !$0 { viewModel.successMessage = nil } } + )) { + Button("OK") { + viewModel.navigateTo(.login) + } + } message: { + Text(viewModel.successMessage ?? "") + } + } + + // MARK: - Subviews + + /// Header with back button and title + /// Header với nút back và tiêu đề + private var headerSection: some View { + VStack(spacing: DesignSystem.spacingLG) { + // Back button + HStack { + Button { + viewModel.navigateTo(.login) + } label: { + HStack(spacing: DesignSystem.spacingXS) { + Image(systemName: "chevron.left") + Text("Quay lại") + } + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + } + Spacer() + } + .padding(.horizontal, DesignSystem.spacingLG) + + // Title + VStack(spacing: DesignSystem.spacingXS) { + Text("Quên mật khẩu?") + .font(.title) + .fontWeight(.bold) + .foregroundStyle(.white) + + Text("Nhập email để nhận link đặt lại") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + + /// Illustration section + /// Phần minh họa + private var illustrationSection: some View { + ZStack { + // Background circle + Circle() + .fill( + RadialGradient( + colors: [ + Color.blue.opacity(0.2), + Color.clear + ], + center: .center, + startRadius: 20, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + + // Icon + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + Color(red: 0.2, green: 0.6, blue: 1.0), + Color(red: 0.4, green: 0.3, blue: 0.9) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 100, height: 100) + + Image(systemName: "envelope.open.fill") + .font(.system(size: 40)) + .foregroundStyle(.white) + } + .shadow(color: .blue.opacity(0.4), radius: 15, x: 0, y: 8) + } + } + + /// Form with email field + /// Form với field email + private var formSection: some View { + VStack(spacing: DesignSystem.spacingMD) { + AuthTextField( + icon: "envelope.fill", + placeholder: "Email", + text: $viewModel.forgotEmail, + keyboardType: .emailAddress + ) + .focused($isEmailFocused) + .submitLabel(.go) + .onSubmit { sendResetLink() } + + // Help text + Text("Chúng tôi sẽ gửi link đặt lại mật khẩu đến email của bạn.") + .font(.caption) + .foregroundStyle(.white.opacity(0.5)) + .multilineTextAlignment(.center) + } + } + + /// Reset password button + /// Nút đặt lại mật khẩu + private var resetButton: some View { + Button(action: sendResetLink) { + HStack(spacing: DesignSystem.spacingSM) { + Text("Gửi link đặt lại") + .fontWeight(.semibold) + + Image(systemName: "paperplane.fill") + } + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingMD) + .background( + LinearGradient( + colors: viewModel.isForgotPasswordValid + ? [Color(red: 0.2, green: 0.6, blue: 1.0), Color(red: 0.4, green: 0.3, blue: 0.9)] + : [Color.gray, Color.gray.opacity(0.8)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(DesignSystem.cornerRadiusMD) + .shadow(color: viewModel.isForgotPasswordValid ? .blue.opacity(0.4) : .clear, radius: 10, x: 0, y: 5) + } + .disabled(!viewModel.isForgotPasswordValid) + } + + /// Back to login section + /// Phần quay lại đăng nhập + private var backSection: some View { + Button { + viewModel.navigateTo(.login) + } label: { + HStack(spacing: DesignSystem.spacingXS) { + Image(systemName: "arrow.left") + Text("Quay lại đăng nhập") + } + .font(.subheadline) + .foregroundStyle(.blue) + } + } + + // MARK: - Methods + + /// Send reset link + /// Gửi link đặt lại + private func sendResetLink() { + isEmailFocused = false + Task { + await viewModel.forgotPassword() + } + } +} + +// MARK: - Preview + +#Preview { + AuthContainerView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/LoginView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/LoginView.swift new file mode 100644 index 00000000..049cd38f --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/LoginView.swift @@ -0,0 +1,364 @@ +// +// LoginView.swift +// AppClientBaseSwift +// +// Login screen view +// View màn hình đăng nhập +// + +import SwiftUI +import Combine + +// MARK: - Login View +// View Đăng nhập + +/// Login screen with email/password authentication +/// Màn hình đăng nhập với xác thực email/password +struct LoginView: View { + + // MARK: - Properties + + /// Auth view model + /// ViewModel Auth + @ObservedObject var viewModel: AuthViewModel + + /// Focus state for text fields + /// State focus cho các text field + @FocusState private var focusedField: Field? + + enum Field { + case email + case password + } + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(spacing: DesignSystem.spacingXL) { + // Header + headerSection + .padding(.top, 60) + + // Form + formSection + .padding(.horizontal, DesignSystem.spacingLG) + + // Login button + loginButton + .padding(.horizontal, DesignSystem.spacingLG) + + // Divider with text + dividerSection + .padding(.horizontal, DesignSystem.spacingLG) + + // Social login buttons + socialButtons + .padding(.horizontal, DesignSystem.spacingLG) + + // Register link + registerSection + .padding(.top, DesignSystem.spacingMD) + + Spacer(minLength: 40) + } + } + .scrollDismissesKeyboard(.interactively) + .alert("Lỗi", isPresented: .init( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + // MARK: - Subviews + + /// Header with logo and title + /// Header với logo và tiêu đề + private var headerSection: some View { + VStack(spacing: DesignSystem.spacingMD) { + // Logo + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + Color(red: 0.2, green: 0.6, blue: 1.0), + Color(red: 0.4, green: 0.3, blue: 0.9) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 70, height: 70) + + Image(systemName: "location.fill") + .font(.system(size: 30)) + .foregroundStyle(.white) + } + .shadow(color: .blue.opacity(0.4), radius: 15, x: 0, y: 8) + + // Title + VStack(spacing: DesignSystem.spacingXS) { + Text("Chào mừng trở lại!") + .font(.title) + .fontWeight(.bold) + .foregroundStyle(.white) + + Text("Đăng nhập để tiếp tục") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + + /// Form with email and password fields + /// Form với các field email và password + private var formSection: some View { + VStack(spacing: DesignSystem.spacingMD) { + // Email field + AuthTextField( + icon: "envelope.fill", + placeholder: "Email", + text: $viewModel.loginEmail, + keyboardType: .emailAddress + ) + .focused($focusedField, equals: .email) + .submitLabel(.next) + .onSubmit { focusedField = .password } + + // Password field + AuthSecureField( + icon: "lock.fill", + placeholder: "Mật khẩu", + text: $viewModel.loginPassword + ) + .focused($focusedField, equals: .password) + .submitLabel(.go) + .onSubmit { login() } + + // Forgot password link + HStack { + Spacer() + Button { + viewModel.navigateTo(.forgotPassword) + } label: { + Text("Quên mật khẩu?") + .font(.subheadline) + .foregroundStyle(.blue) + } + } + } + } + + /// Login button + /// Nút đăng nhập + private var loginButton: some View { + Button(action: login) { + HStack(spacing: DesignSystem.spacingSM) { + Text("Đăng nhập") + .fontWeight(.semibold) + + Image(systemName: "arrow.right") + } + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingMD) + .background( + LinearGradient( + colors: viewModel.isLoginValid + ? [Color(red: 0.2, green: 0.6, blue: 1.0), Color(red: 0.4, green: 0.3, blue: 0.9)] + : [Color.gray, Color.gray.opacity(0.8)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(DesignSystem.cornerRadiusMD) + .shadow(color: viewModel.isLoginValid ? .blue.opacity(0.4) : .clear, radius: 10, x: 0, y: 5) + } + .disabled(!viewModel.isLoginValid) + } + + /// Divider with "or" text + /// Divider với chữ "hoặc" + private var dividerSection: some View { + HStack(spacing: DesignSystem.spacingMD) { + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(height: 1) + + Text("hoặc") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.5)) + + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(height: 1) + } + } + + /// Social login buttons + /// Các nút đăng nhập mạng xã hội + private var socialButtons: some View { + HStack(spacing: DesignSystem.spacingMD) { + SocialLoginButton(icon: "apple.logo", color: .white) { + // Apple login + } + + SocialLoginButton(icon: "g.circle.fill", color: Color(red: 0.9, green: 0.3, blue: 0.2)) { + // Google login + } + + SocialLoginButton(icon: "f.circle.fill", color: Color(red: 0.2, green: 0.4, blue: 0.8)) { + // Facebook login + } + } + } + + /// Register link section + /// Phần link đăng ký + private var registerSection: some View { + HStack(spacing: DesignSystem.spacingXS) { + Text("Chưa có tài khoản?") + .foregroundStyle(.white.opacity(0.6)) + + Button { + viewModel.navigateTo(.register) + } label: { + Text("Đăng ký ngay") + .fontWeight(.semibold) + .foregroundStyle(.blue) + } + } + .font(.subheadline) + } + + // MARK: - Methods + + /// Perform login + /// Thực hiện đăng nhập + private func login() { + focusedField = nil + Task { + await viewModel.login() + } + } +} + +// MARK: - Auth Text Field +// Text Field Auth + +/// Custom text field for auth forms +/// Text field tùy chỉnh cho form auth +struct AuthTextField: View { + let icon: String + let placeholder: String + @Binding var text: String + var keyboardType: UIKeyboardType = .default + + var body: some View { + HStack(spacing: DesignSystem.spacingMD) { + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundStyle(.white.opacity(0.6)) + .frame(width: 24) + + TextField("", text: $text, prompt: Text(placeholder).foregroundStyle(.white.opacity(0.4))) + .foregroundStyle(.white) + .keyboardType(keyboardType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + .padding(.horizontal, DesignSystem.spacingMD) + .padding(.vertical, DesignSystem.spacingMD) + .background(Color.white.opacity(0.1)) + .cornerRadius(DesignSystem.cornerRadiusMD) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusMD) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } +} + +// MARK: - Auth Secure Field +// Secure Field Auth + +/// Custom secure field for auth forms +/// Secure field tùy chỉnh cho form auth +struct AuthSecureField: View { + let icon: String + let placeholder: String + @Binding var text: String + @State private var isVisible = false + + var body: some View { + HStack(spacing: DesignSystem.spacingMD) { + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundStyle(.white.opacity(0.6)) + .frame(width: 24) + + Group { + if isVisible { + TextField("", text: $text, prompt: Text(placeholder).foregroundStyle(.white.opacity(0.4))) + } else { + SecureField("", text: $text, prompt: Text(placeholder).foregroundStyle(.white.opacity(0.4))) + } + } + .foregroundStyle(.white) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button { + isVisible.toggle() + } label: { + Image(systemName: isVisible ? "eye.slash.fill" : "eye.fill") + .foregroundStyle(.white.opacity(0.6)) + } + } + .padding(.horizontal, DesignSystem.spacingMD) + .padding(.vertical, DesignSystem.spacingMD) + .background(Color.white.opacity(0.1)) + .cornerRadius(DesignSystem.cornerRadiusMD) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusMD) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } +} + +// MARK: - Social Login Button +// Nút đăng nhập mạng xã hội + +/// Social login button component +/// Component nút đăng nhập mạng xã hội +struct SocialLoginButton: View { + let icon: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundStyle(color) + .frame(width: 56, height: 56) + .background(Color.white.opacity(0.1)) + .cornerRadius(DesignSystem.cornerRadiusMD) + .overlay( + RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusMD) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } + } +} + +// MARK: - Preview + +#Preview { + AuthContainerView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/RegisterView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/RegisterView.swift new file mode 100644 index 00000000..30bfd7ba --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Auth/RegisterView.swift @@ -0,0 +1,283 @@ +// +// RegisterView.swift +// AppClientBaseSwift +// +// Registration screen view +// View màn hình đăng ký +// + +import SwiftUI +import Combine + +// MARK: - Register View +// View Đăng ký + +/// Registration screen with form validation +/// Màn hình đăng ký với validation form +struct RegisterView: View { + + // MARK: - Properties + + /// Auth view model + /// ViewModel Auth + @ObservedObject var viewModel: AuthViewModel + + /// Focus state for text fields + /// State focus cho các text field + @FocusState private var focusedField: Field? + + enum Field { + case name + case email + case password + case confirmPassword + } + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(spacing: DesignSystem.spacingXL) { + // Back button + Header + headerSection + .padding(.top, 20) + + // Form + formSection + .padding(.horizontal, DesignSystem.spacingLG) + + // Register button + registerButton + .padding(.horizontal, DesignSystem.spacingLG) + + // Login link + loginSection + .padding(.top, DesignSystem.spacingMD) + + Spacer(minLength: 40) + } + } + .scrollDismissesKeyboard(.interactively) + .alert("Lỗi", isPresented: .init( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + // MARK: - Subviews + + /// Header with back button and title + /// Header với nút back và tiêu đề + private var headerSection: some View { + VStack(spacing: DesignSystem.spacingLG) { + // Back button + HStack { + Button { + viewModel.navigateTo(.login) + } label: { + HStack(spacing: DesignSystem.spacingXS) { + Image(systemName: "chevron.left") + Text("Quay lại") + } + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + } + Spacer() + } + .padding(.horizontal, DesignSystem.spacingLG) + + // Title + VStack(spacing: DesignSystem.spacingXS) { + Text("Tạo tài khoản") + .font(.title) + .fontWeight(.bold) + .foregroundStyle(.white) + + Text("Điền thông tin để đăng ký") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + + /// Form with registration fields + /// Form với các field đăng ký + private var formSection: some View { + VStack(spacing: DesignSystem.spacingMD) { + // Name field + AuthTextField( + icon: "person.fill", + placeholder: "Họ và tên", + text: $viewModel.registerName + ) + .focused($focusedField, equals: .name) + .submitLabel(.next) + .onSubmit { focusedField = .email } + + // Email field + AuthTextField( + icon: "envelope.fill", + placeholder: "Email", + text: $viewModel.registerEmail, + keyboardType: .emailAddress + ) + .focused($focusedField, equals: .email) + .submitLabel(.next) + .onSubmit { focusedField = .password } + + // Password field with strength indicator + VStack(alignment: .leading, spacing: DesignSystem.spacingSM) { + AuthSecureField( + icon: "lock.fill", + placeholder: "Mật khẩu", + text: $viewModel.registerPassword + ) + .focused($focusedField, equals: .password) + .submitLabel(.next) + .onSubmit { focusedField = .confirmPassword } + + // Password strength indicator + if !viewModel.registerPassword.isEmpty { + HStack(spacing: DesignSystem.spacingSM) { + ForEach(0..<4, id: \.self) { index in + RoundedRectangle(cornerRadius: 2) + .fill(index < viewModel.passwordStrength + ? viewModel.passwordStrengthColor + : Color.white.opacity(0.2)) + .frame(height: 4) + } + } + + Text(viewModel.passwordStrengthText) + .font(.caption) + .foregroundStyle(viewModel.passwordStrengthColor) + } + } + + // Confirm password field + AuthSecureField( + icon: "lock.fill", + placeholder: "Xác nhận mật khẩu", + text: $viewModel.registerConfirmPassword + ) + .focused($focusedField, equals: .confirmPassword) + .submitLabel(.go) + .onSubmit { register() } + + // Password match indicator + if !viewModel.registerConfirmPassword.isEmpty { + HStack(spacing: DesignSystem.spacingXS) { + Image(systemName: viewModel.registerPassword == viewModel.registerConfirmPassword + ? "checkmark.circle.fill" + : "xmark.circle.fill") + Text(viewModel.registerPassword == viewModel.registerConfirmPassword + ? "Mật khẩu khớp" + : "Mật khẩu không khớp") + } + .font(.caption) + .foregroundStyle(viewModel.registerPassword == viewModel.registerConfirmPassword + ? .green + : .red) + } + + // Terms checkbox + termsSection + .padding(.top, DesignSystem.spacingSM) + } + } + + /// Terms and conditions checkbox + /// Checkbox điều khoản và điều kiện + private var termsSection: some View { + Button { + viewModel.agreedToTerms.toggle() + } label: { + HStack(alignment: .top, spacing: DesignSystem.spacingSM) { + Image(systemName: viewModel.agreedToTerms ? "checkmark.square.fill" : "square") + .foregroundStyle(viewModel.agreedToTerms ? .blue : .white.opacity(0.6)) + + Text("Tôi đồng ý với ") + .foregroundStyle(.white.opacity(0.6)) + + + Text("Điều khoản sử dụng") + .foregroundStyle(.blue) + + + Text(" và ") + .foregroundStyle(.white.opacity(0.6)) + + + Text("Chính sách bảo mật") + .foregroundStyle(.blue) + } + .font(.subheadline) + .multilineTextAlignment(.leading) + } + } + + /// Register button + /// Nút đăng ký + private var registerButton: some View { + Button(action: register) { + HStack(spacing: DesignSystem.spacingSM) { + Text("Đăng ký") + .fontWeight(.semibold) + + Image(systemName: "arrow.right") + } + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingMD) + .background( + LinearGradient( + colors: viewModel.isRegisterValid + ? [Color(red: 0.2, green: 0.6, blue: 1.0), Color(red: 0.4, green: 0.3, blue: 0.9)] + : [Color.gray, Color.gray.opacity(0.8)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(DesignSystem.cornerRadiusMD) + .shadow(color: viewModel.isRegisterValid ? .blue.opacity(0.4) : .clear, radius: 10, x: 0, y: 5) + } + .disabled(!viewModel.isRegisterValid) + } + + /// Login link section + /// Phần link đăng nhập + private var loginSection: some View { + HStack(spacing: DesignSystem.spacingXS) { + Text("Đã có tài khoản?") + .foregroundStyle(.white.opacity(0.6)) + + Button { + viewModel.navigateTo(.login) + } label: { + Text("Đăng nhập") + .fontWeight(.semibold) + .foregroundStyle(.blue) + } + } + .font(.subheadline) + } + + // MARK: - Methods + + /// Perform registration + /// Thực hiện đăng ký + private func register() { + focusedField = nil + Task { + await viewModel.register() + } + } +} + +// MARK: - Preview + +#Preview { + AuthContainerView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/ExploreView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/ExploreView.swift new file mode 100644 index 00000000..06ce1f39 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/ExploreView.swift @@ -0,0 +1,309 @@ +// +// ExploreView.swift +// AppClientBaseSwift +// +// Explore/Search screen view +// View màn hình Khám phá/Tìm kiếm +// + +import SwiftUI + +// MARK: - Explore View +// View Khám phá + +/// Explore tab main view +/// View chính của tab Khám phá +struct ExploreView: View { + + // MARK: - Properties + + /// Search text binding + /// Binding text tìm kiếm + @State private var searchText: String = "" + + /// Search results (placeholder) + /// Kết quả tìm kiếm (placeholder) + @State private var searchResults: [String] = [] + + /// Selected category filter + /// Filter category đã chọn + @State private var selectedCategory: String? + + /// Available categories + /// Các category có sẵn + private let categories = [ + ("all", "Tất cả", "square.grid.2x2"), + ("food", "Ẩm thực", "fork.knife"), + ("coffee", "Cà phê", "cup.and.saucer"), + ("shopping", "Mua sắm", "bag"), + ("entertainment", "Giải trí", "sparkles"), + ("travel", "Du lịch", "airplane"), + ] + + // MARK: - Body + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: DesignSystem.spacingLG) { + // Category filter + // Filter category + categoryFilter + + // Content based on search state + // Nội dung dựa trên trạng thái tìm kiếm + if searchText.isEmpty { + // Show browse content + // Hiển thị nội dung browse + browseContent + } else { + // Show search results + // Hiển thị kết quả tìm kiếm + searchResultsContent + } + } + .padding(.horizontal, DesignSystem.spacingMD) + } + .navigationTitle("tab_explore".localized) + .searchable(text: $searchText, prompt: "explore_search_placeholder".localized) + } + } + + // MARK: - Subviews + + /// Category filter horizontal scroll + /// Scroll ngang filter category + private var categoryFilter: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: DesignSystem.spacingSM) { + ForEach(categories, id: \.0) { category in + CategoryChip( + title: category.1, + icon: category.2, + isSelected: selectedCategory == category.0 + || (selectedCategory == nil && category.0 == "all") + ) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + selectedCategory = category.0 == "all" ? nil : category.0 + } + } + } + } + .padding(.vertical, DesignSystem.spacingXS) + } + } + + /// Browse content when not searching + /// Nội dung browse khi không tìm kiếm + private var browseContent: some View { + VStack(alignment: .leading, spacing: DesignSystem.spacingLG) { + // Popular section + // Phần phổ biến + VStack(alignment: .leading, spacing: DesignSystem.spacingSM) { + Text("explore_popular".localized) + .font(.headline) + + LazyVGrid( + columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + ], spacing: DesignSystem.spacingMD + ) { + ForEach(0..<6, id: \.self) { index in + ExploreCard(index: index) + } + } + } + + // Nearby section + // Phần gần đây + VStack(alignment: .leading, spacing: DesignSystem.spacingSM) { + HStack { + Text("explore_nearby".localized) + .font(.headline) + + Spacer() + + Button("common_see_all".localized) { + // Show all nearby + } + .font(.subheadline) + } + + VStack(spacing: DesignSystem.spacingSM) { + ForEach(0..<3, id: \.self) { index in + NearbyRow(index: index) + } + } + } + } + } + + /// Search results content + /// Nội dung kết quả tìm kiếm + private var searchResultsContent: some View { + VStack(spacing: DesignSystem.spacingMD) { + if searchResults.isEmpty && !searchText.isEmpty { + // Empty state + // Trạng thái rỗng + ContentUnavailableView( + "explore_no_results".localized, + systemImage: "magnifyingglass", + description: Text("explore_try_different".localized) + ) + .padding(.top, DesignSystem.spacingXL) + } else { + // Results list + // Danh sách kết quả + ForEach(searchResults, id: \.self) { result in + Text(result) + } + } + } + } +} + +// MARK: - Category Chip +// Chip Category + +/// Category filter chip +/// Chip filter category +struct CategoryChip: View { + let title: String + let icon: String + let isSelected: Bool + + var body: some View { + HStack(spacing: DesignSystem.spacingXS) { + Image(systemName: icon) + .font(.caption) + + Text(title) + .font(.subheadline) + } + .padding(.horizontal, DesignSystem.spacingMD) + .padding(.vertical, DesignSystem.spacingSM) + .background(isSelected ? Color.accentColor : Color.gray.opacity(0.15)) + .foregroundStyle(isSelected ? .white : .primary) + .cornerRadius(DesignSystem.cornerRadiusCircle) + } +} + +// MARK: - Explore Card +// Card Explore + +/// Explore grid card +/// Card grid explore +struct ExploreCard: View { + let index: Int + + private let placeholderColors: [Color] = [ + .blue, .purple, .pink, .orange, .green, .mint, + ] + + var body: some View { + VStack(alignment: .leading, spacing: DesignSystem.spacingSM) { + // Image placeholder + // Placeholder ảnh + RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusMD) + .fill( + LinearGradient( + colors: [ + placeholderColors[index % placeholderColors.count].opacity(0.8), + placeholderColors[(index + 1) % placeholderColors.count].opacity(0.8), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(height: 120) + .overlay( + Image(systemName: "photo") + .font(.title) + .foregroundStyle(.white.opacity(0.8)) + ) + + // Info + // Thông tin + VStack(alignment: .leading, spacing: 2) { + Text("Địa điểm \(index + 1)") + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + + Text("4.\(5 - index % 3)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: - Nearby Row +// Row Gần đây + +/// Nearby location row +/// Row địa điểm gần đây +struct NearbyRow: View { + let index: Int + + var body: some View { + HStack(spacing: DesignSystem.spacingMD) { + // Image + RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusSM) + .fill(Color.accentColor.opacity(0.1)) + .frame(width: 64, height: 64) + .overlay( + Image(systemName: "mappin.circle.fill") + .font(.title2) + .foregroundColor(.accentColor) + ) + + // Info + // Thông tin + VStack(alignment: .leading, spacing: DesignSystem.spacingXS) { + Text("Địa điểm gần bạn \(index + 1)") + .font(.subheadline) + .fontWeight(.medium) + + Text("Cách \(index + 1).5 km") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Distance badge + // Badge khoảng cách + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 2) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + Text("4.\(8 - index)") + .font(.caption) + } + + Text("12\(index) đánh giá") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(DesignSystem.spacingSM) + .background(Color.gray.opacity(0.1)) + .cornerRadius(DesignSystem.cornerRadiusMD) + } +} + +// MARK: - Preview + +#Preview { + ExploreView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/HomeView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/HomeView.swift new file mode 100644 index 00000000..32d8f8f6 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/HomeView.swift @@ -0,0 +1,247 @@ +// +// HomeView.swift +// AppClientBaseSwift +// +// Home screen view with MVVM binding +// View màn hình Home với MVVM binding +// + +import SwiftUI + +// MARK: - Home View +// View Home + +/// Home tab main view +/// View chính của tab Home +struct HomeView: View { + + // MARK: - Properties + + /// ViewModel for home screen + /// ViewModel cho màn hình home + @StateObject private var viewModel = HomeViewModel() + + // MARK: - Body + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: DesignSystem.spacingLG) { + // Greeting section + // Phần lời chào + greetingSection + + // Featured carousel + // Carousel nổi bật + if !viewModel.featuredItems.isEmpty { + featuredSection + } + + // Main content + // Nội dung chính + contentSection + } + .padding(.horizontal, DesignSystem.spacingMD) + .padding(.vertical, DesignSystem.spacingSM) + } + .refreshable { + await viewModel.refresh() + } + .navigationTitle("tab_home".localized) + .navigationBarTitleDisplayMode(.large) + .loadingOverlay(viewModel.isLoading) + .task { + await viewModel.loadData() + } + .alert("error_title".localized, isPresented: .constant(viewModel.errorMessage != nil)) { + Button("common_ok".localized) { + viewModel.errorMessage = nil + } + } message: { + Text(viewModel.errorMessage ?? "") + } + } + } + + // MARK: - Subviews + + /// Greeting section at top + /// Phần lời chào ở đầu + private var greetingSection: some View { + HStack { + VStack(alignment: .leading, spacing: DesignSystem.spacingXS) { + Text(viewModel.greeting) + .font(.title2) + .fontWeight(.bold) + + Text("home_subtitle".localized) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + // Notification button + // Nút thông báo + Button { + // Handle notification tap + // Xử lý tap thông báo + } label: { + Image(systemName: "bell.badge") + .font(.title2) + .foregroundStyle(.primary) + } + } + .padding(.vertical, DesignSystem.spacingSM) + } + + /// Featured items carousel section + /// Phần carousel items nổi bật + private var featuredSection: some View { + VStack(alignment: .leading, spacing: DesignSystem.spacingSM) { + Text("home_featured".localized) + .font(.headline) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: DesignSystem.spacingMD) { + ForEach(viewModel.featuredItems) { item in + FeaturedCard(item: item) + .onTapGesture { + viewModel.handleItemTap(item) + } + } + } + } + } + } + + /// Main content section with items + /// Phần nội dung chính với các items + private var contentSection: some View { + VStack(alignment: .leading, spacing: DesignSystem.spacingSM) { + Text("home_explore".localized) + .font(.headline) + + LazyVStack(spacing: DesignSystem.spacingMD) { + ForEach(viewModel.items) { item in + HomeItemRow(item: item) + .onTapGesture { + viewModel.handleItemTap(item) + } + } + } + } + } +} + +// MARK: - Featured Card +// Card nổi bật + +/// Featured item card view +/// View card item nổi bật +struct FeaturedCard: View { + let item: HomeItem + + var body: some View { + VStack(alignment: .leading, spacing: DesignSystem.spacingSM) { + // Placeholder image + // Ảnh placeholder + RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusMD) + .fill( + LinearGradient( + colors: [.blue.opacity(0.8), .purple.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 280, height: 140) + .overlay( + Image(systemName: "star.fill") + .font(.largeTitle) + .foregroundStyle(.white) + ) + + VStack(alignment: .leading, spacing: DesignSystem.spacingXS) { + Text(item.title) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + + Text(item.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .frame(width: 280, alignment: .leading) + } + } +} + +// MARK: - Home Item Row +// Row item Home + +/// Home item row view +/// View row item home +struct HomeItemRow: View { + let item: HomeItem + + var body: some View { + HStack(spacing: DesignSystem.spacingMD) { + // Icon + RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusSM) + .fill(Color.accentColor.opacity(0.1)) + .frame(width: 56, height: 56) + .overlay( + Image(systemName: iconForCategory(item.category)) + .font(.title2) + .foregroundStyle(Color.accentColor) + ) + + // Content + // Nội dung + VStack(alignment: .leading, spacing: DesignSystem.spacingXS) { + Text(item.title) + .font(.subheadline) + .fontWeight(.medium) + + Text(item.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Arrow + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(DesignSystem.spacingMD) + .cardStyle() + } + + /// Get SF Symbol icon for category + /// Lấy icon SF Symbol cho category + /// - Parameter category: Item category / Category của item + /// - Returns: SF Symbol name / Tên SF Symbol + private func iconForCategory(_ category: String) -> String { + switch category { + case "explore": + return "map" + case "promo": + return "tag.fill" + case "points": + return "star.circle.fill" + case "recommend": + return "heart.fill" + default: + return "circle.fill" + } + } +} + +// MARK: - Preview + +#Preview { + HomeView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/ProfileView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/ProfileView.swift new file mode 100644 index 00000000..deecfe42 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/ProfileView.swift @@ -0,0 +1,191 @@ +// +// ProfileView.swift +// AppClientBaseSwift +// +// Profile screen view with MVVM binding +// View màn hình Profile với MVVM binding +// + +import SwiftUI + +// MARK: - Profile View +// View Profile + +/// Profile tab main view +/// View chính của tab Profile +struct ProfileView: View { + + // MARK: - Properties + + /// ViewModel for profile screen + /// ViewModel cho màn hình profile + @StateObject private var viewModel = ProfileViewModel() + + // MARK: - Body + + var body: some View { + NavigationStack { + List { + // User info section + // Phần thông tin user + userInfoSection + + // Menu items section + // Phần menu items + menuSection + } + .listStyle(.insetGrouped) + .navigationTitle("tab_profile".localized) + .refreshable { + await viewModel.refreshUser() + } + .alert("profile_logout_title".localized, isPresented: $viewModel.showLogoutAlert) { + Button("common_cancel".localized, role: .cancel) {} + Button("profile_logout".localized, role: .destructive) { + viewModel.confirmLogout() + } + } message: { + Text("profile_logout_message".localized) + } + } + } + + // MARK: - Subviews + + /// User info header section + /// Phần header thông tin user + private var userInfoSection: some View { + Section { + HStack(spacing: DesignSystem.spacingMD) { + // Avatar + avatarView + + // User details + // Chi tiết user + VStack(alignment: .leading, spacing: DesignSystem.spacingXS) { + Text(viewModel.user?.name ?? "Guest User") + .font(.headline) + + Text(viewModel.user?.email ?? "guest@example.com") + .font(.subheadline) + .foregroundStyle(.secondary) + + // Verification badge + // Badge xác minh + if viewModel.user?.isEmailVerified == true { + HStack(spacing: 4) { + Image(systemName: "checkmark.seal.fill") + .font(.caption) + .foregroundStyle(.green) + + Text("profile_verified".localized) + .font(.caption) + .foregroundStyle(.green) + } + } + } + + Spacer() + } + .padding(.vertical, DesignSystem.spacingSM) + } + } + + /// User avatar view + /// View avatar user + private var avatarView: some View { + Group { + if let avatarUrl = viewModel.user?.avatarUrl, + let url = URL(string: avatarUrl) + { + // Async image + AsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + avatarPlaceholder + } + } else { + // Placeholder with initials + // Placeholder với chữ cái đầu + avatarPlaceholder + } + } + .frame(width: 72, height: 72) + .clipShape(Circle()) + } + + /// Avatar placeholder with initials + /// Placeholder avatar với chữ cái đầu + private var avatarPlaceholder: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [.blue, .purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + Text(viewModel.user?.initials ?? "GU") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(.white) + } + } + + /// Menu items section + /// Phần menu items + private var menuSection: some View { + Section { + ForEach(viewModel.menuItems) { item in + menuRow(item: item) + } + } + } + + /// Menu row view + /// View row menu + /// - Parameter item: Menu item / Item menu + private func menuRow(item: ProfileMenuItem) -> some View { + Button { + viewModel.handleMenuTap(item) + } label: { + HStack(spacing: DesignSystem.spacingMD) { + // Icon + Image(systemName: item.icon) + .font(.body) + .foregroundStyle(item.action == .logout ? .red : .accentColor) + .frame(width: 24) + + // Title + Text(item.title) + .foregroundStyle(item.action == .logout ? .red : .primary) + + Spacer() + + // Subtitle or arrow + // Subtitle hoặc mũi tên + if let subtitle = item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if item.action != .logout { + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } +} + +// MARK: - Preview + +#Preview { + ProfileView() +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/SplashView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/SplashView.swift new file mode 100644 index 00000000..86330ba2 --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/SplashView.swift @@ -0,0 +1,178 @@ +// +// SplashView.swift +// AppClientBaseSwift +// +// Splash screen with GoodGo logo animation +// Màn hình splash với animation logo GoodGo +// + +import SwiftUI + +// MARK: - Splash View +// View Splash + +/// Splash screen displayed when app launches +/// Màn hình splash hiển thị khi app khởi động +struct SplashView: View { + + // MARK: - Properties + + /// Content to show after splash + /// Nội dung hiển thị sau splash + let content: () -> Content + + /// Whether splash is complete + /// Splash đã hoàn thành chưa + @State private var isComplete = false + + /// Logo opacity for fade-in animation + /// Độ mờ logo cho animation fade-in + @State private var logoOpacity: Double = 0 + + /// Logo scale for scale animation + /// Tỷ lệ logo cho animation scale + @State private var logoScale: CGFloat = 0.6 + + /// Pulse animation state + /// Trạng thái animation pulse + @State private var isPulsing = false + + // MARK: - Init + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + // MARK: - Body + + var body: some View { + Group { + if isComplete { + content() + .transition(.opacity) + } else { + splashContent + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.5), value: isComplete) + } + + // MARK: - Subviews + + /// Splash screen content + /// Nội dung màn hình splash + private var splashContent: some View { + ZStack { + // Background gradient + // Gradient nền + LinearGradient( + colors: [ + Color(red: 0.1, green: 0.1, blue: 0.2), + Color(red: 0.05, green: 0.05, blue: 0.15) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + // Logo + Text + VStack(spacing: DesignSystem.spacingLG) { + // Logo icon with pulse effect + // Icon logo với hiệu ứng pulse + ZStack { + // Outer glow + Circle() + .fill( + RadialGradient( + colors: [ + Color.blue.opacity(isPulsing ? 0.4 : 0.2), + Color.clear + ], + center: .center, + startRadius: 40, + endRadius: isPulsing ? 100 : 80 + ) + ) + .frame(width: 180, height: 180) + + // Main logo circle + Circle() + .fill( + LinearGradient( + colors: [ + Color(red: 0.2, green: 0.6, blue: 1.0), + Color(red: 0.4, green: 0.3, blue: 0.9) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 100, height: 100) + .shadow(color: .blue.opacity(0.5), radius: 20, x: 0, y: 10) + + // Icon + Image(systemName: "location.fill") + .font(.system(size: 44, weight: .medium)) + .foregroundStyle(.white) + } + .scaleEffect(logoScale) + .opacity(logoOpacity) + + // App name + // Tên ứng dụng + VStack(spacing: DesignSystem.spacingSM) { + Text("GoodGo") + .font(.system(size: 42, weight: .bold, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [.white, .white.opacity(0.8)], + startPoint: .top, + endPoint: .bottom + ) + ) + + Text("Khám phá địa điểm tuyệt vời") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.6)) + } + .opacity(logoOpacity) + } + } + .onAppear { + startAnimations() + } + } + + // MARK: - Methods + + /// Start splash animations + /// Bắt đầu các animation splash + private func startAnimations() { + // Fade in + scale animation + withAnimation(.spring(response: 0.8, dampingFraction: 0.6, blendDuration: 0)) { + logoOpacity = 1 + logoScale = 1 + } + + // Pulse animation + withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true).delay(0.5)) { + isPulsing = true + } + + // Auto dismiss after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + withAnimation { + isComplete = true + } + } + } +} + +// MARK: - Preview + +#Preview { + SplashView { + Text("Main Content") + } +} diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/WelcomeView.swift b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/WelcomeView.swift new file mode 100644 index 00000000..ca5e918b --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift/Views/Screens/WelcomeView.swift @@ -0,0 +1,207 @@ +// +// WelcomeView.swift +// AppClientBaseSwift +// +// Onboarding/Welcome screen view +// View màn hình Onboarding/Welcome +// + +import SwiftUI + +// MARK: - Welcome View +// View Welcome + +/// Welcome/Onboarding screen +/// Màn hình Welcome/Onboarding +struct WelcomeView: View { + + // MARK: - Properties + + /// Current page index + /// Index trang hiện tại + @State private var currentPage = 0 + + /// Callback when onboarding completes + /// Callback khi onboarding hoàn thành + var onComplete: () -> Void = {} + + /// Onboarding pages data + /// Dữ liệu các trang onboarding + private let pages: [(icon: String, title: String, description: String)] = [ + ( + "map.fill", + "welcome_discover_title".localized, + "welcome_discover_desc".localized + ), + ( + "star.fill", + "welcome_rewards_title".localized, + "welcome_rewards_desc".localized + ), + ( + "person.2.fill", + "welcome_community_title".localized, + "welcome_community_desc".localized + ), + ] + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + // Page content + // Nội dung trang + TabView(selection: $currentPage) { + ForEach(0.. some View { + VStack(spacing: DesignSystem.spacingXL) { + Spacer() + + // Icon + ZStack { + Circle() + .fill( + LinearGradient( + colors: [.blue.opacity(0.8), .purple.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 140, height: 140) + + Image(systemName: pages[index].icon) + .font(.system(size: 60)) + .foregroundStyle(.white) + } + .shadow(color: .blue.opacity(0.3), radius: 20, x: 0, y: 10) + + // Text content + // Nội dung text + VStack(spacing: DesignSystem.spacingMD) { + Text(pages[index].title) + .font(.title) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text(pages[index].description) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(3) + } + .padding(.horizontal, DesignSystem.spacingLG) + + Spacer() + Spacer() + } + } + + /// Bottom section with indicators and buttons + /// Phần dưới với indicators và buttons + private var bottomSection: some View { + VStack(spacing: DesignSystem.spacingLG) { + // Page indicators + // Indicators trang + HStack(spacing: DesignSystem.spacingSM) { + ForEach(0.. 0 { + // Back button + // Nút quay lại + Button { + withAnimation { + currentPage -= 1 + } + } label: { + Text("common_back".localized) + .font(.headline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingMD) + } + } + + // Next/Get Started button + // Nút tiếp/Bắt đầu + Button { + if currentPage < pages.count - 1 { + withAnimation { + currentPage += 1 + } + } else { + onComplete() + } + } label: { + Text( + currentPage < pages.count - 1 + ? "common_next".localized : "welcome_get_started".localized + ) + .font(.headline) + .fontWeight(.semibold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, DesignSystem.spacingMD) + .background( + LinearGradient( + colors: [.blue, .purple], + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(DesignSystem.cornerRadiusMD) + } + } + + // Skip button + // Nút bỏ qua + if currentPage < pages.count - 1 { + Button { + onComplete() + } label: { + Text("common_skip".localized) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: - Preview + +#Preview { + WelcomeView() +}