feat(app-client-base-swift): Add iOS Swift client app with auth UI
- Add SplashView, AuthContainerView with Login/Register/ForgotPassword - Add AuthViewModel with form validation - Add HomeView, ProfileView, ExploreView screens - Add APIService, AuthManager for networking - Add multi-language support (en/vi) - Add User model and extensions
This commit is contained in:
Submodule apps/app-client-base-swift/AppClientBaseSwift deleted from 9f3cfadcc3
@@ -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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
901363EB2F19DDEB0097E0A7 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
901363EA2F19DDEB0097E0A7 /* AppClientBaseSwift.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildLocationStyle</key>
|
||||
<string>UseAppPreferences</string>
|
||||
<key>CompilationCachingSetting</key>
|
||||
<string>Default</string>
|
||||
<key>CustomBuildLocationType</key>
|
||||
<string>RelativeToDerivedData</string>
|
||||
<key>DerivedDataLocationStyle</key>
|
||||
<string>Default</string>
|
||||
<key>ShowSharedSchemesAutomaticallyEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "901363E92F19DDEB0097E0A7"
|
||||
BuildableName = "AppClientBaseSwift.app"
|
||||
BlueprintName = "AppClientBaseSwift"
|
||||
ReferencedContainer = "container:AppClientBaseSwift.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "901363E92F19DDEB0097E0A7"
|
||||
BuildableName = "AppClientBaseSwift.app"
|
||||
BlueprintName = "AppClientBaseSwift"
|
||||
ReferencedContainer = "container:AppClientBaseSwift.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "901363E92F19DDEB0097E0A7"
|
||||
BuildableName = "AppClientBaseSwift.app"
|
||||
BlueprintName = "AppClientBaseSwift"
|
||||
ReferencedContainer = "container:AppClientBaseSwift.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>AppClientBaseSwift.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>901363E92F19DDEB0097E0A7</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Int>) -> 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..<endIndex])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// View+Extensions.swift
|
||||
// AppClientBaseSwift
|
||||
//
|
||||
// SwiftUI View extension helpers
|
||||
// Các extension helper cho SwiftUI View
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
// MARK: - View Modifiers
|
||||
// Các modifier cho View
|
||||
|
||||
extension View {
|
||||
|
||||
/// Apply card style with shadow
|
||||
/// Áp dụng style card với bóng đổ
|
||||
/// - Parameters:
|
||||
/// - cornerRadius: Corner radius / Bo góc
|
||||
/// - shadowRadius: Shadow radius / Bán kính bóng
|
||||
/// - Returns: Modified view / View đã được sửa đổi
|
||||
func cardStyle(cornerRadius: CGFloat = 12, shadowRadius: CGFloat = 4) -> 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`<Content: View>(_ 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<Destination: View>(@ViewBuilder destination: @escaping () -> Destination)
|
||||
-> some View
|
||||
{
|
||||
NavigationLink {
|
||||
destination()
|
||||
} label: {
|
||||
self
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.";
|
||||
@@ -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.";
|
||||
@@ -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<T: Decodable>(
|
||||
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<T: Decodable>(
|
||||
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<T: Decodable>(endpoint: String) async throws -> T {
|
||||
try await request(endpoint: endpoint, method: .get, body: nil as String?, headers: nil)
|
||||
}
|
||||
|
||||
/// POST request
|
||||
/// Request POST
|
||||
func post<T: Decodable, B: Encodable>(endpoint: String, body: B) async throws -> T {
|
||||
try await request(endpoint: endpoint, method: .post, body: body, headers: nil)
|
||||
}
|
||||
|
||||
/// PUT request
|
||||
/// Request PUT
|
||||
func put<T: Decodable, B: Encodable>(endpoint: String, body: B) async throws -> T {
|
||||
try await request(endpoint: endpoint, method: .put, body: body, headers: nil)
|
||||
}
|
||||
|
||||
/// DELETE request
|
||||
/// Request DELETE
|
||||
func delete<T: Decodable>(endpoint: String) async throws -> T {
|
||||
try await request(endpoint: endpoint, method: .delete, body: nil as String?, headers: nil)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)! 🌙"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<Content: View>: 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")
|
||||
}
|
||||
}
|
||||
@@ -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..<pages.count, id: \.self) { index in
|
||||
onboardingPage(index: index)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
|
||||
// Bottom section
|
||||
// Phần dưới
|
||||
bottomSection
|
||||
.padding(.horizontal, DesignSystem.spacingLG)
|
||||
.padding(.bottom, DesignSystem.spacingXL)
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
/// Onboarding page content
|
||||
/// Nội dung trang onboarding
|
||||
/// - Parameter index: Page index / Index trang
|
||||
private func onboardingPage(index: Int) -> 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..<pages.count, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == currentPage ? Color.accentColor : Color.gray.opacity(0.3))
|
||||
.frame(
|
||||
width: index == currentPage ? 10 : 8,
|
||||
height: index == currentPage ? 10 : 8
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.2), value: currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
// Buttons hành động
|
||||
HStack(spacing: DesignSystem.spacingMD) {
|
||||
if currentPage > 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()
|
||||
}
|
||||
Reference in New Issue
Block a user