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:
Ho Ngoc Hai
2026-01-16 10:35:19 +07:00
parent 6c7e984653
commit d1a7a791f9
33 changed files with 4823 additions and 1 deletions

Submodule apps/app-client-base-swift/AppClientBaseSwift deleted from 9f3cfadcc3

View File

@@ -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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,20 @@
//
// AppClientBaseSwiftApp.swift
// AppClientBaseSwift
//
// Main app entry point with splash screen
// Entry point chính ca app vi splash screen
//
import SwiftUI
@main
struct AppClientBaseSwiftApp: App {
var body: some Scene {
WindowGroup {
SplashView {
ContentView()
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,184 @@
//
// ContentView.swift
// AppClientBaseSwift
//
// Main tab container with TabView
// Container tab chính vi TabView
//
import SwiftUI
// MARK: - Content View
// View Content
/// Main content view with tab-based navigation
/// View content chính vi điu hưng tab
struct ContentView: View {
// MARK: - Properties
/// Currently selected tab
/// Tab đang đưc chn
@State private var selectedTab: Tab = .home
/// Auth manager for authentication state
/// Qun lý xác thc cho trng thái authentication
@StateObject private var authManager = AuthManager.shared
/// Whether to show welcome screen
/// Có hin 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ó sn
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 chn
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
// Hin th loading khi đang kim tra trng thái auth
loadingView
case .unauthenticated:
// Show auth flow
// Hin th lung auth
AuthContainerView()
case .authenticated:
// Show main app content
// Hin th ni 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 kim 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()
}

View File

@@ -0,0 +1,163 @@
//
// Constants.swift
// AppClientBaseSwift
//
// App-wide constants and configuration
// Hng s và cu hình toàn ng dng
//
import SwiftUI
// MARK: - API Configuration
// Cu hình API endpoints
/// API configuration constants
/// Các hng s cu hình API
enum APIConfig {
/// Base URL for API requests
/// URL gc cho các request API
static let baseURL = "https://api.goodgo.vn"
/// API version prefix
/// Tin t phiên bn API
static let apiVersion = "/api/v1"
/// Request timeout in seconds
/// Thi gian timeout request (giây)
static let timeout: TimeInterval = 30.0
}
// MARK: - App Constants
// Hng s ng dng
/// General app constants
/// Các hng s chung ca ng dng
enum AppConstants {
/// App name
/// Tên ng dng
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
/// Thi gian animation mc đ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 liu ngưi dùng
static let userData = "user_data"
/// Is first launch key
/// Khóa kim tra ln chy đu tiên
static let isFirstLaunch = "is_first_launch"
/// Selected language key
/// Khóa ngôn ng đã chn
static let selectedLanguage = "selected_language"
}
// MARK: - Design System
// H thng thiết kế
/// Design system constants for consistent UI
/// Hng s h thng thiết kế cho UI nht quán
enum DesignSystem {
// MARK: Spacing
// Khong 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 sc
/// App color palette
/// Bng màu ng dng
extension Color {
/// Primary brand color
/// Màu chính thương hiu
static let brandPrimary = Color("BrandPrimary", bundle: nil)
/// Secondary brand color
/// Màu ph thương hiu
static let brandSecondary = Color("BrandSecondary", bundle: nil)
/// Background color
/// Màu nn
static let appBackground = Color("AppBackground", bundle: nil)
/// Card background color
/// Màu nn card
static let cardBackground = Color("CardBackground", bundle: nil)
}

View File

@@ -0,0 +1,158 @@
//
// String+Extensions.swift
// AppClientBaseSwift
//
// String utility extensions
// Các extension tin ích cho String
//
import Foundation
// MARK: - Localization
// Đa ngôn ng
extension String {
/// Get localized string
/// Ly chui đã bn đa hóa
var localized: String {
NSLocalizedString(self, comment: "")
}
/// Get localized string with arguments
/// Ly chui đã bn đa hóa vi tham s
/// - Parameter arguments: Format arguments / Các tham s format
/// - Returns: Localized formatted string / Chui đã format và bn đa hóa
func localized(with arguments: CVarArg...) -> String {
String(format: localized, arguments: arguments)
}
}
// MARK: - Validation
// Kim tra hp l
extension String {
/// Check if string is valid email
/// Kim tra chui có phi email hp 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)
/// Kim tra chui có phi s đin thoi hp l (Vit 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)
/// Kim tra mt khu hp l (ti thiu 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)
/// Chui đã loi b khong trng đu/cui
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Check if string is empty or whitespace only
/// Kim tra chui rng hoc ch có khong trng
var isBlank: Bool {
trimmed.isEmpty
}
}
// MARK: - Formatting
// Đnh dng
extension String {
/// Mask email for privacy (e.g., "j***@example.com")
/// n email cho bo mt (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 đin thoi cho bo mt (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 dng tin t (Đng Vit Nam)
/// - Parameter amount: Amount to format / S tin cn đnh dng
/// - Returns: Formatted currency string / Chui tin t đã đnh dng
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
/// Chuyn đi thành URL nếu hp l
var asURL: URL? {
URL(string: self)
}
/// URL encoded string
/// Chui đã mã hóa URL
var urlEncoded: String {
addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? self
}
}
// MARK: - Subscript
// Truy cp ký t
extension String {
/// Safe subscript access by index
/// Truy cp 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 cp 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])
}
}

View File

@@ -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 dng style card vi bóng đ
/// - Parameters:
/// - cornerRadius: Corner radius / Bo góc
/// - shadowRadius: Shadow radius / Bán kính bóng
/// - Returns: Modified view / View đã đưc sa đ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 dng overlay loading
/// - Parameter isLoading: Loading state / Trng thái loading
/// - Returns: Modified view / View đã đưc sa đ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ó điu kin
/// - Parameters:
/// - condition: Condition to check / Điu kin kim tra
/// - transform: Transform to apply / Transform cn áp dng
/// - Returns: Modified view / View đã đưc sa đ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 dng hiu ng shimmer cho placeholder loading
/// - Parameter isActive: Whether shimmer is active / Shimmer có đang hot đng không
/// - Returns: Modified view / View đã đưc sa đ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 hiu ng Shimmer
/// Shimmer loading effect modifier
/// Modifier hiu 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 điu hưng
extension View {
/// Navigate to destination on tap
/// Điu 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)
}
}

View File

@@ -0,0 +1,129 @@
//
// User.swift
// AppClientBaseSwift
//
// User data model
// Model d liu 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 din cho user đã xác thc
struct User: Codable, Identifiable, Equatable {
/// Unique user identifier
/// Đnh danh ngưi dùng duy nht
let id: String
/// User email address
/// Đa ch email ngưi dùng
let email: String
/// User display name
/// Tên hin th ngưi dùng
let name: String
/// User avatar URL
/// URL nh đi din ngưi dùng
let avatarUrl: String?
/// User phone number
/// S đin thoi 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 to tài khon
let createdAt: Date?
/// Last update date
/// Ngày cp nht cui
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 ca User
extension User {
/// Get user initials for avatar placeholder
/// Ly 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)
/// Ly tên hin th (ch tên đu)
var firstName: String {
String(name.split(separator: " ").first ?? Substring(name))
}
/// Get masked email for privacy display
/// Ly email n đ hin th bo mt
var maskedEmail: String {
email.maskedEmail
}
/// Get masked phone for privacy display
/// Ly s đin thoi n đ hin th bo mt
var maskedPhone: String? {
phoneNumber?.maskedPhone
}
}
// MARK: - Mock Data
// D liu mu
#if DEBUG
extension User {
/// Sample user for previews and testing
/// Ngưi dùng mu 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 mu 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

View File

@@ -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.";

View File

@@ -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.";

View File

@@ -0,0 +1,235 @@
//
// APIService.swift
// AppClientBaseSwift
//
// HTTP client service using URLSession
// Dch v HTTP client s dng URLSession
//
import Foundation
// MARK: - API Error
// Li API
/// API error types
/// Các loi li 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 thc HTTP
/// HTTP request methods
/// Các phương thc 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 dch v API
/// API service protocol for dependency injection
/// Protocol dch 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
// Dch v API
/// Main API service for network requests
/// Dch 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 gii mã JSON
private let decoder: JSONDecoder
// MARK: - Init
/// Initialize API service
/// Khi to dch 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
/// Thc hin request network
/// - Parameters:
/// - endpoint: API endpoint path / Đưng dn endpoint API
/// - method: HTTP method / Phương thc HTTP
/// - body: Request body / Body request
/// - headers: Additional headers / Headers b sung
/// - Returns: Decoded response / Response đã gii mã
func request<T: Decodable>(
endpoint: String,
method: HTTPMethod = .get,
body: Encodable? = nil,
headers: [String: String]? = nil
) async throws -> T {
// Build URL
// Xây dng URL
guard let url = URL(string: APIConfig.baseURL + APIConfig.apiVersion + endpoint) else {
throw APIError.invalidURL
}
// Create request
// To request
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.timeoutInterval = APIConfig.timeout
// Set default headers
// Đt headers mc đnh
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
// Add auth token if available
// Thêm token xác thc nếu có
if let token = AuthManager.shared.accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
// Add custom headers
// Thêm headers tùy chnh
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
// Thc hin 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
// Kim 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 thc tin í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)
}
}

View File

@@ -0,0 +1,333 @@
//
// AuthManager.swift
// AppClientBaseSwift
//
// Authentication state management
// Qun lý trng thái xác thc
//
import Foundation
import Security
import Combine
// MARK: - Auth State
// Trng thái xác thc
/// Authentication state enumeration
/// Enum trng thái xác thc
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
// Qun lý xác thc
/// Main authentication manager
/// Qun lý xác thc chính
final class AuthManager: ObservableObject {
// MARK: - Properties
/// Shared singleton instance
/// Instance singleton dùng chung
@MainActor static let shared = AuthManager()
/// Current authentication state
/// Trng thái xác thc hin ti
@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 thc không
@MainActor var isAuthenticated: Bool {
authState.isAuthenticated
}
/// Current authenticated user
/// Ngưi dùng đã xác thc hin ti
@MainActor var currentUser: User? {
authState.user
}
// MARK: - Init
private init() {}
// MARK: - Public Methods
/// Set authenticated state with user (for mock login)
/// Đt trng thái authenticated vi user (cho mock login)
@MainActor func setAuthenticated(user: User) {
authState = .authenticated(user)
}
// MARK: - Public Methods
/// Initialize auth state on app launch
/// Khi to trng thái xác thc khi app khi đng
@MainActor func initialize() async {
guard accessToken != nil else {
authState = .unauthenticated
return
}
// Try to load cached user
// Th ti 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
// Ly user t API
await refreshCurrentUser()
}
}
/// Login with email and password
/// Đăng nhp vi email và mt khu
/// - Parameters:
/// - email: User email / Email ngưi dùng
/// - password: User password / Mt khu 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 liu 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 mi
/// - Parameters:
/// - name: User name / Tên ngưi dùng
/// - email: User email / Email ngưi dùng
/// - password: User password / Mt khu 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 liu user
if let userData = try? JSONEncoder().encode(response.user) {
UserDefaults.standard.set(userData, forKey: StorageKeys.userData)
}
authState = .authenticated(response.user)
}
/// Logout current user
/// Đăng xut ngưi dùng hin ti
@MainActor func logout() {
// Clear tokens from Keychain
// Xóa tokens khi Keychain
KeychainHelper.delete(key: StorageKeys.accessToken)
KeychainHelper.delete(key: StorageKeys.refreshToken)
// Clear cached user data
// Xóa d liu 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 mi 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 liu 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 mi access token s dng 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 mi
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 mi
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 khi 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)
}
}

View File

@@ -0,0 +1,280 @@
//
// AuthViewModel.swift
// AppClientBaseSwift
//
// ViewModel for authentication UI state management
// ViewModel qun lý state UI xác thc
//
import SwiftUI
import Combine
// MARK: - Auth Screen
// Màn hình Auth
/// Available auth screens
/// Các màn hình auth có sn
enum AuthScreen {
case login
case register
case forgotPassword
}
// MARK: - Auth View Model
// ViewModel Auth
/// ViewModel for authentication UI
/// ViewModel cho UI xác thc
@MainActor
final class AuthViewModel: ObservableObject {
// MARK: - Published Properties
/// Current auth screen
/// Màn hình auth hin ti
@Published var currentScreen: AuthScreen = .login
/// Login email
/// Email đăng nhp
@Published var loginEmail = ""
/// Login password
/// Mt khu đăng nhp
@Published var loginPassword = ""
/// Register name
/// Tên đăng ký
@Published var registerName = ""
/// Register email
/// Email đăng ký
@Published var registerEmail = ""
/// Register password
/// Mt khu đăng ký
@Published var registerPassword = ""
/// Register confirm password
/// Xác nhn mt khu đăng ký
@Published var registerConfirmPassword = ""
/// Forgot password email
/// Email quên mt khu
@Published var forgotEmail = ""
/// Is loading state
/// Trng thái đang ti
@Published var isLoading = false
/// Error message to display
/// Thông báo li hin th
@Published var errorMessage: String?
/// Success message to display
/// Thông báo thành công hin th
@Published var successMessage: String?
/// Agreed to terms
/// Đã đng ý điu khon
@Published var agreedToTerms = false
// MARK: - Validation
/// Validate login form
/// Kim tra form đăng nhp
var isLoginValid: Bool {
loginEmail.isValidEmail && !loginPassword.isEmpty
}
/// Validate register form
/// Kim tra form đăng ký
var isRegisterValid: Bool {
!registerName.trimmed.isEmpty &&
registerEmail.isValidEmail &&
registerPassword.isValidPassword &&
registerPassword == registerConfirmPassword &&
agreedToTerms
}
/// Validate forgot password form
/// Kim tra form quên mt khu
var isForgotPasswordValid: Bool {
forgotEmail.isValidEmail
}
/// Password strength (0-4)
/// Đ mnh mt khu (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 đ mnh mt khu
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 đ mnh mt khu
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
/// Thc hin đăng nhp
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 nhp mock đ test
if loginEmail.lowercased() == mockEmail && loginPassword == mockPassword {
// Simulate network delay
// Gi lp delay mng
try? await Task.sleep(nanoseconds: 1_000_000_000)
// Create mock user and authenticate
// To mock user và xác thc
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
// Cp nht trng thái auth
await MainActor.run {
AuthManager.shared.setAuthenticated(user: mockUser)
}
isLoading = false
return
}
// Real API login
// Đăng nhp API tht
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
/// Thc hin đă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
/// Gi email quên mt khu
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 lp gi 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
/// Điu 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 tt c các field
func clearFields() {
loginEmail = ""
loginPassword = ""
registerName = ""
registerEmail = ""
registerPassword = ""
registerConfirmPassword = ""
forgotEmail = ""
agreedToTerms = false
errorMessage = nil
successMessage = nil
}
}

View File

@@ -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 đ hin 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
/// Trng thái loading
@Published var isLoading: Bool = false
/// Refreshing state (pull-to-refresh)
/// Trng thái refreshing (kéo đ làm mi)
@Published var isRefreshing: Bool = false
/// Error message if any
/// Thông báo li 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 ni bt cho carousel
@Published var featuredItems: [HomeItem] = []
/// Greeting message based on time
/// Li chào da trên thi gian
@Published var greeting: String = ""
// MARK: - Dependencies
/// API service for network requests
/// Dch v API cho các request network
private let apiService: APIServiceProtocol
/// Auth manager for user info
/// Qun lý xác thc cho thông tin user
private let authManager: AuthManager
// MARK: - Init
/// Initialize ViewModel
/// Khi to ViewModel
/// - Parameters:
/// - apiService: API service instance / Instance dch v API
/// - authManager: Auth manager instance / Instance qun lý xác thc
init(
apiService: APIServiceProtocol = APIService.shared,
authManager: AuthManager = .shared
) {
self.apiService = apiService
self.authManager = authManager
updateGreeting()
}
// MARK: - Public Methods
/// Load home data
/// Ti d liu 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ô phng gi API vi mock data tm thi
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
// Gi API tht
// 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 mi d liu (kéo đ làm mi)
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
// Điu hưng hoc thc hin action da trên item
print("Item tapped: \(item.title)")
}
// MARK: - Private Methods
/// Update greeting based on current time
/// Cp nht li chào da trên thi gian hin ti
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)! 🌙"
}
}
}

