feat: Reimplement HomeView with a Super App layout, introducing WalletCard, ServiceGrid, PromoCarousel, and ActivityFeed components.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,193 @@
|
||||
//
|
||||
// ActivityFeed.swift
|
||||
// AppClientBaseSwift
|
||||
//
|
||||
// Recent activity/transaction feed
|
||||
// Feed hoạt động/giao dịch gần đây
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Activity Item
|
||||
// Item Activity
|
||||
|
||||
/// Activity/Transaction item model
|
||||
/// Model item hoạt động/giao dịch
|
||||
struct ActivityItem: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let amount: Double
|
||||
let icon: String
|
||||
let color: Color
|
||||
let time: String
|
||||
|
||||
/// Whether amount is positive (received)
|
||||
/// Amount có dương (nhận tiền) không
|
||||
var isPositive: Bool {
|
||||
amount >= 0
|
||||
}
|
||||
|
||||
static let samples: [ActivityItem] = [
|
||||
ActivityItem(
|
||||
title: "Grab - Đặt xe",
|
||||
subtitle: "Quận 1 → Quận 7",
|
||||
amount: -45000,
|
||||
icon: "car.fill",
|
||||
color: .green,
|
||||
time: "10:30"
|
||||
),
|
||||
ActivityItem(
|
||||
title: "Nhận tiền",
|
||||
subtitle: "Từ Nguyễn Văn A",
|
||||
amount: 200000,
|
||||
icon: "arrow.down.circle.fill",
|
||||
color: .blue,
|
||||
time: "Hôm qua"
|
||||
),
|
||||
ActivityItem(
|
||||
title: "Thanh toán điện",
|
||||
subtitle: "EVN HCM",
|
||||
amount: -350000,
|
||||
icon: "bolt.fill",
|
||||
color: .yellow,
|
||||
time: "20/01"
|
||||
),
|
||||
ActivityItem(
|
||||
title: "Hoàn tiền",
|
||||
subtitle: "Khuyến mãi đặt xe",
|
||||
amount: 15000,
|
||||
icon: "gift.fill",
|
||||
color: .pink,
|
||||
time: "19/01"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Activity Feed
|
||||
// Feed Activity
|
||||
|
||||
/// Activity/Transaction feed list
|
||||
/// List feed hoạt động/giao dịch
|
||||
struct ActivityFeed: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Activity items
|
||||
/// Các items hoạt động
|
||||
let activities: [ActivityItem]
|
||||
|
||||
/// Item tap callback
|
||||
/// Callback tap item
|
||||
var onActivityTap: ((ActivityItem) -> Void)?
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(activities: [ActivityItem] = ActivityItem.samples, onActivityTap: ((ActivityItem) -> Void)? = nil) {
|
||||
self.activities = activities
|
||||
self.onActivityTap = onActivityTap
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Hoạt động gần đây")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Xem tất cả") {
|
||||
// Show all activities
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
|
||||
// Activity list
|
||||
// Danh sách hoạt động
|
||||
VStack(spacing: 0) {
|
||||
ForEach(activities) { activity in
|
||||
ActivityRow(activity: activity)
|
||||
.onTapGesture {
|
||||
onActivityTap?(activity)
|
||||
}
|
||||
|
||||
if activity.id != activities.last?.id {
|
||||
Divider()
|
||||
.padding(.leading, 56)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.white)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.05), radius: 10, x: 0, y: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activity Row
|
||||
// Row Activity
|
||||
|
||||
/// Individual activity row
|
||||
/// Row hoạt động riêng lẻ
|
||||
struct ActivityRow: View {
|
||||
let activity: ActivityItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(activity.color.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: activity.icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(activity.color)
|
||||
}
|
||||
|
||||
// Content
|
||||
// Nội dung
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(activity.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(activity.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Amount + Time
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(activity.isPositive ? "+\(String.formatVND(activity.amount))" : String.formatVND(activity.amount))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(activity.isPositive ? .green : .primary)
|
||||
|
||||
Text(activity.time)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
Color.gray.opacity(0.1).ignoresSafeArea()
|
||||
ActivityFeed()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// PromoCarousel.swift
|
||||
// AppClientBaseSwift
|
||||
//
|
||||
// Promotional banners carousel
|
||||
// Carousel banners khuyến mãi
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Promo Item
|
||||
// Item Promo
|
||||
|
||||
/// Promotional banner item
|
||||
/// Item banner khuyến mãi
|
||||
struct PromoItem: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let gradientColors: [Color]
|
||||
let icon: String
|
||||
|
||||
static let samples: [PromoItem] = [
|
||||
PromoItem(
|
||||
title: "Giảm 50% Đặt xe",
|
||||
subtitle: "Áp dụng đến 31/01",
|
||||
gradientColors: [.orange, .red],
|
||||
icon: "car.fill"
|
||||
),
|
||||
PromoItem(
|
||||
title: "Freeship Đồ ăn",
|
||||
subtitle: "Đơn từ 50K",
|
||||
gradientColors: [.green, .mint],
|
||||
icon: "takeoutbag.and.cup.and.straw.fill"
|
||||
),
|
||||
PromoItem(
|
||||
title: "Hoàn tiền 20%",
|
||||
subtitle: "Thanh toán hóa đơn",
|
||||
gradientColors: [.blue, .purple],
|
||||
icon: "creditcard.fill"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Promo Carousel
|
||||
// Carousel Promo
|
||||
|
||||
/// Promotional banners carousel
|
||||
/// Carousel banners khuyến mãi
|
||||
struct PromoCarousel: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Promo items to display
|
||||
/// Các items promo hiển thị
|
||||
let items: [PromoItem]
|
||||
|
||||
/// Current page index
|
||||
/// Index trang hiện tại
|
||||
@State private var currentIndex = 0
|
||||
|
||||
/// Auto-scroll timer
|
||||
/// Timer auto-scroll
|
||||
@State private var timer: Timer?
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(items: [PromoItem] = PromoItem.samples) {
|
||||
self.items = items
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Carousel
|
||||
TabView(selection: $currentIndex) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
|
||||
PromoCard(item: item)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.frame(height: 140)
|
||||
|
||||
// Page indicators
|
||||
// Indicators trang
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<items.count, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == currentIndex ? Color.accentColor : Color.gray.opacity(0.3))
|
||||
.frame(width: index == currentIndex ? 8 : 6, height: index == currentIndex ? 8 : 6)
|
||||
.animation(.easeInOut(duration: 0.2), value: currentIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startAutoScroll()
|
||||
}
|
||||
.onDisappear {
|
||||
stopAutoScroll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
/// Start auto-scroll timer
|
||||
/// Bắt đầu timer auto-scroll
|
||||
private func startAutoScroll() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { _ in
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
currentIndex = (currentIndex + 1) % items.count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop auto-scroll timer
|
||||
/// Dừng timer auto-scroll
|
||||
private func stopAutoScroll() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Promo Card
|
||||
// Card Promo
|
||||
|
||||
/// Individual promo banner card
|
||||
/// Card banner promo riêng lẻ
|
||||
struct PromoCard: View {
|
||||
let item: PromoItem
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
// Content
|
||||
// Nội dung
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(item.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
|
||||
Spacer()
|
||||
|
||||
// CTA Button
|
||||
Text("Xem ngay →")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.white.opacity(0.2))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.padding(20)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Icon
|
||||
Image(systemName: item.icon)
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
.padding(.trailing, 20)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: item.gradientColors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(16)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
PromoCarousel()
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// ServiceGrid.swift
|
||||
// AppClientBaseSwift
|
||||
//
|
||||
// Services grid with icons and labels
|
||||
// Grid services với icons và labels
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Service Item
|
||||
// Item Service
|
||||
|
||||
/// Service item model
|
||||
/// Model item service
|
||||
struct ServiceItem: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let color: Color
|
||||
let action: String
|
||||
|
||||
static let defaultServices: [ServiceItem] = [
|
||||
ServiceItem(icon: "car.fill", title: "Đặt xe", color: .orange, action: "booking"),
|
||||
ServiceItem(icon: "takeoutbag.and.cup.and.straw.fill", title: "Đồ ăn", color: .red, action: "food"),
|
||||
ServiceItem(icon: "creditcard.fill", title: "Thanh toán", color: .blue, action: "payment"),
|
||||
ServiceItem(icon: "arrow.left.arrow.right", title: "Chuyển tiền", color: .purple, action: "transfer"),
|
||||
ServiceItem(icon: "tag.fill", title: "Khuyến mãi", color: .pink, action: "promo"),
|
||||
ServiceItem(icon: "plus.circle.fill", title: "Nạp tiền", color: .green, action: "topup"),
|
||||
ServiceItem(icon: "ticket.fill", title: "Đặt vé", color: .orange, action: "tickets"),
|
||||
ServiceItem(icon: "ellipsis", title: "Thêm", color: .gray, action: "more")
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Service Grid
|
||||
// Grid Service
|
||||
|
||||
/// Grid of service icons
|
||||
/// Grid các icon service
|
||||
struct ServiceGrid: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Services to display
|
||||
/// Các services hiển thị
|
||||
let services: [ServiceItem]
|
||||
|
||||
/// Service tap callback
|
||||
/// Callback tap service
|
||||
var onServiceTap: ((ServiceItem) -> Void)?
|
||||
|
||||
/// Grid columns
|
||||
/// Các cột grid
|
||||
private let columns = [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
]
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(services: [ServiceItem] = ServiceItem.defaultServices, onServiceTap: ((ServiceItem) -> Void)? = nil) {
|
||||
self.services = services
|
||||
self.onServiceTap = onServiceTap
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 20) {
|
||||
ForEach(services) { service in
|
||||
ServiceGridItem(service: service)
|
||||
.onTapGesture {
|
||||
onServiceTap?(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Grid Item
|
||||
// Item Grid Service
|
||||
|
||||
/// Individual service grid item
|
||||
/// Item grid service riêng lẻ
|
||||
struct ServiceGridItem: View {
|
||||
let service: ServiceItem
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
// Icon container
|
||||
// Container icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(service.color.opacity(0.15))
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
Image(systemName: service.icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(service.color)
|
||||
}
|
||||
|
||||
// Label
|
||||
Text(service.title)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ServiceGrid()
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// WalletCard.swift
|
||||
// AppClientBaseSwift
|
||||
//
|
||||
// Wallet card with balance and quick actions
|
||||
// Card ví với số dư và quick actions
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Wallet Card
|
||||
// Card Ví
|
||||
|
||||
/// Wallet card displaying balance and quick actions
|
||||
/// Card ví hiển thị số dư và quick actions
|
||||
struct WalletCard: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Current balance
|
||||
/// Số dư hiện tại
|
||||
let balance: Double
|
||||
|
||||
/// Whether balance is hidden
|
||||
/// Số dư có bị ẩn không
|
||||
@State private var isBalanceHidden = false
|
||||
|
||||
/// Quick action callback
|
||||
/// Callback quick action
|
||||
var onQuickAction: ((QuickAction) -> Void)?
|
||||
|
||||
// MARK: - Quick Actions Enum
|
||||
|
||||
enum QuickAction: String, CaseIterable {
|
||||
case topUp = "Nạp tiền"
|
||||
case transfer = "Chuyển tiền"
|
||||
case payment = "Thanh toán"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .topUp: return "plus.circle.fill"
|
||||
case .transfer: return "arrow.left.arrow.right.circle.fill"
|
||||
case .payment: return "qrcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Balance section
|
||||
// Phần số dư
|
||||
balanceSection
|
||||
|
||||
// Quick actions
|
||||
// Quick actions
|
||||
quickActionsSection
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.4, green: 0.5, blue: 0.92),
|
||||
Color(red: 0.47, green: 0.3, blue: 0.64)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(20)
|
||||
.shadow(color: Color(red: 0.4, green: 0.3, blue: 0.7).opacity(0.4), radius: 15, x: 0, y: 10)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
/// Balance display section
|
||||
/// Phần hiển thị số dư
|
||||
private var balanceSection: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Số dư khả dụng")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if isBalanceHidden {
|
||||
Text("••••••••")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text(String.formatVND(balance))
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isBalanceHidden.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isBalanceHidden ? "eye.slash.fill" : "eye.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Points badge
|
||||
// Badge điểm
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "star.circle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text("1,250")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("điểm thưởng")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick actions buttons section
|
||||
/// Phần buttons quick actions
|
||||
private var quickActionsSection: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(QuickAction.allCases, id: \.self) { action in
|
||||
Button {
|
||||
onQuickAction?(action)
|
||||
} label: {
|
||||
VStack(spacing: 8) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.2))
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
Image(systemName: action.icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Text(action.rawValue)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ZStack {
|
||||
Color.gray.opacity(0.1).ignoresSafeArea()
|
||||
WalletCard(balance: 1_250_000)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
// HomeView.swift
|
||||
// AppClientBaseSwift
|
||||
//
|
||||
// Home screen view with MVVM binding
|
||||
// View màn hình Home với MVVM binding
|
||||
// Super App style home screen
|
||||
// Màn hình home phong cách Super App
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@@ -11,233 +11,211 @@ import SwiftUI
|
||||
// MARK: - Home View
|
||||
// View Home
|
||||
|
||||
/// Home tab main view
|
||||
/// View chính của tab Home
|
||||
/// Super App style home view with wallet, services, promos
|
||||
/// View home phong cách Super App với ví, services, promos
|
||||
struct HomeView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// ViewModel for home screen
|
||||
/// ViewModel cho màn hình home
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
/// Auth manager for user info
|
||||
/// Auth manager cho thông tin user
|
||||
@StateObject private var authManager = AuthManager.shared
|
||||
|
||||
/// Mock balance
|
||||
/// Số dư mock
|
||||
private let balance: Double = 1_250_000
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: DesignSystem.spacingLG) {
|
||||
// Greeting section
|
||||
// Phần lời chào
|
||||
greetingSection
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 24) {
|
||||
// Header
|
||||
headerSection
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// Featured carousel
|
||||
// Carousel nổi bật
|
||||
if !viewModel.featuredItems.isEmpty {
|
||||
featuredSection
|
||||
// Wallet Card
|
||||
// Card Ví
|
||||
WalletCard(balance: balance) { action in
|
||||
handleQuickAction(action)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// Services Grid
|
||||
// Grid Services
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Dịch vụ")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
ServiceGrid { service in
|
||||
handleServiceTap(service)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// Main content
|
||||
// Nội dung chính
|
||||
contentSection
|
||||
// Promo Carousel
|
||||
// Carousel Promo
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Ưu đãi hấp dẫn")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Xem tất cả") {
|
||||
// Show all promos
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
PromoCarousel()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
// Activity Feed
|
||||
// Feed Hoạt động
|
||||
ActivityFeed { activity in
|
||||
handleActivityTap(activity)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// Bottom spacing
|
||||
Spacer(minLength: 20)
|
||||
}
|
||||
.padding(.horizontal, DesignSystem.spacingMD)
|
||||
.padding(.vertical, DesignSystem.spacingSM)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.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
|
||||
.background(Color(red: 0.96, green: 0.97, blue: 0.98))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
// QR Code
|
||||
Button {
|
||||
// Show QR scanner
|
||||
} label: {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
// Notifications
|
||||
Button {
|
||||
// Show notifications
|
||||
} label: {
|
||||
Image(systemName: "bell.badge")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
/// Greeting section at top
|
||||
/// Phần lời chào ở đầu
|
||||
private var greetingSection: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: DesignSystem.spacingXS) {
|
||||
Text(viewModel.greeting)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
/// Header with user info
|
||||
/// Header với thông tin user
|
||||
private var headerSection: some View {
|
||||
HStack(spacing: 14) {
|
||||
// Avatar
|
||||
avatarView
|
||||
|
||||
Text("home_subtitle".localized)
|
||||
// Greeting
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(greetingText)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(authManager.currentUser?.name ?? "Người dùng")
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Notification button
|
||||
// Nút thông báo
|
||||
Button {
|
||||
// Handle notification tap
|
||||
// Xử lý tap thông báo
|
||||
} label: {
|
||||
Image(systemName: "bell.badge")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, DesignSystem.spacingSM)
|
||||
}
|
||||
|
||||
/// Featured items carousel section
|
||||
/// Phần carousel items nổi bật
|
||||
private var featuredSection: some View {
|
||||
VStack(alignment: .leading, spacing: DesignSystem.spacingSM) {
|
||||
Text("home_featured".localized)
|
||||
.font(.headline)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: DesignSystem.spacingMD) {
|
||||
ForEach(viewModel.featuredItems) { item in
|
||||
FeaturedCard(item: item)
|
||||
.onTapGesture {
|
||||
viewModel.handleItemTap(item)
|
||||
}
|
||||
}
|
||||
/// User avatar view
|
||||
/// View avatar user
|
||||
private var avatarView: some View {
|
||||
Group {
|
||||
if let avatarUrl = authManager.currentUser?.avatarUrl,
|
||||
let url = URL(string: avatarUrl) {
|
||||
AsyncImage(url: url) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
avatarPlaceholder
|
||||
}
|
||||
} else {
|
||||
avatarPlaceholder
|
||||
}
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
/// Main content section with items
|
||||
/// Phần nội dung chính với các items
|
||||
private var contentSection: some View {
|
||||
VStack(alignment: .leading, spacing: DesignSystem.spacingSM) {
|
||||
Text("home_explore".localized)
|
||||
.font(.headline)
|
||||
|
||||
LazyVStack(spacing: DesignSystem.spacingMD) {
|
||||
ForEach(viewModel.items) { item in
|
||||
HomeItemRow(item: item)
|
||||
.onTapGesture {
|
||||
viewModel.handleItemTap(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Featured Card
|
||||
// Card nổi bật
|
||||
|
||||
/// Featured item card view
|
||||
/// View card item nổi bật
|
||||
struct FeaturedCard: View {
|
||||
let item: HomeItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DesignSystem.spacingSM) {
|
||||
// Placeholder image
|
||||
// Ảnh placeholder
|
||||
RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusMD)
|
||||
/// Avatar placeholder
|
||||
/// Placeholder avatar
|
||||
private var avatarPlaceholder: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.blue.opacity(0.8), .purple.opacity(0.8)],
|
||||
colors: [.blue, .purple],
|
||||
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)
|
||||
Text(authManager.currentUser?.initials ?? "U")
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home Item Row
|
||||
// Row item Home
|
||||
|
||||
/// Home item row view
|
||||
/// View row item home
|
||||
struct HomeItemRow: View {
|
||||
let item: HomeItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DesignSystem.spacingMD) {
|
||||
// Icon
|
||||
RoundedRectangle(cornerRadius: DesignSystem.cornerRadiusSM)
|
||||
.fill(Color.accentColor.opacity(0.1))
|
||||
.frame(width: 56, height: 56)
|
||||
.overlay(
|
||||
Image(systemName: iconForCategory(item.category))
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
)
|
||||
|
||||
// Content
|
||||
// Nội dung
|
||||
VStack(alignment: .leading, spacing: DesignSystem.spacingXS) {
|
||||
Text(item.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text(item.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Arrow
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(DesignSystem.spacingMD)
|
||||
.cardStyle()
|
||||
}
|
||||
|
||||
/// Get SF Symbol icon for category
|
||||
/// Lấy icon SF Symbol cho category
|
||||
/// - Parameter category: Item category / Category của item
|
||||
/// - Returns: SF Symbol name / Tên SF Symbol
|
||||
private func iconForCategory(_ category: String) -> String {
|
||||
switch category {
|
||||
case "explore":
|
||||
return "map"
|
||||
case "promo":
|
||||
return "tag.fill"
|
||||
case "points":
|
||||
return "star.circle.fill"
|
||||
case "recommend":
|
||||
return "heart.fill"
|
||||
/// Greeting text based on time of day
|
||||
/// Text lời chào dựa trên thời gian trong ngày
|
||||
private var greetingText: String {
|
||||
let hour = Calendar.current.component(.hour, from: Date())
|
||||
switch hour {
|
||||
case 5..<12:
|
||||
return "Chào buổi sáng 👋"
|
||||
case 12..<18:
|
||||
return "Chào buổi chiều ☀️"
|
||||
default:
|
||||
return "circle.fill"
|
||||
return "Chào buổi tối 🌙"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
/// Handle quick action tap
|
||||
/// Xử lý tap quick action
|
||||
private func handleQuickAction(_ action: WalletCard.QuickAction) {
|
||||
print("Quick action: \(action.rawValue)")
|
||||
}
|
||||
|
||||
/// Handle service tap
|
||||
/// Xử lý tap service
|
||||
private func handleServiceTap(_ service: ServiceItem) {
|
||||
print("Service: \(service.title)")
|
||||
}
|
||||
|
||||
/// Handle activity tap
|
||||
/// Xử lý tap activity
|
||||
private func handleActivityTap(_ activity: ActivityItem) {
|
||||
print("Activity: \(activity.title)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
Reference in New Issue
Block a user