Setting claude code and basic layout
This commit is contained in:
parent
9aa5cc5414
commit
52f45dba2b
22
myApp/HaruDanim/CLAUDE.md
Normal file
22
myApp/HaruDanim/CLAUDE.md
Normal file
@ -0,0 +1,22 @@
|
||||
# HaruDanim (하루 다님) Development Guidelines
|
||||
|
||||
## Architecture & Stack
|
||||
- **UI Framework**: SwiftUI
|
||||
- **Data Storage**: SwiftData
|
||||
- **Architecture**: MVVM (Model-View-ViewModel) + Observation (`@Observable`)
|
||||
- **Minimum Target**: iOS 17.0+ (to fully utilize SwiftData and new Observation framework)
|
||||
|
||||
## Coding Conventions
|
||||
1. **Scope Limitation**: Never scan the entire project unnecessarily. Only read and edit files explicitly requested.
|
||||
2. **Data Modeling**:
|
||||
- Use `@Model` macro for all SwiftData models.
|
||||
- Strictly implement relationships (e.g., Action <-> Tag many-to-many, Goal -> Quest one-to-many).
|
||||
- Use raw value Enums for specific types (ActionType, QuestPeriod, Direction).
|
||||
3. **UI/UX Guidelines**:
|
||||
- Primary Green: Light `#2F6B4F`, Dark `#7FBF9E`
|
||||
- Accent Yellow: Light `#D9A621`, Dark `#E8C558`
|
||||
- Background: Light `#FAFAF6`, Dark `#111512`
|
||||
- Use `SF Symbols` exclusively for iconography.
|
||||
4. **Business Logic**:
|
||||
- A day boundary starts at `AppSettings.dayStartHour` (default 00:00).
|
||||
- Sessions crossing this boundary must be saved as one `TimeSession` but logically split when queried for statistics.
|
||||
Binary file not shown.
@ -9,13 +9,7 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(systemName: "globe")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.tint)
|
||||
Text("Hello, world!")
|
||||
}
|
||||
.padding()
|
||||
MainTabView()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct HaruDanimApp: App {
|
||||
@ -13,5 +14,14 @@ struct HaruDanimApp: App {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(for: [
|
||||
Action.self,
|
||||
Tag.self,
|
||||
TimeSession.self,
|
||||
CountEntry.self,
|
||||
Goal.self,
|
||||
Quest.self,
|
||||
AppSettings.self
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
265
myApp/HaruDanim/IOS/Models/Schema.swift
Normal file
265
myApp/HaruDanim/IOS/Models/Schema.swift
Normal file
@ -0,0 +1,265 @@
|
||||
//
|
||||
// Schema.swift
|
||||
// HaruDanim
|
||||
//
|
||||
// SwiftData schema definitions: models, enums, and relationships.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Enums
|
||||
|
||||
/// Determines how an `Action` is tracked.
|
||||
enum ActionType: String, Codable, CaseIterable {
|
||||
/// Tracked via elapsed time (`TimeSession`).
|
||||
case time
|
||||
/// Tracked via discrete counts (`CountEntry`).
|
||||
case count
|
||||
}
|
||||
|
||||
/// Lifecycle state of a `Goal`.
|
||||
enum GoalStatus: String, Codable, CaseIterable {
|
||||
case inProgress
|
||||
case achieved
|
||||
case failed
|
||||
/// Awaiting user confirmation (e.g. period ended, result ambiguous).
|
||||
case needsConfirmation
|
||||
}
|
||||
|
||||
/// The recurrence window a `Quest` is evaluated over.
|
||||
enum QuestPeriod: String, Codable, CaseIterable {
|
||||
case daily
|
||||
case weekly
|
||||
case monthly
|
||||
case custom
|
||||
}
|
||||
|
||||
/// Whether the target value should be met by going above or below it.
|
||||
enum QuestDirection: String, Codable, CaseIterable {
|
||||
/// Succeed when the measured value is at or above the target.
|
||||
case above
|
||||
/// Succeed when the measured value is at or below the target.
|
||||
case below
|
||||
}
|
||||
|
||||
// MARK: - Tag
|
||||
|
||||
@Model
|
||||
final class Tag {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
/// Hex color string (e.g. `#2F6B4F`) for tag styling.
|
||||
var colorHex: String
|
||||
var createdAt: Date
|
||||
|
||||
/// Actions carrying this tag (many-to-many). Inverse declared on `Action.tags`.
|
||||
var actions: [Action]
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
colorHex: String = "#2F6B4F",
|
||||
createdAt: Date = .now,
|
||||
actions: [Action] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.colorHex = colorHex
|
||||
self.createdAt = createdAt
|
||||
self.actions = actions
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action
|
||||
|
||||
@Model
|
||||
final class Action {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var typeRaw: String
|
||||
/// SF Symbol name used for iconography.
|
||||
var iconName: String
|
||||
var createdAt: Date
|
||||
|
||||
/// Tags applied to this action (many-to-many, owning side of the inverse).
|
||||
@Relationship(inverse: \Tag.actions)
|
||||
var tags: [Tag]
|
||||
|
||||
/// Time-based tracking records. Deleting the action cascades to its sessions.
|
||||
@Relationship(deleteRule: .cascade, inverse: \TimeSession.action)
|
||||
var timeSessions: [TimeSession]
|
||||
|
||||
/// Count-based tracking records. Deleting the action cascades to its entries.
|
||||
@Relationship(deleteRule: .cascade, inverse: \CountEntry.action)
|
||||
var countEntries: [CountEntry]
|
||||
|
||||
var type: ActionType {
|
||||
get { ActionType(rawValue: typeRaw) ?? .time }
|
||||
set { typeRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
type: ActionType = .time,
|
||||
iconName: String = "circle",
|
||||
createdAt: Date = .now,
|
||||
tags: [Tag] = [],
|
||||
timeSessions: [TimeSession] = [],
|
||||
countEntries: [CountEntry] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.typeRaw = type.rawValue
|
||||
self.iconName = iconName
|
||||
self.createdAt = createdAt
|
||||
self.tags = tags
|
||||
self.timeSessions = timeSessions
|
||||
self.countEntries = countEntries
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TimeSession
|
||||
|
||||
@Model
|
||||
final class TimeSession {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var startTime: Date
|
||||
/// `nil` while the session is still running.
|
||||
var endTime: Date?
|
||||
|
||||
/// The action this session belongs to (inverse of `Action.timeSessions`).
|
||||
var action: Action?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
startTime: Date = .now,
|
||||
endTime: Date? = nil,
|
||||
action: Action? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.startTime = startTime
|
||||
self.endTime = endTime
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CountEntry
|
||||
|
||||
@Model
|
||||
final class CountEntry {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var timestamp: Date
|
||||
/// Number of units recorded in this entry.
|
||||
var amount: Int
|
||||
|
||||
/// The action this entry belongs to (inverse of `Action.countEntries`).
|
||||
var action: Action?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
timestamp: Date = .now,
|
||||
amount: Int = 1,
|
||||
action: Action? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.timestamp = timestamp
|
||||
self.amount = amount
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Goal
|
||||
|
||||
@Model
|
||||
final class Goal {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var title: String
|
||||
var statusRaw: String
|
||||
var createdAt: Date
|
||||
|
||||
/// Quests composing this goal. Deleting the goal cascades to its quests.
|
||||
@Relationship(deleteRule: .cascade, inverse: \Quest.goal)
|
||||
var quests: [Quest]
|
||||
|
||||
var status: GoalStatus {
|
||||
get { GoalStatus(rawValue: statusRaw) ?? .inProgress }
|
||||
set { statusRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
status: GoalStatus = .inProgress,
|
||||
createdAt: Date = .now,
|
||||
quests: [Quest] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.statusRaw = status.rawValue
|
||||
self.createdAt = createdAt
|
||||
self.quests = quests
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quest
|
||||
|
||||
@Model
|
||||
final class Quest {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var title: String
|
||||
var periodRaw: String
|
||||
var directionRaw: String
|
||||
/// The threshold this quest is measured against (seconds for time, units for count).
|
||||
var targetValue: Double
|
||||
var createdAt: Date
|
||||
|
||||
/// The goal this quest belongs to (inverse of `Goal.quests`).
|
||||
var goal: Goal?
|
||||
|
||||
var period: QuestPeriod {
|
||||
get { QuestPeriod(rawValue: periodRaw) ?? .daily }
|
||||
set { periodRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var direction: QuestDirection {
|
||||
get { QuestDirection(rawValue: directionRaw) ?? .above }
|
||||
set { directionRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
period: QuestPeriod = .daily,
|
||||
direction: QuestDirection = .above,
|
||||
targetValue: Double = 0,
|
||||
createdAt: Date = .now,
|
||||
goal: Goal? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.periodRaw = period.rawValue
|
||||
self.directionRaw = direction.rawValue
|
||||
self.targetValue = targetValue
|
||||
self.createdAt = createdAt
|
||||
self.goal = goal
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AppSettings
|
||||
|
||||
@Model
|
||||
final class AppSettings {
|
||||
@Attribute(.unique) var id: UUID
|
||||
/// Hour (0...23) at which a new logical day begins. Default 00:00.
|
||||
var dayStartHour: Int
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
dayStartHour: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.dayStartHour = dayStartHour
|
||||
}
|
||||
}
|
||||
85
myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift
Normal file
85
myApp/HaruDanim/IOS/ViewModels/TrackingViewModel.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// TrackingViewModel.swift
|
||||
// HaruDanim
|
||||
//
|
||||
// Drives real-time tracking of Actions: starting/stopping time sessions
|
||||
// and recording count entries.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class TrackingViewModel {
|
||||
/// Currently running time sessions (those without an `endTime`).
|
||||
var activeSessions: [TimeSession] = []
|
||||
|
||||
// MARK: - Time tracking
|
||||
|
||||
/// Starts a new `TimeSession` for the action, or ends the running one if it is
|
||||
/// already being tracked.
|
||||
func toggleTimeTracking(for action: Action, context: ModelContext) {
|
||||
if let running = runningSession(for: action, context: context) {
|
||||
running.endTime = .now
|
||||
} else {
|
||||
let session = TimeSession(startTime: .now, action: action)
|
||||
context.insert(session)
|
||||
}
|
||||
save(context)
|
||||
refreshActiveSessions(context: context)
|
||||
}
|
||||
|
||||
/// Ends a specific running session (used by the stop button in the UI).
|
||||
func stopSession(_ session: TimeSession, context: ModelContext) {
|
||||
guard session.endTime == nil else { return }
|
||||
session.endTime = .now
|
||||
save(context)
|
||||
refreshActiveSessions(context: context)
|
||||
}
|
||||
|
||||
// MARK: - Count tracking
|
||||
|
||||
/// Records a `CountEntry` for the action.
|
||||
func incrementCount(for action: Action, amount: Int = 1, context: ModelContext) {
|
||||
let entry = CountEntry(timestamp: .now, amount: amount, action: action)
|
||||
context.insert(entry)
|
||||
save(context)
|
||||
}
|
||||
|
||||
// MARK: - Active session loading
|
||||
|
||||
/// Reloads `activeSessions` from the store (sessions with no `endTime`).
|
||||
func refreshActiveSessions(context: ModelContext) {
|
||||
let descriptor = FetchDescriptor<TimeSession>(
|
||||
predicate: #Predicate { $0.endTime == nil },
|
||||
sortBy: [SortDescriptor(\.startTime, order: .forward)]
|
||||
)
|
||||
activeSessions = (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Returns the running session belonging to `action`, if any.
|
||||
func runningSession(for action: Action, context: ModelContext) -> TimeSession? {
|
||||
let actionID = action.id
|
||||
let descriptor = FetchDescriptor<TimeSession>(
|
||||
predicate: #Predicate { $0.endTime == nil && $0.action?.id == actionID }
|
||||
)
|
||||
return try? context.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Whether the given action currently has a running session.
|
||||
func isTracking(_ action: Action) -> Bool {
|
||||
activeSessions.contains { $0.action?.id == action.id }
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func save(_ context: ModelContext) {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
// Persistence failures are non-fatal for the in-flight UI; surface in logs.
|
||||
print("TrackingViewModel save failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
207
myApp/HaruDanim/IOS/Views/MainDashboardView.swift
Normal file
207
myApp/HaruDanim/IOS/Views/MainDashboardView.swift
Normal file
@ -0,0 +1,207 @@
|
||||
//
|
||||
// MainDashboardView.swift
|
||||
// HaruDanim
|
||||
//
|
||||
// The main dashboard: shows currently running time sessions and a grid of
|
||||
// Actions the user can tap to start/stop tracking or add a count.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct MainDashboardView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
@Query(sort: \Action.createdAt, order: .forward) private var actions: [Action]
|
||||
|
||||
@State private var viewModel = TrackingViewModel()
|
||||
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
activeSection
|
||||
actionsSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("메인")
|
||||
.onAppear { viewModel.refreshActiveSessions(context: context) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Currently tracking
|
||||
|
||||
@ViewBuilder
|
||||
private var activeSection: some View {
|
||||
if !viewModel.activeSessions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("현재 진행 중")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(viewModel.activeSessions) { session in
|
||||
ActiveSessionRow(session: session) {
|
||||
viewModel.stopSession(session, context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions grid
|
||||
|
||||
@ViewBuilder
|
||||
private var actionsSection: some View {
|
||||
if actions.isEmpty {
|
||||
Text("등록된 행동이 없습니다.")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
ForEach(actions) { action in
|
||||
ActionTile(
|
||||
action: action,
|
||||
isTracking: viewModel.isTracking(action)
|
||||
) {
|
||||
tapped(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tapped(_ action: Action) {
|
||||
switch action.type {
|
||||
case .time:
|
||||
viewModel.toggleTimeTracking(for: action, context: context)
|
||||
case .count:
|
||||
viewModel.incrementCount(for: action, context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active session row
|
||||
|
||||
private struct ActiveSessionRow: View {
|
||||
let session: TimeSession
|
||||
let onStop: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: session.action?.iconName ?? "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.brandPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(session.action?.name ?? "행동")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TimelineView(.periodic(from: session.startTime, by: 1)) { _ in
|
||||
Text(elapsedString)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onStop) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.brandPrimary, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.brandPrimary.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private var elapsedString: String {
|
||||
let seconds = Int(Date.now.timeIntervalSince(session.startTime))
|
||||
let h = seconds / 3600
|
||||
let m = (seconds % 3600) / 60
|
||||
let s = seconds % 60
|
||||
return String(format: "%02d:%02d:%02d", h, m, s)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action tile
|
||||
|
||||
private struct ActionTile: View {
|
||||
let action: Action
|
||||
let isTracking: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
private var tileColor: Color {
|
||||
Color(hex: action.tags.first?.colorHex ?? "#2F6B4F")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: action.iconName)
|
||||
.font(.system(size: 30))
|
||||
Text(action.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
if action.type == .time && isTracking {
|
||||
Text("진행 중")
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 120)
|
||||
.background(tileColor, in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay {
|
||||
if isTracking {
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.strokeBorder(.white, lineWidth: 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color helpers
|
||||
|
||||
extension Color {
|
||||
/// App primary green (adapts light/dark via asset-free literal fallback).
|
||||
static let brandPrimary = Color(hex: "#2F6B4F")
|
||||
|
||||
/// Builds a `Color` from a `#RRGGBB` (or `RRGGBB`) hex string.
|
||||
init(hex: String) {
|
||||
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var value: UInt64 = 0
|
||||
Scanner(string: cleaned).scanHexInt64(&value)
|
||||
|
||||
let r, g, b, a: Double
|
||||
switch cleaned.count {
|
||||
case 6:
|
||||
r = Double((value & 0xFF0000) >> 16) / 255
|
||||
g = Double((value & 0x00FF00) >> 8) / 255
|
||||
b = Double(value & 0x0000FF) / 255
|
||||
a = 1
|
||||
case 8:
|
||||
r = Double((value & 0xFF000000) >> 24) / 255
|
||||
g = Double((value & 0x00FF0000) >> 16) / 255
|
||||
b = Double((value & 0x0000FF00) >> 8) / 255
|
||||
a = Double(value & 0x000000FF) / 255
|
||||
default:
|
||||
r = 0.18; g = 0.42; b = 0.31; a = 1 // fallback to primary green
|
||||
}
|
||||
self.init(.sRGB, red: r, green: g, blue: b, opacity: a)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainDashboardView()
|
||||
.modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true)
|
||||
}
|
||||
48
myApp/HaruDanim/IOS/Views/MainTabView.swift
Normal file
48
myApp/HaruDanim/IOS/Views/MainTabView.swift
Normal file
@ -0,0 +1,48 @@
|
||||
//
|
||||
// MainTabView.swift
|
||||
// HaruDanim
|
||||
//
|
||||
// Root tab navigation hosting the app's six top-level sections.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
MainDashboardView()
|
||||
.tabItem {
|
||||
Label("메인", systemImage: "house")
|
||||
}
|
||||
|
||||
Text("행동")
|
||||
.tabItem {
|
||||
Label("행동", systemImage: "bolt")
|
||||
}
|
||||
|
||||
Text("꼬리표")
|
||||
.tabItem {
|
||||
Label("꼬리표", systemImage: "tag")
|
||||
}
|
||||
|
||||
Text("목표")
|
||||
.tabItem {
|
||||
Label("목표", systemImage: "flag")
|
||||
}
|
||||
|
||||
Text("기록")
|
||||
.tabItem {
|
||||
Label("기록", systemImage: "chart.bar")
|
||||
}
|
||||
|
||||
Text("설정")
|
||||
.tabItem {
|
||||
Label("설정", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainTabView()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user