View File

@@ -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 hin ti
@Published var user: User?
/// Loading state
/// Trng thái loading
@Published var isLoading: Bool = false
/// Error message if any
/// Thông báo li nếu có
@Published var errorMessage: String?
/// Show logout confirmation alert
/// Hin th alert xác nhn đăng xut
@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
/// Qun lý xác thc cho các thao tác user
private let authManager: AuthManager
// MARK: - Init
/// Initialize ViewModel
/// Khi to ViewModel
/// - Parameter authManager: Auth manager instance / Instance qun lý xác thc
init(authManager: AuthManager = .shared) {
self.authManager = authManager
loadUser()
setupMenuItems()
}
// MARK: - Public Methods
/// Load current user data
/// Ti d liu ngưi dùng hin ti
func loadUser() {
user = authManager.currentUser
#if DEBUG
if user == nil {
user = User.sample
}
#endif
}
/// Refresh user data from API
/// Làm mi d liu 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
// Điu hưng đến chnh sa profile
print("Navigate to edit profile")
case .notifications:
// Navigate to notification settings
// Điu hưng đến cài đt thông báo
print("Navigate to notifications")
case .security:
// Navigate to security settings
// Điu hưng đến cài đt bo mt
print("Navigate to security")
case .language:
// Navigate to language settings
// Điu hưng đến cài đt ngôn ng
print("Navigate to language")
case .help:
// Navigate to help center
// Điu hưng đến trung tâm tr giúp
print("Navigate to help")
case .about:
// Navigate to about page
// Điu hưng đến trang gii thiu
print("Navigate to about")
case .logout:
// Show logout confirmation
// Hin th xác nhn đăng xut
showLogoutAlert = true
}
}
/// Confirm logout action
/// Xác nhn action đăng xut
func confirmLogout() {
authManager.logout()
}
// MARK: - Private Methods
/// Setup menu items
/// Thiết lp 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
),
]
}
}

