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 00000000..d0aeae0a Binary files /dev/null and b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/WorkspaceSettings.xcsettings b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..723a561c --- /dev/null +++ b/apps/app-client-base-swift/AppClientBaseSwift/AppClientBaseSwift.xcodeproj/project.xcworkspace/xcuserdata/velikho.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,16 @@ + + + + + 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() +}