View File

@@ -0,0 +1,76 @@
//
// AuthContainerView.swift
// AppClientBaseSwift
//
// Container view for authentication flows
// View container cho các lung xác thc
//
import SwiftUI
import Combine
// MARK: - Auth Container View
// View Container Auth
/// Container that manages navigation between auth screens
/// Container qun lý điu hưng gia 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 nn
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
// Ni dung
VStack(spacing: 0) {
// Current screen
// Màn hình hin ti
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()
}

View File

@@ -0,0 +1,238 @@
//
// ForgotPasswordView.swift
// AppClientBaseSwift
//
// Forgot password screen view
// View màn hình quên mt khu
//
import SwiftUI
import Combine
// MARK: - Forgot Password View
// View Quên mt khu
/// Forgot password screen with email reset
/// Màn hình quên mt khu vi 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 vi 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
/// Phn minh ha
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 vi 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 li mt khu
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
/// Phn quay li đăng nhp
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
/// Gi link đt li
private func sendResetLink() {
isEmailFocused = false
Task {
await viewModel.forgotPassword()
}
}
}
// MARK: - Preview
#Preview {
AuthContainerView()
}

View File

@@ -0,0 +1,364 @@
//
// LoginView.swift
// AppClientBaseSwift
//
// Login screen view
// View màn hình đăng nhp
//
import SwiftUI
import Combine
// MARK: - Login View
// View Đăng nhp
/// Login screen with email/password authentication
/// Màn hình đăng nhp vi xác thc 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 vi 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 vi 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 nhp
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 vi ch "hoc"
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 nhp mng xã hi
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
/// Phn 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
/// Thc hin đăng nhp
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 chnh 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 chnh 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 nhp mng xã hi
/// Social login button component
/// Component nút đăng nhp mng xã hi
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()
}

View File

@@ -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ý vi 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 vi 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 vi 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 điu khon và điu kin
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("")
.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
/// Phn link đăng nhp
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
/// Thc hin đăng ký
private func register() {
focusedField = nil
Task {
await viewModel.register()
}
}
}
// MARK: - Preview
#Preview {
AuthContainerView()
}

View File

@@ -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 ca 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 đã chn
@State private var selectedCategory: String?
/// Available categories
/// Các category có sn
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
// Ni dung da trên trng thái tìm kiếm
if searchText.isEmpty {
// Show browse content
// Hin th ni dung browse
browseContent
} else {
// Show search results
// Hin 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
/// Ni dung browse khi không tìm kiếm
private var browseContent: some View {
VStack(alignment: .leading, spacing: DesignSystem.spacingLG) {
// Popular section
// Phn 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
// Phn gn đâ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
/// Ni dung kết qu tìm kiếm
private var searchResultsContent: some View {
VStack(spacing: DesignSystem.spacingMD) {
if searchResults.isEmpty && !searchText.isEmpty {
// Empty state
// Trng thái rng
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 Gn đây
/// Nearby location row
/// Row đa đim gn đâ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 khong 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()
}

View File

@@ -0,0 +1,247 @@
//
// HomeView.swift
// AppClientBaseSwift
//
// Home screen view with MVVM binding
// View màn hình Home vi MVVM binding
//
import SwiftUI
// MARK: - Home View
// View Home
/// Home tab main view
/// View chính ca 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
// Phn li chào
greetingSection
// Featured carousel
// Carousel ni bt
if !viewModel.featuredItems.isEmpty {
featuredSection
}
// Main content
// Ni 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
/// Phn li 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
/// Phn carousel items ni bt
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
/// Phn ni dung chính vi 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 ni bt
/// Featured item card view
/// View card item ni bt
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
// Ni 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
/// Ly icon SF Symbol cho category
/// - Parameter category: Item category / Category ca 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()
}

View File

@@ -0,0 +1,191 @@
//
// ProfileView.swift
// AppClientBaseSwift
//
// Profile screen view with MVVM binding
// View màn hình Profile vi MVVM binding
//
import SwiftUI
// MARK: - Profile View
// View Profile
/// Profile tab main view
/// View chính ca 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
// Phn thông tin user
userInfoSection
// Menu items section
// Phn 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
/// Phn 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 vi ch cái đu
avatarPlaceholder
}
}
.frame(width: 72, height: 72)
.clipShape(Circle())
}
/// Avatar placeholder with initials
/// Placeholder avatar vi 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
/// Phn 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 hoc 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()
}

View File

@@ -0,0 +1,178 @@
//
// SplashView.swift
// AppClientBaseSwift
//
// Splash screen with GoodGo logo animation
// Màn hình splash vi animation logo GoodGo
//
import SwiftUI
// MARK: - Splash View
// View Splash
/// Splash screen displayed when app launches
/// Màn hình splash hin th khi app khi đng
struct SplashView<Content: View>: View {
// MARK: - Properties
/// Content to show after splash
/// Ni dung hin 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
/// Trng 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
/// Ni dung màn hình splash
private var splashContent: some View {
ZStack {
// Background gradient
// Gradient nn
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 vi hiu 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 dng
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
/// Bt đ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")
}
}

View File

@@ -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 hin ti
@State private var currentPage = 0
/// Callback when onboarding completes
/// Callback khi onboarding hoàn thành
var onComplete: () -> Void = {}
/// Onboarding pages data
/// D liu 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
// Ni 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
// Phn dưi
bottomSection
.padding(.horizontal, DesignSystem.spacingLG)
.padding(.bottom, DesignSystem.spacingXL)
}
.ignoresSafeArea(edges: .top)
}
// MARK: - Subviews
/// Onboarding page content
/// Ni 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
// Ni 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
/// Phn dưi vi 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 li
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/Bt đ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()
}