diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml old mode 100644 new mode 100755 diff --git "a/Bilder-Dungeon-Monster/B\303\244r-ohne-Bg.png" "b/Bilder-Dungeon-Monster/B\303\244r-ohne-Bg.png" old mode 100644 new mode 100755 diff --git a/Bilder-Dungeon-Monster/Wolf-ohne-Bg.png b/Bilder-Dungeon-Monster/Wolf-ohne-Bg.png old mode 100644 new mode 100755 diff --git a/Bilder-Dungeon-Monster/Zombie-ohne-Bg.png b/Bilder-Dungeon-Monster/Zombie-ohne-Bg.png old mode 100644 new mode 100755 diff --git a/Breichtsplanung.md b/Breichtsplanung.md old mode 100644 new mode 100755 diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100644 new mode 100755 diff --git a/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.pbxproj b/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.pbxproj new file mode 100755 index 0000000..899bda2 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.pbxproj @@ -0,0 +1,337 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 4FFA710B2F6A09DC001C4545 /* DailyQuest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DailyQuest.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 4FFA710D2F6A09DC001C4545 /* DailyQuest */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = DailyQuest; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4FFA71082F6A09DC001C4545 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4FFA71022F6A09DC001C4545 = { + isa = PBXGroup; + children = ( + 4FFA710D2F6A09DC001C4545 /* DailyQuest */, + 4FFA710C2F6A09DC001C4545 /* Products */, + ); + sourceTree = ""; + }; + 4FFA710C2F6A09DC001C4545 /* Products */ = { + isa = PBXGroup; + children = ( + 4FFA710B2F6A09DC001C4545 /* DailyQuest.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4FFA710A2F6A09DC001C4545 /* DailyQuest */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4FFA71162F6A09DE001C4545 /* Build configuration list for PBXNativeTarget "DailyQuest" */; + buildPhases = ( + 4FFA71072F6A09DC001C4545 /* Sources */, + 4FFA71082F6A09DC001C4545 /* Frameworks */, + 4FFA71092F6A09DC001C4545 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 4FFA710D2F6A09DC001C4545 /* DailyQuest */, + ); + name = DailyQuest; + packageProductDependencies = ( + ); + productName = DailyQuest; + productReference = 4FFA710B2F6A09DC001C4545 /* DailyQuest.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4FFA71032F6A09DC001C4545 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + 4FFA710A2F6A09DC001C4545 = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = 4FFA71062F6A09DC001C4545 /* Build configuration list for PBXProject "DailyQuest" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4FFA71022F6A09DC001C4545; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 4FFA710C2F6A09DC001C4545 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 4FFA710A2F6A09DC001C4545 /* DailyQuest */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4FFA71092F6A09DC001C4545 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4FFA71072F6A09DC001C4545 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4FFA71142F6A09DE001C4545 /* 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 = S9RJFYF6L5; + 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; + }; + 4FFA71152F6A09DE001C4545 /* 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 = S9RJFYF6L5; + 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; + }; + 4FFA71172F6A09DE001C4545 /* 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 = S9RJFYF6L5; + 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 = de.cnprmedia.DailyQuest; + 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; + }; + 4FFA71182F6A09DE001C4545 /* 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 = S9RJFYF6L5; + 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 = de.cnprmedia.DailyQuest; + 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 */ + 4FFA71062F6A09DC001C4545 /* Build configuration list for PBXProject "DailyQuest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4FFA71142F6A09DE001C4545 /* Debug */, + 4FFA71152F6A09DE001C4545 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4FFA71162F6A09DE001C4545 /* Build configuration list for PBXNativeTarget "DailyQuest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4FFA71172F6A09DE001C4545 /* Debug */, + 4FFA71182F6A09DE001C4545 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 4FFA71032F6A09DC001C4545 /* Project object */; +} diff --git a/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100755 index 0000000..919434a --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.xcworkspace/xcuserdata/noah.xcuserdatad/UserInterfaceState.xcuserstate b/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.xcworkspace/xcuserdata/noah.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100755 index 0000000..9e8727d Binary files /dev/null and b/DailyQuest-SwiftApp/DailyQuest.xcodeproj/project.xcworkspace/xcuserdata/noah.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/DailyQuest-SwiftApp/DailyQuest.xcodeproj/xcuserdata/noah.xcuserdatad/xcschemes/xcschememanagement.plist b/DailyQuest-SwiftApp/DailyQuest.xcodeproj/xcuserdata/noah.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100755 index 0000000..e69de29 diff --git a/DailyQuest-SwiftApp/DailyQuest/AchievementsView.swift b/DailyQuest-SwiftApp/DailyQuest/AchievementsView.swift new file mode 100755 index 0000000..d6ec7d4 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/AchievementsView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct AchievementsView: View { + @EnvironmentObject private var store: GameStore + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(store.achievementsList()) { entry in + AchievementCard(entry: entry) + } + } + .padding(16) + } + .navigationTitle(store.t("achievements")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} + +private struct AchievementCard: View { + @EnvironmentObject private var store: GameStore + + let entry: AchievementViewModel + + var body: some View { + let info = store.achievementProgress(for: entry.id) + let progressValue = info.goalValue > 0 ? Double(min(info.currentValue, info.goalValue)) / Double(info.goalValue) : 0 + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: entry.definition.icon) + .font(.title3) + .frame(width: 28, height: 28) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 4) { + Text(store.achievementText(entry.definition.nameKey)) + .font(.headline) + Text(store.achievementText(entry.definition.descriptionKey)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Text("Tier \(info.currentTier)/\(info.totalTiers)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + ProgressView(value: progressValue) + .tint(entry.status == .completed ? .green : .accentColor) + + HStack { + Text("\(min(info.currentValue, info.goalValue)) / \(info.goalValue)") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if entry.status == .claimable { + Button(store.t("claim")) { + store.claimAchievement(entry.id) + } + .buttonStyle(.borderedProminent) + } else if entry.status == .completed { + Text(store.state.settings.language == .de ? "Abgeschlossen" : "Completed") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + } else { + Text(store.state.settings.language == .de ? "In Arbeit" : "In progress") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/AccentColor.colorset/Contents.json b/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100755 index 0000000..eb87897 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/AppIcon.appiconset/Contents.json b/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100755 index 0000000..2305880 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/Contents.json b/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/Contents.json new file mode 100755 index 0000000..73c0059 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/BackupDocument.swift b/DailyQuest-SwiftApp/DailyQuest/BackupDocument.swift new file mode 100755 index 0000000..8c184ec --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/BackupDocument.swift @@ -0,0 +1,24 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct BackupDocument: FileDocument { + static var readableContentTypes: [UTType] { [.json] } + + var data: Data + + init(data: Data = Data()) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + if let fileData = configuration.file.regularFileContents { + self.data = fileData + } else { + self.data = Data() + } + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + .init(regularFileWithContents: data) + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/CharacterView.swift b/DailyQuest-SwiftApp/DailyQuest/CharacterView.swift new file mode 100755 index 0000000..5b292c0 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/CharacterView.swift @@ -0,0 +1,527 @@ +import SwiftUI +import Charts + +struct CharacterView: View { + @EnvironmentObject private var store: GameStore + + enum CharacterTab: String, CaseIterable, Identifiable { + case stats + case inventory + + var id: String { rawValue } + } + + @State private var selectedTab: CharacterTab = .stats + @State private var showAddWeightSheet = false + @State private var weightInput = "" + + var body: some View { + ScrollView { + VStack(spacing: 14) { + CharacterHeaderCard() + + CharacterVitalsCard() + + Picker("Tab", selection: $selectedTab) { + Text(store.t("base_stats")).tag(CharacterTab.stats) + Text(store.t("inventory")).tag(CharacterTab.inventory) + } + .pickerStyle(.segmented) + + if selectedTab == .stats { + statsTab + } else { + inventoryTab + } + } + .padding(16) + } + .sheet(isPresented: $showAddWeightSheet) { + NavigationStack { + Form { + TextField(store.state.settings.language == .de ? "Gewicht" : "Weight", text: $weightInput) + .keyboardType(.decimalPad) + } + .navigationTitle(store.t("add_weight")) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + weightInput = "" + showAddWeightSheet = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button(store.t("save")) { + let normalized = weightInput.replacingOccurrences(of: ",", with: ".") + if let value = Double(normalized), value > 0 { + store.addWeightEntry(value) + } + weightInput = "" + showAddWeightSheet = false + } + } + } + } + } + } + + private var statsTab: some View { + VStack(spacing: 14) { + StatsRadarCard() + + FocusStatsCard() + + if store.state.character.weightTrackingEnabled { + WeightTrackingCard(showAddWeightSheet: $showAddWeightSheet) + } + } + } + + private var inventoryTab: some View { + VStack(spacing: 14) { + EquipmentCard() + InventoryCard() + } + } +} + +private struct CharacterHeaderCard: View { + @EnvironmentObject private var store: GameStore + + var body: some View { + let char = store.state.character + let manaPercentage = char.manaToNextLevel > 0 ? Double(char.mana) / Double(char.manaToNextLevel) : 0 + let label = store.calculateCharacterLabel() + + VStack(alignment: .leading, spacing: 10) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(char.name) + .font(.title3.bold()) + Text("\(store.t("level")): \(char.level)") + .foregroundStyle(.secondary) + } + Spacer() + Text(label.name) + .font(.caption.bold()) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(hex: label.colorHex).opacity(0.2), in: Capsule()) + } + + Text("\(store.t("mana")): \(char.mana) / \(char.manaToNextLevel)") + .font(.subheadline) + + ProgressView(value: manaPercentage) + .tint(.accentColor) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } +} + +private struct CharacterVitalsCard: View { + @EnvironmentObject private var store: GameStore + + var body: some View { + let stats = store.equipmentStats + + HStack(spacing: 10) { + SmallStat(title: store.t("gold"), value: "\(store.state.character.gold)", icon: "dollarsign.circle") + SmallStat(title: store.t("attack"), value: "\(stats.angriff)", icon: "sword") + SmallStat(title: store.t("protection"), value: "\(stats.schutz)", icon: "shield") + SmallStat(title: store.t("streak"), value: "\(store.state.streak.streak)", icon: "flame") + } + } +} + +private struct SmallStat: View { + let title: String + let value: String + let icon: String + + var body: some View { + VStack(spacing: 3) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.headline) + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } +} + +private struct StatsRadarCard: View { + @EnvironmentObject private var store: GameStore + + private let order: [StatKey] = [.kraft, .ausdauer, .beweglichkeit, .durchhaltevermoegen, .willenskraft] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(store.t("base_stats")) + .font(.headline) + + RadarChart(stats: store.state.character.stats) + .frame(height: 270) + + VStack(spacing: 8) { + ForEach(order, id: \.self) { key in + HStack { + Text(statName(key)) + Spacer() + Text(String(format: "%.1f", store.state.character.stats[key] ?? 0)) + .fontWeight(.semibold) + } + } + } + .font(.subheadline) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } + + private func statName(_ key: StatKey) -> String { + switch key { + case .kraft: return store.state.settings.language == .de ? "Kraft" : "Strength" + case .ausdauer: return store.state.settings.language == .de ? "Ausdauer" : "Endurance" + case .beweglichkeit: return store.state.settings.language == .de ? "Beweglichkeit" : "Agility" + case .durchhaltevermoegen: return store.state.settings.language == .de ? "Durchhaltevermögen" : "Stamina" + case .willenskraft: return store.state.settings.language == .de ? "Willenskraft" : "Willpower" + } + } +} + +private struct RadarChart: View { + let stats: [StatKey: Double] + + private let order: [StatKey] = [.kraft, .ausdauer, .beweglichkeit, .durchhaltevermoegen, .willenskraft] + + var body: some View { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let radius = min(size.width, size.height) * 0.32 + + let maxValue = max(20, (stats.values.max() ?? 5) + 5) + + for level in 1...4 { + let r = radius * CGFloat(level) / 4.0 + var path = Path() + for i in 0.. CGFloat { + (CGFloat(index) / CGFloat(order.count)) * (.pi * 2) - .pi / 2 + } +} + +private struct FocusStatsCard: View { + @EnvironmentObject private var store: GameStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(store.t("focus_stats")) + .font(.headline) + + let totalMinutes = store.state.vibe.sessions.reduce(0) { $0 + $1.duration } + HStack { + Text(store.state.settings.language == .de ? "Gesamtzeit" : "Total") + Spacer() + Text(formatMinutes(totalMinutes)) + .fontWeight(.semibold) + } + + HStack { + Text(store.state.settings.language == .de ? "Sessions" : "Sessions") + Spacer() + Text("\(store.state.vibe.sessions.count)") + .fontWeight(.semibold) + } + + ForEach(store.focusSummaryByLabel(), id: \.label) { item in + HStack { + Text(item.label) + Spacer() + Text("\(formatMinutes(item.minutes)) (\(item.sessions))") + .foregroundStyle(.secondary) + } + } + + if store.focusSummaryByLabel().isEmpty { + Text(store.state.settings.language == .de ? "Noch keine Fokus-Sessions" : "No focus sessions yet") + .foregroundStyle(.secondary) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } + + private func formatMinutes(_ mins: Int) -> String { + let h = mins / 60 + let m = mins % 60 + if h > 0 { + return "\(h)h \(m)m" + } + return "\(m)m" + } +} + +private struct WeightTrackingCard: View { + @EnvironmentObject private var store: GameStore + + @Binding var showAddWeightSheet: Bool + + var body: some View { + let entries = store.state.weightEntries.sorted { $0.time < $1.time } + let latest = entries.last + + VStack(alignment: .leading, spacing: 10) { + Text(store.t("weight_history")) + .font(.headline) + + HStack { + weightBlock(title: store.t("current"), value: latest.map { String(format: "%.1f kg", $0.weight) } ?? "-") + weightBlock(title: store.t("target"), value: store.state.character.targetWeight.map { String(format: "%.1f kg", $0) } ?? "-") + } + + if entries.count >= 2 { + Chart(entries) { entry in + LineMark( + x: .value("Time", entry.time), + y: .value("Weight", entry.weight) + ) + .interpolationMethod(.catmullRom) + + PointMark( + x: .value("Time", entry.time), + y: .value("Weight", entry.weight) + ) + } + .frame(height: 180) + } else if let only = entries.first { + Text(String(format: "%.1f kg", only.weight)) + .font(.title3.bold()) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } else { + Text(store.state.settings.language == .de ? "Füge deinen ersten Eintrag hinzu." : "Add your first entry.") + .foregroundStyle(.secondary) + } + + ForEach(entries.suffix(5).reversed()) { entry in + HStack { + Text(entry.time.formatted(date: .abbreviated, time: .shortened)) + .foregroundStyle(.secondary) + Spacer() + Text(String(format: "%.1f kg", entry.weight)) + .fontWeight(.semibold) + } + .font(.subheadline) + } + + Button(store.t("add_weight")) { + showAddWeightSheet = true + } + .buttonStyle(.borderedProminent) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } + + @ViewBuilder + private func weightBlock(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } +} + +private struct EquipmentCard: View { + @EnvironmentObject private var store: GameStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(store.t("equipment")) + .font(.headline) + + if store.state.character.equipment.weapons.isEmpty && store.state.character.equipment.armor == nil { + Text(store.state.settings.language == .de ? "Keine Ausrüstung angelegt." : "No equipment equipped.") + .foregroundStyle(.secondary) + } + + ForEach(store.state.character.equipment.weapons) { weapon in + InventoryItemRow( + item: weapon, + primaryTitle: store.t("unequip"), + primaryAction: { store.unequipWeapon(weapon.id) }, + secondaryTitle: store.t("sell"), + secondaryAction: { store.sellEquippedWeapon(weapon.id) } + ) + } + + if let armor = store.state.character.equipment.armor { + InventoryItemRow( + item: armor, + primaryTitle: store.t("unequip"), + primaryAction: { store.unequipArmor() }, + secondaryTitle: store.t("sell"), + secondaryAction: { store.sellEquippedArmor() } + ) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } +} + +private struct InventoryCard: View { + @EnvironmentObject private var store: GameStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(store.t("inventory")) + .font(.headline) + + if store.state.character.inventory.isEmpty { + Text(store.state.settings.language == .de ? "Inventar ist leer." : "Inventory is empty.") + .foregroundStyle(.secondary) + } + + ForEach(store.state.character.inventory, id: \.id) { item in + row(for: item) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } + + @ViewBuilder + private func row(for item: ItemInstance) -> some View { + switch item.type { + case .consumable: + InventoryItemRow( + item: item, + primaryTitle: store.t("use"), + primaryAction: { store.useInventoryItem(item.id) }, + secondaryTitle: store.t("sell"), + secondaryAction: { store.sellInventoryItem(item.id) } + ) + case .streak_freeze: + InventoryItemRow( + item: item, + primaryTitle: store.state.settings.language == .de ? "Aktiv" : "Active", + primaryDisabled: true, + primaryAction: {}, + secondaryTitle: store.t("sell"), + secondaryAction: { store.sellInventoryItem(item.id) } + ) + case .weapon, .armor: + InventoryItemRow( + item: item, + primaryTitle: store.t("equip"), + primaryAction: { store.equipInventoryItem(item.id) }, + secondaryTitle: store.t("sell"), + secondaryAction: { store.sellInventoryItem(item.id) } + ) + } + } +} + +private struct InventoryItemRow: View { + let item: ItemInstance + let primaryTitle: String + var primaryDisabled: Bool = false + let primaryAction: () -> Void + let secondaryTitle: String + let secondaryAction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(item.name) + .font(.headline) + Text(item.description) + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button(primaryTitle) { primaryAction() } + .buttonStyle(.borderedProminent) + .disabled(primaryDisabled) + Button(secondaryTitle) { secondaryAction() } + .buttonStyle(.bordered) + } + } + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } +} + +private extension Color { + init(hex: String) { + let cleaned = hex.replacingOccurrences(of: "#", with: "") + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + + self.init(red: r, green: g, blue: b) + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/ContentView.swift b/DailyQuest-SwiftApp/DailyQuest/ContentView.swift new file mode 100755 index 0000000..b52e1a6 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/ContentView.swift @@ -0,0 +1,158 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var store: GameStore + + @State private var showSettings = false + @State private var showAchievements = false + + var body: some View { + NavigationStack { + ZStack(alignment: .bottom) { + TabView(selection: $store.selectedTab) { + ExercisesView() + .tag(MainTab.exercises) + .tabItem { + Label(store.tabTitle(.exercises), systemImage: "figure.strengthtraining.traditional") + } + + FocusView() + .tag(MainTab.focus) + .tabItem { + Label(store.tabTitle(.focus), systemImage: "timer") + } + + CharacterView() + .tag(MainTab.character) + .tabItem { + Label(store.tabTitle(.character), systemImage: "person.crop.circle") + } + + ShopView() + .tag(MainTab.shop) + .tabItem { + Label(store.tabTitle(.shop), systemImage: "bag") + } + + ExtraQuestView() + .tag(MainTab.extraQuest) + .tabItem { + Label(store.tabTitle(.extraQuest), systemImage: "bolt.fill") + } + } + + if store.state.dungeonProgress.activeDungeon && store.activeDungeon == nil { + Button { + store.openDungeon() + } label: { + HStack(spacing: 10) { + Image(systemName: "location.circle.fill") + Text(store.t("dungeon_spawn")) + .lineLimit(1) + Text(store.t("dungeon_go")) + .fontWeight(.bold) + } + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: Capsule()) + .overlay( + Capsule() + .stroke(Color.accentColor.opacity(0.35), lineWidth: 1) + ) + .shadow(radius: 6, y: 2) + } + .padding(.bottom, 62) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .navigationTitle(store.tabTitle(store.selectedTab)) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showAchievements = true + } label: { + Image(systemName: "trophy") + } + + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + } + } + } + .onChange(of: store.selectedTab) { _, newTab in + switch newTab { + case .exercises: + store.maybeShowFeatureTip(.exercises) + case .focus: + store.maybeShowFeatureTip(.focus) + case .character: + store.maybeShowFeatureTip(.character) + case .shop: + store.maybeShowFeatureTip(.shop) + case .extraQuest: + store.maybeShowFeatureTip(.extraQuest) + } + } + .sheet(isPresented: $showSettings) { + SettingsView() + } + .sheet(isPresented: $showAchievements) { + AchievementsView() + } + .fullScreenCover( + isPresented: Binding( + get: { store.activeDungeon != nil }, + set: { isPresented in + if !isPresented { + store.activeDungeon = nil + } + } + ) + ) { + DungeonView() + } + .fullScreenCover(isPresented: $store.showOnboarding) { + OnboardingView() + } + .alert(item: $store.showingFeatureTip) { tip in + Alert( + title: Text(tip.title), + message: Text(tip.text), + dismissButton: .default(Text("OK")) + ) + } + .overlay(alignment: .top) { + if let toast = store.toast { + ToastView(text: toast.text, isPenalty: toast.isPenalty) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.spring(duration: 0.25), value: store.toast) + .animation(.spring(duration: 0.25), value: store.state.dungeonProgress.activeDungeon) + } + .preferredColorScheme(store.state.settings.theme == .dark ? .dark : .light) + } +} + +private struct ToastView: View { + let text: String + let isPenalty: Bool + + var body: some View { + Text(text) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .multilineTextAlignment(.center) + .background(isPenalty ? Color.red.opacity(0.2) : Color.accentColor.opacity(0.2), in: Capsule()) + .overlay( + Capsule() + .stroke(isPenalty ? Color.red.opacity(0.5) : Color.accentColor.opacity(0.4), lineWidth: 1) + ) + .padding(.horizontal, 18) + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/DailyQuestApp.swift b/DailyQuest-SwiftApp/DailyQuest/DailyQuestApp.swift new file mode 100755 index 0000000..24473d9 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/DailyQuestApp.swift @@ -0,0 +1,20 @@ +// +// DailyQuestApp.swift +// DailyQuest +// +// Created by Noah R on 17.03.26. +// + +import SwiftUI + +@main +struct DailyQuestApp: App { + @StateObject private var store = GameStore() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(store) + } + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/DungeonView.swift b/DailyQuest-SwiftApp/DailyQuest/DungeonView.swift new file mode 100755 index 0000000..9e0839a --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/DungeonView.swift @@ -0,0 +1,137 @@ +import SwiftUI + +struct DungeonView: View { + @EnvironmentObject private var store: GameStore + @Environment(\.dismiss) private var dismiss + + @State private var reps: [String: Int] = [:] + + var body: some View { + NavigationStack { + if let encounter = store.activeDungeon { + ScrollView { + VStack(spacing: 14) { + monsterCard(encounter) + taskCard(encounter) + playerCard(encounter) + } + .padding(16) + } + .navigationTitle("Dungeon") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + .onAppear { + for task in encounter.dungeon.tasks { + reps[task.id] = max(1, reps[task.id] ?? 1) + } + } + } else { + VStack(spacing: 12) { + Text(store.state.settings.language == .de ? "Kein aktiver Dungeon." : "No active dungeon.") + Button("Close") { + dismiss() + } + .buttonStyle(.borderedProminent) + } + .navigationTitle("Dungeon") + } + } + } + + private func monsterCard(_ encounter: DungeonEncounter) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(encounter.monster.name) + .font(.title3.bold()) + Spacer() + Text("Lvl \(encounter.level)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } + + HStack { + Text(store.state.settings.language == .de ? "Monster HP" : "Monster HP") + Spacer() + Text("\(encounter.monsterHp) / \(encounter.monsterHpMax)") + .fontWeight(.semibold) + } + ProgressView(value: encounter.monsterHpMax > 0 ? Double(encounter.monsterHp) / Double(encounter.monsterHpMax) : 0) + .tint(.red) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } + + private func taskCard(_ encounter: DungeonEncounter) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(store.state.settings.language == .de ? "Aktionen" : "Actions") + .font(.headline) + + ForEach(encounter.dungeon.tasks) { task in + VStack(alignment: .leading, spacing: 8) { + Text(task.label) + .font(.subheadline.weight(.semibold)) + + HStack { + Stepper(value: Binding( + get: { reps[task.id] ?? 1 }, + set: { reps[task.id] = max(1, $0) } + ), in: 1...999) { + Text("\(reps[task.id] ?? 1) reps") + .font(.subheadline) + } + + Spacer() + + Button("OK") { + let value = reps[task.id] ?? 1 + store.performDungeonAction(task: task, reps: value) + if store.activeDungeon == nil { + dismiss() + } + } + .buttonStyle(.borderedProminent) + } + } + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } + + private func playerCard(_ encounter: DungeonEncounter) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(store.state.settings.language == .de ? "Dein Status" : "Your status") + .font(.headline) + + HStack { + Text(store.state.settings.language == .de ? "HP" : "HP") + Spacer() + Text("\(encounter.playerHp) / \(encounter.playerHpMax)") + .fontWeight(.semibold) + } + ProgressView(value: encounter.playerHpMax > 0 ? Double(encounter.playerHp) / Double(encounter.playerHpMax) : 0) + .tint(.green) + + HStack { + Text("\(store.t("attack")): \(encounter.attack)") + Spacer() + Text("\(store.t("protection")): \(encounter.protection)") + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/ExercisesView.swift b/DailyQuest-SwiftApp/DailyQuest/ExercisesView.swift new file mode 100755 index 0000000..e354f08 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/ExercisesView.swift @@ -0,0 +1,313 @@ +import SwiftUI + +struct ExercisesView: View { + @EnvironmentObject private var store: GameStore + + @State private var selectedFilter: TrainingGoal? = nil + @State private var searchText = "" + @State private var highlightedExerciseID: Int? + @State private var infoPayload: ExerciseInfoPayload? + + private let filterOptions: [TrainingGoal?] = [ + nil, + .muscle, + .endurance, + .fatloss, + .kraft_abnehmen, + .learning, + .restday, + .general_workout, + .calisthenics, + .sick + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(store.t("daily_quests")) + .font(.title3.bold()) + + if store.dailyQuestsForToday().isEmpty { + Text(store.state.settings.language == .de ? "Noch keine Quests für heute." : "No quests for today yet.") + .foregroundStyle(.secondary) + } else { + LazyVStack(spacing: 12) { + ForEach(store.dailyQuestsForToday()) { quest in + QuestCard( + title: store.exerciseName(for: quest.nameKey), + target: store.formatTarget(type: quest.type, value: quest.target), + completed: quest.completed, + isFocus: quest.type == .focus, + onInfo: { + infoPayload = .quest(quest) + }, + onAction: { + if quest.type == .focus { + store.prepareFocusForQuest(questID: quest.id) + } else { + store.completeQuest(quest.id) + } + } + ) + } + } + } + + if store.isRestDay() { + Text(store.t("restday_info_box")) + .font(.subheadline) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + } + + Divider() + + Text(store.t("free_training")) + .font(.title3.bold()) + + HStack(spacing: 8) { + TextField(store.t("search"), text: $searchText) + .textFieldStyle(.roundedBorder) + + Button(store.t("search")) { + guard let result = store.searchExercise(term: searchText) else { + store.toast = ToastMessage( + text: store.state.settings.language == .de + ? "Übung nicht gefunden." + : "Exercise not found.", + isPenalty: true + ) + return + } + selectedFilter = nil + highlightedExerciseID = result.id + } + .buttonStyle(.borderedProminent) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(filterOptions, id: \.self) { option in + Button(filterTitle(option)) { + selectedFilter = option + } + .buttonStyle(.bordered) + .tint(selectedFilter == option ? .accentColor : .secondary) + } + } + .padding(.vertical, 2) + } + + LazyVStack(spacing: 12) { + ForEach(store.freeExercises(filter: selectedFilter)) { exercise in + QuestCard( + title: store.exerciseName(for: exercise.nameKey), + target: scaledTargetText(for: exercise), + completed: false, + isFocus: exercise.type == .focus, + onInfo: { + infoPayload = .exercise(exercise) + }, + onAction: { + if exercise.type == .focus { + store.prepareFocusForExercise(exerciseID: exercise.id) + } else { + store.completeFreeExercise(exercise.id) + } + } + ) + .overlay { + if highlightedExerciseID == exercise.id { + RoundedRectangle(cornerRadius: 14) + .stroke(Color.accentColor, lineWidth: 2) + .padding(1) + } + } + } + } + } + .padding(16) + } + .onAppear { + store.generateDailyQuestsIfNeeded(forceRegenerate: false) + } + .sheet(item: $infoPayload) { payload in + ExerciseInfoView(payload: payload) + .environmentObject(store) + } + } + + private func scaledTargetText(for exercise: ExerciseTemplate) -> String { + var target = exercise.baseValue + if exercise.type != .check && exercise.type != .link && exercise.type != .focus { + target = Int(ceil(Double(exercise.baseValue) + (Double(exercise.baseValue) * 0.4 * Double(store.state.settings.difficulty - 1)))) + } + return store.formatTarget(type: exercise.type, value: target) + } + + private func filterTitle(_ filter: TrainingGoal?) -> String { + guard let filter else { + return store.state.settings.language == .de ? "Alle" : "All" + } + + switch filter { + case .muscle: return store.state.settings.language == .de ? "Kraft" : "Strength" + case .endurance: return store.state.settings.language == .de ? "Ausdauer" : "Endurance" + case .fatloss: return store.state.settings.language == .de ? "Fettverbrennung" : "Fat Loss" + case .kraft_abnehmen: return store.state.settings.language == .de ? "Körpergewicht" : "Bodyweight" + case .learning: return store.state.settings.language == .de ? "Lernen" : "Learning" + case .restday: return store.state.settings.language == .de ? "Erholung" : "Recovery" + case .general_workout: return store.state.settings.language == .de ? "Allgemein" : "General" + case .calisthenics: return "Calisthenics" + case .sick: return store.state.settings.language == .de ? "Krank" : "Sick" + } + } +} + +private struct QuestCard: View { + let title: String + let target: String + let completed: Bool + let isFocus: Bool + let onInfo: () -> Void + let onAction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + if !target.isEmpty { + Text(target) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + HStack(spacing: 8) { + Button("?") { + onInfo() + } + .buttonStyle(.bordered) + + Button { + onAction() + } label: { + if completed { + Image(systemName: "checkmark") + } else { + Text(isFocus ? "Start" : "OK") + } + } + .buttonStyle(.borderedProminent) + .disabled(completed) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(completed ? Color.green.opacity(0.14) : Color.secondary.opacity(0.08)) + ) + } +} + +private struct ExerciseInfoView: View { + @EnvironmentObject private var store: GameStore + let payload: ExerciseInfoPayload + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(title) + .font(.title3.bold()) + + Text(store.explanation(for: nameKey)) + .font(.body) + .foregroundStyle(.secondary) + + if !targetText.isEmpty { + infoRow(label: store.state.settings.language == .de ? "Ziel" : "Target", value: targetText) + } + + infoRow(label: store.state.settings.language == .de ? "Belohnung" : "Reward", value: "+\(manaReward) Mana, +\(goldReward) Gold") + } + .padding(16) + } + .navigationTitle(store.state.settings.language == .de ? "Infos" : "Info") + .navigationBarTitleDisplayMode(.inline) + } + } + + private var nameKey: String { + switch payload { + case .quest(let quest): return quest.nameKey + case .exercise(let exercise): return exercise.nameKey + } + } + + private var title: String { + store.exerciseName(for: nameKey) + } + + private var targetText: String { + switch payload { + case .quest(let quest): + return store.formatTarget(type: quest.type, value: quest.target) + case .exercise(let exercise): + let difficulty = store.state.settings.difficulty + var target = exercise.baseValue + if exercise.type != .check && exercise.type != .link && exercise.type != .focus { + target = Int(ceil(Double(exercise.baseValue) + (Double(exercise.baseValue) * 0.4 * Double(difficulty - 1)))) + } + return store.formatTarget(type: exercise.type, value: target) + } + } + + private var manaReward: Int { + switch payload { + case .quest(let quest): + return quest.manaReward + case .exercise(let exercise): + let difficulty = store.state.settings.difficulty + return Int(ceil(Double(exercise.mana) * (1 + 0.2 * Double(difficulty - 1)))) + } + } + + private var goldReward: Int { + switch payload { + case .quest(let quest): + return quest.goldReward + case .exercise(let exercise): + let difficulty = store.state.settings.difficulty + return Int(ceil(Double(exercise.gold) * (1 + 0.15 * Double(difficulty - 1)))) + } + } + + @ViewBuilder + private func infoRow(label: String, value: String) -> some View { + HStack { + Text(label) + .fontWeight(.medium) + Spacer() + Text(value) + .multilineTextAlignment(.trailing) + } + } +} + +enum ExerciseInfoPayload: Identifiable { + case quest(DailyQuest) + case exercise(ExerciseTemplate) + + var id: String { + switch self { + case .quest(let q): return "quest-\(q.id)" + case .exercise(let e): return "exercise-\(e.id)" + } + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/ExtraQuestView.swift b/DailyQuest-SwiftApp/DailyQuest/ExtraQuestView.swift new file mode 100755 index 0000000..d1c29c1 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/ExtraQuestView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct ExtraQuestView: View { + @EnvironmentObject private var store: GameStore + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + if let quest = store.state.extraQuest { + activeQuestView(quest) + } else { + inactiveView + } + } + .padding(16) + } + } + + private var inactiveView: some View { + VStack(alignment: .leading, spacing: 14) { + Text(store.t("extra_quest")) + .font(.title3.bold()) + + Text(store.state.settings.language == .de + ? "Eine zufällige, fordernde Aufgabe. Hohes Risiko, hohe Belohnung." + : "A random demanding task. High risk, high reward.") + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + penaltyRow(store.state.settings.language == .de ? "Level -1" : "Level -1") + penaltyRow(store.state.settings.language == .de ? "150 Gold Strafe" : "150 gold penalty") + penaltyRow(store.state.settings.language == .de ? "Stat-Verluste" : "Stat losses") + } + .padding(12) + .background(Color.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + + Button(store.t("extra_start")) { + store.startExtraQuest() + } + .buttonStyle(.borderedProminent) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } + + private func activeQuestView(_ quest: ActiveExtraQuest) -> some View { + TimelineView(.periodic(from: .now, by: 1)) { _ in + let total = max(1, quest.deadline.timeIntervalSince(quest.startTime)) + let remaining = max(0, quest.deadline.timeIntervalSinceNow) + let progress = remaining / total + + VStack(alignment: .leading, spacing: 14) { + Text(store.t("extra_task")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Text(store.extraQuestName(for: quest.nameKey)) + .font(.title3.bold()) + + VStack(alignment: .leading, spacing: 6) { + Text(store.t("extra_time")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(formatRemaining(remaining)) + .font(.system(size: 34, weight: .bold, design: .rounded)) + .monospacedDigit() + + ProgressView(value: progress) + .tint(progress < 0.2 ? .red : .accentColor) + } + .padding(12) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + + Button(store.t("extra_complete")) { + store.completeExtraQuest() + } + .buttonStyle(.borderedProminent) + .disabled(remaining <= 0) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } + } + + @ViewBuilder + private func penaltyRow(_ text: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(text) + .font(.subheadline) + } + } + + private func formatRemaining(_ seconds: TimeInterval) -> String { + let value = Int(seconds) + let hours = value / 3600 + let minutes = (value % 3600) / 60 + let secs = value % 60 + return String(format: "%02d:%02d:%02d", hours, minutes, secs) + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/FocusView.swift b/DailyQuest-SwiftApp/DailyQuest/FocusView.swift new file mode 100755 index 0000000..aa32c2a --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/FocusView.swift @@ -0,0 +1,214 @@ +import SwiftUI + +struct FocusView: View { + @EnvironmentObject private var store: GameStore + + @State private var showLabelDialog = false + @State private var showNewLabelPrompt = false + @State private var newLabelName = "" + + private let quotesDE = [ + "Jeder Schritt zählt.", + "Konzentration ist der Schlüssel.", + "Bleib dran, du schaffst das!", + "Eine Minute nach der anderen.", + "Wachstum braucht Zeit und Fokus." + ] + + private let quotesEN = [ + "Every step counts.", + "Concentration is the key.", + "Keep going, you can do it!", + "One minute at a time.", + "Growth needs time and focus." + ] + + var body: some View { + ScrollView { + VStack(spacing: 18) { + Picker("Mode", selection: Binding( + get: { store.state.vibe.timer.mode }, + set: { store.setFocusMode($0) } + )) { + Text(store.t("timer")).tag(FocusMode.pomodoro) + Text(store.t("stopwatch")).tag(FocusMode.stopwatch) + } + .pickerStyle(.segmented) + .disabled(store.state.vibe.timer.isSessionActive) + + Text(formattedTime) + .font(.system(size: 56, weight: .bold, design: .rounded)) + .monospacedDigit() + .padding(.top, 8) + + if store.state.vibe.timer.isSessionActive { + Text(randomQuote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + } + + if store.state.vibe.timer.mode == .pomodoro { + HStack(spacing: 8) { + ForEach([15, 25, 50], id: \.self) { minutes in + Button("\(minutes) min") { + store.setPomodoroDuration(minutes: minutes) + } + .buttonStyle(.bordered) + .tint(store.state.vibe.timer.pomodoroDuration == minutes * 60 ? .accentColor : .secondary) + .disabled(store.state.vibe.timer.isSessionActive) + } + } + } + + Button { + if store.state.vibe.timer.isSessionActive { + store.stopFocusSessionManually() + } else { + startFlow() + } + } label: { + Text(store.state.vibe.timer.isSessionActive ? store.t("stop") : store.t("start")) + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text(store.t("focus_stats")) + .font(.headline) + + let totalMinutes = store.state.vibe.sessions.reduce(0) { $0 + $1.duration } + HStack { + statBlock(title: store.state.settings.language == .de ? "Gesamtzeit" : "Total time", value: formatMinutes(totalMinutes)) + statBlock(title: store.state.settings.language == .de ? "Sessions" : "Sessions", value: "\(store.state.vibe.sessions.count)") + } + + ForEach(store.focusSummaryByLabel(), id: \.label) { summary in + HStack { + Text(summary.label) + .fontWeight(.medium) + Spacer() + Text("\(formatMinutes(summary.minutes)) (\(summary.sessions))") + .foregroundStyle(.secondary) + } + } + + if store.focusSummaryByLabel().isEmpty { + Text(store.state.settings.language == .de ? "Noch keine Fokus-Sessions" : "No focus sessions yet") + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + } + .confirmationDialog(store.t("focus_label_title"), isPresented: $showLabelDialog, titleVisibility: .visible) { + ForEach(store.focusAllLabels, id: \.self) { label in + Button(label) { + store.startFocusSession(withLabel: label) + } + } + + Button(store.t("focus_add_label")) { + showNewLabelPrompt = true + } + + Button("Cancel", role: .cancel) {} + } + .sheet(isPresented: $showNewLabelPrompt) { + NavigationStack { + Form { + TextField(store.t("focus_new_label"), text: $newLabelName) + } + .navigationTitle(store.t("focus_new_label")) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + newLabelName = "" + showNewLabelPrompt = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button(store.t("save")) { + store.addCustomFocusLabel(newLabelName) + let saved = newLabelName.trimmingCharacters(in: .whitespacesAndNewlines) + if !saved.isEmpty { + store.startFocusSession(withLabel: saved) + } + newLabelName = "" + showNewLabelPrompt = false + } + } + } + } + } + } + + private var formattedTime: String { + let seconds: Int + if store.state.vibe.timer.isSessionActive { + seconds = store.state.vibe.timer.elapsedSeconds + } else { + seconds = store.state.vibe.timer.mode == .pomodoro ? store.state.vibe.timer.pomodoroDuration : 0 + } + + let minutes = seconds / 60 + let remaining = seconds % 60 + return String(format: "%02d:%02d", minutes, remaining) + } + + private var randomQuote: String { + let source = store.state.settings.language == .de ? quotesDE : quotesEN + return source.randomElement() ?? source[0] + } + + private func startFlow() { + if let linked = store.state.vibe.linkedQuest, + let labelKey = linked.labelKey { + let label = defaultLabelFromKey(labelKey) + store.startFocusSession(withLabel: label) + return + } + + showLabelDialog = true + } + + private func defaultLabelFromKey(_ key: String) -> String { + switch key { + case "focus_label_reading": + return store.state.settings.language == .de ? "Lesen" : "Reading" + case "focus_label_meditating": + return store.state.settings.language == .de ? "Meditieren" : "Meditating" + default: + return store.state.settings.language == .de ? "Lernen" : "Learning" + } + } + + @ViewBuilder + private func statBlock(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.headline) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + + private func formatMinutes(_ mins: Int) -> String { + let hours = mins / 60 + let minutes = mins % 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } + return "\(minutes)m" + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/GameStore.swift b/DailyQuest-SwiftApp/DailyQuest/GameStore.swift new file mode 100755 index 0000000..f332f6d --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/GameStore.swift @@ -0,0 +1,1397 @@ +import Foundation +import Combine + +struct ToastMessage: Identifiable, Equatable { + let id = UUID() + let text: String + let isPenalty: Bool +} + +struct AchievementViewModel: Identifiable, Hashable { + let id: AchievementKey + let definition: AchievementDefinition + let progress: AchievementProgress + let status: AchievementStatus +} + +enum AchievementStatus: Int { + case claimable = 0 + case inProgress = 1 + case completed = 2 +} + +struct AchievementProgressInfo { + let currentValue: Int + let goalValue: Int + let currentTier: Int + let totalTiers: Int +} + +@MainActor +final class GameStore: ObservableObject { + @Published private(set) var state: GameState + @Published var selectedTab: MainTab = .exercises + @Published var activeDungeon: DungeonEncounter? + @Published var showOnboarding: Bool = false + @Published var toast: ToastMessage? + @Published var showingFeatureTip: FeatureTip? + + private let saveURL: URL + private var hasRolledDungeonSpawn = false + private var focusTickTimer: Timer? + + init() { + self.saveURL = GameStore.makeSaveURL() + self.state = SeedData.defaultState() + loadState() + self.showOnboarding = !state.tutorial.completed + + // JS parity: stop running focus sessions when app boots. + state.vibe.timer.isSessionActive = false + state.vibe.timer.startTime = nil + state.vibe.timer.endTime = nil + state.vibe.currentSessionLabel = nil + state.vibe.linkedQuest = nil + + Task { + await boot() + } + } + + deinit { + focusTickTimer?.invalidate() + } + + // MARK: - Boot + + func boot() async { + await checkForPenaltyAndReset() + rollDungeonSpawnIfNeeded() + checkAllAchievements() + saveState() + } + + // MARK: - Localization + + func t(_ key: String) -> String { + SeedData.strings[state.settings.language]?[key] ?? SeedData.strings[.de]?[key] ?? key + } + + func achievementText(_ key: String) -> String { + SeedData.achievementTexts[state.settings.language]?[key] + ?? SeedData.achievementTexts[.de]?[key] + ?? key + } + + func exerciseName(for nameKey: String) -> String { + SeedData.exerciseNames[state.settings.language]?[nameKey] + ?? SeedData.exerciseNames[.de]?[nameKey] + ?? nameKey + } + + func extraQuestName(for nameKey: String) -> String { + SeedData.extraQuestNames[state.settings.language]?[nameKey] + ?? SeedData.extraQuestNames[.de]?[nameKey] + ?? nameKey + } + + func explanation(for nameKey: String) -> String { + if let value = SeedData.exerciseExplanations[state.settings.language]?[nameKey] { + return value + } + if let value = SeedData.exerciseExplanations[.de]?[nameKey] { + return value + } + return state.settings.language == .de + ? "Keine Beschreibung verfügbar." + : "No description available." + } + + func tabTitle(_ tab: MainTab) -> String { + switch tab { + case .exercises: return t("exercises") + case .focus: return t("focus") + case .character: return t("character") + case .shop: return t("shop") + case .extraQuest: return t("extra_quest") + } + } + + // MARK: - Dates + + func todayString() -> String { + dateString(for: Date()) + } + + func yesterdayString() -> String { + guard let date = Calendar.current.date(byAdding: .day, value: -1, to: Date()) else { + return todayString() + } + return dateString(for: date) + } + + func dateString(for date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + // MARK: - Settings + + func updateLanguage(_ language: AppLanguage) { + state.settings.language = language + saveState() + } + + func updateTheme(_ theme: AppTheme) { + state.settings.theme = theme + saveState() + } + + func updateCharacterName(_ name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + state.character.name = trimmed.isEmpty ? "Unknown Hunter" : trimmed + saveState() + } + + func updateDifficulty(_ difficulty: Int) { + state.settings.difficulty = max(1, min(5, difficulty)) + generateDailyQuestsIfNeeded(forceRegenerate: true) + saveState() + } + + func updateGoal(_ goal: TrainingGoal) { + state.settings.goal = goal + generateDailyQuestsIfNeeded(forceRegenerate: true) + saveState() + } + + func updateRestDays(_ restDays: Int) { + state.settings.restDays = max(0, min(3, restDays)) + generateDailyQuestsIfNeeded(forceRegenerate: true) + saveState() + } + + func updateWeightTrackingEnabled(_ enabled: Bool) { + state.character.weightTrackingEnabled = enabled + saveState() + } + + func updateTargetWeight(_ value: Double?) { + state.character.targetWeight = value + saveState() + } + + func updateWeightDirection(_ direction: WeightDirection) { + state.character.weightDirection = direction + saveState() + } + + func deleteWeightData() { + state.weightEntries = [] + saveState() + showToast(state.settings.language == .de ? "Alle Gewichtsdaten wurden gelöscht." : "All weight data was deleted.") + } + + // MARK: - Quests + + func dailyQuestsForToday() -> [DailyQuest] { + state.dailyQuests + .filter { $0.date == todayString() } + .sorted { $0.id < $1.id } + } + + func quest(by id: Int) -> DailyQuest? { + state.dailyQuests.first(where: { $0.id == id }) + } + + func freeExercises(filter: TrainingGoal?) -> [ExerciseTemplate] { + let list = state.exercises + let filtered = filter == nil ? list : list.filter { $0.category == filter } + return filtered.sorted { exerciseName(for: $0.nameKey) < exerciseName(for: $1.nameKey) } + } + + func searchExercise(term: String) -> ExerciseTemplate? { + let query = term.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return nil } + return state.exercises.first { + exerciseName(for: $0.nameKey).lowercased().contains(query) + } + } + + func formatTarget(type: ExerciseType, value: Int) -> String { + switch type { + case .reps: + return "\(value) Reps" + case .time: + if value >= 60 { + let minutes = value / 60 + let seconds = value % 60 + return seconds == 0 ? "\(minutes) min" : "\(minutes)m \(seconds)s" + } + return state.settings.language == .de ? "\(value) Sek." : "\(value) sec" + case .check, .focus, .link: + return "" + } + } + + func prepareFocusForQuest(questID: Int) { + guard let quest = quest(by: questID) else { return } + guard quest.type == .focus else { return } + guard let template = state.exercises.first(where: { $0.nameKey == quest.nameKey }) else { return } + guard let timerDuration = template.timerDuration else { return } + + let labelKey = labelKeyForExercise(template.nameKey) + prepareFocusSession(durationMinutes: timerDuration, linked: LinkedFocusQuest(type: .quest, id: questID, labelKey: labelKey)) + selectedTab = .focus + } + + func prepareFocusForExercise(exerciseID: Int) { + guard let exercise = state.exercises.first(where: { $0.id == exerciseID }) else { return } + guard exercise.type == .focus else { return } + guard let timerDuration = exercise.timerDuration else { return } + + let labelKey = labelKeyForExercise(exercise.nameKey) + prepareFocusSession(durationMinutes: timerDuration, linked: LinkedFocusQuest(type: .free, id: exerciseID, labelKey: labelKey)) + selectedTab = .focus + } + + func completeQuest(_ questID: Int) { + guard let index = state.dailyQuests.firstIndex(where: { $0.id == questID }) else { return } + guard !state.dailyQuests[index].completed else { return } + + let quest = state.dailyQuests[index] + state.dailyQuests[index].completed = true + + state.character.mana += quest.manaReward + state.character.gold += quest.goldReward + state.character.totalGoldEarned += quest.goldReward + state.character.totalQuestsCompleted += 1 + + if let template = state.exercises.first(where: { $0.nameKey == quest.nameKey }) { + processStatGains(exercise: template) + } + + levelUpCheck() + checkStreakCompletion() + checkAllAchievements() + saveState() + + showToast("+\(quest.manaReward) Mana | +\(quest.goldReward) Gold") + } + + func completeFreeExercise(_ exerciseID: Int) { + guard let exercise = state.exercises.first(where: { $0.id == exerciseID }) else { return } + + let difficulty = state.settings.difficulty + let scaledMana = Int(ceil(Double(exercise.mana) * (1 + 0.2 * Double(difficulty - 1)))) + let scaledGold = Int(ceil(Double(exercise.gold) * (1 + 0.15 * Double(difficulty - 1)))) + + state.character.mana += scaledMana + state.character.gold += scaledGold + state.character.totalGoldEarned += scaledGold + + processStatGains(exercise: exercise) + levelUpCheck() + checkAllAchievements() + saveState() + + showToast("+\(scaledMana) Mana | +\(scaledGold) Gold") + } + + func generateDailyQuestsIfNeeded(forceRegenerate: Bool = false) { + let today = todayString() + let questsTodayIndices = state.dailyQuests.indices.filter { state.dailyQuests[$0].date == today } + + if !questsTodayIndices.isEmpty && !forceRegenerate { + return + } + + if !questsTodayIndices.isEmpty && forceRegenerate { + state.dailyQuests.removeAll { $0.date == today } + } + + var goal = state.settings.goal + if goal != .sick { + let dayOfWeek = Calendar.current.component(.weekday, from: Date()) - 1 + let activeRestDays: [Int] + switch state.settings.restDays { + case 1: activeRestDays = [0] + case 2: activeRestDays = [2, 6] + case 3: activeRestDays = [0, 2, 4] + default: activeRestDays = [] + } + if activeRestDays.contains(dayOfWeek) { + goal = .restday + } + } + + var pool = state.exercises.filter { $0.category == goal } + if pool.isEmpty { + pool = state.exercises.filter { $0.category == .muscle } + } + pool.shuffle() + + let questCount = (goal == .restday || goal == .sick) ? 5 : 6 + let selected = Array(pool.prefix(questCount)) + let difficulty = state.settings.difficulty + + for template in selected { + var target = template.baseValue + if template.type != .check && template.type != .link && template.type != .focus { + target = Int(ceil(Double(template.baseValue) + (Double(template.baseValue) * 0.4 * Double(difficulty - 1)))) + } + + let manaReward = Int(ceil(Double(template.mana) * (1 + 0.2 * Double(difficulty - 1)))) + let goldReward = Int(ceil(Double(template.gold) * (1 + 0.15 * Double(difficulty - 1)))) + + let quest = DailyQuest( + id: state.nextQuestID, + date: today, + nameKey: template.nameKey, + type: template.type, + target: target, + manaReward: manaReward, + goldReward: goldReward, + completed: false, + goal: goal + ) + + state.nextQuestID += 1 + state.dailyQuests.append(quest) + } + + saveState() + } + + // MARK: - Stats and leveling + + private func processStatGains(exercise: ExerciseTemplate) { + let difficulty = state.settings.difficulty + let throughMultiplier = 0.5 + + let mainThresholds: [Int: Double] = [1: 5.5, 2: 5, 3: 4.5, 4: 4, 5: 3.5] + let willThresholds: [Int: Double] = [1: 4.5, 2: 4, 3: 3.5, 4: 3, 5: 2.5] + + if let direct = exercise.directStatGain { + for (stat, rawGain) in direct { + let gain = stat == .durchhaltevermoegen ? rawGain * throughMultiplier : rawGain + state.character.stats[stat, default: 0] += gain + } + } + + if let statPoints = exercise.statPoints { + for (stat, rawPoint) in statPoints { + let points = stat == .durchhaltevermoegen ? rawPoint * throughMultiplier : rawPoint + state.character.statProgress[stat, default: 0] += points + + let threshold = stat == .willenskraft + ? (willThresholds[difficulty] ?? 3.5) + : (mainThresholds[difficulty] ?? 4.5) + + if state.character.statProgress[stat, default: 0] >= threshold { + state.character.stats[stat, default: 0] += 1 + state.character.statProgress[stat, default: 0] -= threshold + } + } + } + } + + private func levelUpCheck() { + var didLevel = false + + while state.character.mana >= state.character.manaToNextLevel { + let manaNeeded = state.character.manaToNextLevel + state.character.mana -= manaNeeded + state.character.level += 1 + state.character.manaToNextLevel = manaForLevel(state.character.level) + didLevel = true + } + + if didLevel { + showToast(state.settings.language == .de + ? "LEVEL UP! Du bist jetzt Level \(state.character.level)!" + : "LEVEL UP! You are now level \(state.character.level)!") + } + } + + func manaForLevel(_ level: Int) -> Int { + Int(floor(100 * pow(1.5, Double(level - 1)))) + } + + // MARK: - Streak and penalty + + func checkStreakCompletion() { + let today = todayString() + let yesterday = yesterdayString() + let quests = dailyQuestsForToday() + guard !quests.isEmpty, quests.allSatisfy(\.completed) else { return } + + if state.streak.lastDate != today { + if state.streak.lastDate == yesterday { + state.streak.streak += 1 + } else { + state.streak.streak = 1 + } + state.streak.lastDate = today + } + } + + func checkForPenaltyAndReset() async { + let today = todayString() + if state.lastPenaltyCheck == today { + generateDailyQuestsIfNeeded(forceRegenerate: false) + return + } + + state.lastPenaltyCheck = today + + // Remove quests older than two days ago. + if let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: Date()) { + let oldDate = dateString(for: twoDaysAgo) + state.dailyQuests.removeAll { $0.date < oldDate } + } + + let yesterday = yesterdayString() + let yesterdaysQuests = state.dailyQuests.filter { $0.date == yesterday } + + var penaltyReason: String? + + if !yesterdaysQuests.isEmpty && !yesterdaysQuests.allSatisfy(\.completed) { + if let freezeIndex = state.character.inventory.firstIndex(where: { $0.type == .streak_freeze }) { + state.character.inventory.remove(at: freezeIndex) + if state.streak.streak > 0 { + state.streak.lastDate = yesterday + } + penaltyReason = "freeze" + } else { + state.streak.streak = 0 + state.streak.lastDate = nil + if state.character.level > 1 { + state.character.level -= 1 + state.character.manaToNextLevel = manaForLevel(state.character.level) + } + penaltyReason = "daily" + } + } + + if let extra = state.extraQuest, !extra.completed, extra.deadline < Date() { + if state.character.level > 1 { + state.character.level -= 1 + } + state.character.manaToNextLevel = manaForLevel(state.character.level) + state.character.gold = max(0, state.character.gold - 150) + + for stat in StatKey.allCases { + let loss: Double = stat == .willenskraft ? 3 : 1 + state.character.stats[stat] = max(1, (state.character.stats[stat] ?? 1) - loss) + } + + state.extraQuest = nil + penaltyReason = "extra" + } + + generateDailyQuestsIfNeeded(forceRegenerate: true) + checkAllAchievements() + saveState() + + if penaltyReason == "daily" { + showToast(state.settings.language == .de + ? "Strafe: Du hast ein Level verloren." + : "Penalty: You lost one level.", isPenalty: true) + } else if penaltyReason == "freeze" { + showToast(state.settings.language == .de + ? "Streak Freeze hat deine Serie gerettet." + : "Streak Freeze saved your streak.") + } else if penaltyReason == "extra" { + showToast(state.settings.language == .de + ? "Extra-Quest gescheitert. Strafe angewendet." + : "Extra quest failed. Penalty applied.", isPenalty: true) + } + } + + // MARK: - Extra Quest + + func startExtraQuest() { + guard state.extraQuest == nil else { return } + guard let random = SeedData.extraQuestPool.randomElement() else { return } + + let now = Date() + var calendar = Calendar.current + calendar.timeZone = .current + let deadline = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: now) ?? now + + state.extraQuest = ActiveExtraQuest( + id: 1, + nameKey: random.nameKey, + manaReward: random.mana, + goldReward: random.gold, + startTime: now, + deadline: deadline, + completed: false + ) + + saveState() + } + + func completeExtraQuest() { + guard var quest = state.extraQuest else { return } + guard !quest.completed else { return } + + if Date() > quest.deadline { + state.extraQuest = nil + saveState() + showToast(state.settings.language == .de + ? "Die Zeit ist abgelaufen. Extra-Quest gescheitert." + : "Time is up. Extra quest failed.", isPenalty: true) + return + } + + quest.completed = true + state.extraQuest = nil + + state.character.mana += quest.manaReward + state.character.gold += quest.goldReward + state.character.totalGoldEarned += quest.goldReward + + if let template = SeedData.extraQuestPool.first(where: { $0.nameKey == quest.nameKey }) { + let exerciseEquivalent = ExerciseTemplate( + id: template.id, + category: .general_workout, + nameKey: template.nameKey, + type: .check, + baseValue: 1, + mana: template.mana, + gold: template.gold, + statPoints: template.statPoints, + directStatGain: nil, + timerDuration: nil + ) + processStatGains(exercise: exerciseEquivalent) + } + + levelUpCheck() + checkAllAchievements() + saveState() + + showToast("+\(quest.manaReward) Mana | +\(quest.goldReward) Gold") + } + + func extraQuestRemainingTime() -> TimeInterval { + guard let extra = state.extraQuest else { return 0 } + return max(0, extra.deadline.timeIntervalSinceNow) + } + + // MARK: - Shop / Inventory + + var equipmentStats: (angriff: Int, schutz: Int) { + calculateEquipmentStats(character: state.character) + } + + func calculateEquipmentStats(character: Character) -> (angriff: Int, schutz: Int) { + let attack = character.equipment.weapons.reduce(0) { $0 + ($1.bonus?["angriff"] ?? 0) } + let protection = (character.equipment.armor?.bonus?["schutz"] ?? 0) + return (attack, protection) + } + + func buyItem(_ item: ShopItem) { + guard state.character.gold >= item.cost else { + showToast(state.settings.language == .de ? "Nicht genug Gold." : "Not enough gold.", isPenalty: true) + return + } + + if item.type == .consumable { + let count = state.character.inventory.filter { $0.type == .consumable }.count + if count >= 5 { + showToast(state.settings.language == .de ? "Dein Mana-Beutel ist voll (max. 5)." : "Your mana bag is full (max 5).", isPenalty: true) + return + } + } + + if item.type == .streak_freeze { + let count = state.character.inventory.filter { $0.type == .streak_freeze }.count + if count >= 2 { + showToast(state.settings.language == .de ? "Maximal 2 Streak Freezes erlaubt." : "Maximum 2 Streak Freezes allowed.", isPenalty: true) + return + } + } + + state.character.gold -= item.cost + state.character.inventory.append(ItemInstance(from: item)) + state.character.totalItemsPurchased += 1 + + checkAchievement(.shop) + saveState() + showToast("\(item.name) \(state.settings.language == .de ? "gekauft" : "purchased")") + } + + func useInventoryItem(_ itemID: UUID) { + guard let index = state.character.inventory.firstIndex(where: { $0.id == itemID }) else { return } + let item = state.character.inventory[index] + guard item.type == .consumable else { return } + + let manaGain = item.effect?["mana"] ?? 0 + state.character.mana += manaGain + state.character.inventory.remove(at: index) + levelUpCheck() + checkAllAchievements() + saveState() + showToast("+\(manaGain) Mana") + } + + func equipInventoryItem(_ itemID: UUID) { + guard let index = state.character.inventory.firstIndex(where: { $0.id == itemID }) else { return } + let item = state.character.inventory[index] + + switch item.type { + case .weapon: + if state.character.equipment.weapons.count >= 2 { + showToast(state.settings.language == .de ? "Du kannst nur 2 Waffen tragen." : "You can only carry 2 weapons.", isPenalty: true) + return + } + state.character.equipment.weapons.append(item) + case .armor: + if state.character.equipment.armor != nil { + showToast(state.settings.language == .de ? "Lege zuerst deine aktuelle Rüstung ab." : "Unequip your current armor first.", isPenalty: true) + return + } + state.character.equipment.armor = item + case .consumable, .streak_freeze: + return + } + + state.character.inventory.remove(at: index) + saveState() + } + + func unequipWeapon(_ itemID: UUID) { + guard let index = state.character.equipment.weapons.firstIndex(where: { $0.id == itemID }) else { return } + let item = state.character.equipment.weapons.remove(at: index) + state.character.inventory.append(item) + saveState() + } + + func unequipArmor() { + guard let armor = state.character.equipment.armor else { return } + state.character.equipment.armor = nil + state.character.inventory.append(armor) + saveState() + } + + func sellInventoryItem(_ itemID: UUID) { + guard let index = state.character.inventory.firstIndex(where: { $0.id == itemID }) else { return } + let item = state.character.inventory.remove(at: index) + sellItem(item) + } + + func sellEquippedWeapon(_ itemID: UUID) { + guard let index = state.character.equipment.weapons.firstIndex(where: { $0.id == itemID }) else { return } + let item = state.character.equipment.weapons.remove(at: index) + sellItem(item) + } + + func sellEquippedArmor() { + guard let item = state.character.equipment.armor else { return } + state.character.equipment.armor = nil + sellItem(item) + } + + private func sellItem(_ item: ItemInstance) { + let sellPrice = Int(floor(Double(item.cost) * 0.7)) + state.character.gold += sellPrice + state.character.totalGoldEarned += sellPrice + checkAchievement(.gold) + saveState() + showToast("+\(sellPrice) Gold") + } + + // MARK: - Weight tracking + + func addWeightEntry(_ weight: Double) { + let clamped = min(max(weight, 0), 200) + let now = Date() + let entry = WeightEntry(id: UUID(), date: dateString(for: now), time: now, weight: clamped) + state.weightEntries.append(entry) + state.weightEntries.sort { $0.time < $1.time } + saveState() + } + + // MARK: - Achievements + + func achievementsList() -> [AchievementViewModel] { + SeedData.achievementDefinitions + .map { definition in + let progress = state.character.achievements[definition.key] ?? AchievementProgress(tier: 0, claimable: false) + let status: AchievementStatus + if progress.claimable { + status = .claimable + } else if progress.tier >= definition.tiers.count { + status = .completed + } else { + status = .inProgress + } + return AchievementViewModel(id: definition.key, definition: definition, progress: progress, status: status) + } + .sorted { + if $0.status != $1.status { + return $0.status.rawValue < $1.status.rawValue + } + return $0.definition.key.rawValue < $1.definition.key.rawValue + } + } + + func achievementProgress(for key: AchievementKey) -> AchievementProgressInfo { + guard let definition = SeedData.achievementDefinitions.first(where: { $0.key == key }) else { + return AchievementProgressInfo(currentValue: 0, goalValue: 1, currentTier: 0, totalTiers: 1) + } + + let progress = state.character.achievements[key] ?? AchievementProgress(tier: 0, claimable: false) + let tier = progress.tier + let goal = tier < definition.tiers.count ? definition.tiers[tier] : definition.tiers.last ?? 1 + let current = currentProgressValue(for: key) + + return AchievementProgressInfo( + currentValue: current, + goalValue: goal, + currentTier: min(tier + 1, definition.tiers.count), + totalTiers: definition.tiers.count + ) + } + + func claimAchievement(_ key: AchievementKey) { + guard var progress = state.character.achievements[key] else { return } + guard progress.claimable else { return } + guard let definition = SeedData.achievementDefinitions.first(where: { $0.key == key }) else { return } + + let currentTier = progress.tier + let level = currentTier + 1 + + let rewardGold = 100 * level + let rewardMana = 100 * level + + state.character.gold += rewardGold + state.character.mana += rewardMana + state.character.totalGoldEarned += rewardGold + + progress.tier += 1 + progress.claimable = false + state.character.achievements[key] = progress + + checkAchievement(key) + levelUpCheck() + saveState() + + let title = achievementText(definition.nameKey) + showToast("\(title): +\(rewardGold) Gold | +\(rewardMana) Mana") + } + + func checkAllAchievements() { + for key in AchievementKey.allCases { + checkAchievement(key) + } + } + + func checkAchievement(_ key: AchievementKey) { + guard let definition = SeedData.achievementDefinitions.first(where: { $0.key == key }) else { return } + + var progress = state.character.achievements[key] ?? AchievementProgress(tier: 0, claimable: false) + + if progress.claimable || progress.tier >= definition.tiers.count { + state.character.achievements[key] = progress + return + } + + let goal = definition.tiers[progress.tier] + let current = currentProgressValue(for: key) + + if current >= goal { + progress.claimable = true + state.character.achievements[key] = progress + + let name = achievementText(definition.nameKey) + showToast(state.settings.language == .de + ? "Erfolg freigeschaltet: \(name)!" + : "Achievement unlocked: \(name)!") + } + } + + private func currentProgressValue(for key: AchievementKey) -> Int { + switch key { + case .level: + return state.character.level + case .quests: + return state.character.totalQuestsCompleted + case .gold: + return state.character.totalGoldEarned + case .shop: + return state.character.totalItemsPurchased + case .strength: + return Int(state.character.stats[.kraft] ?? 0) + case .streak: + return state.streak.streak + case .focus_time: + return state.vibe.sessions.reduce(0) { $0 + $1.duration } + } + } + + // MARK: - Focus module + + var focusDefaultLabels: [String] { + SeedData.defaultFocusLabels.map { + switch $0 { + case "focus_label_reading": return state.settings.language == .de ? "Lesen" : "Reading" + case "focus_label_learning": return state.settings.language == .de ? "Lernen" : "Learning" + case "focus_label_meditating": return state.settings.language == .de ? "Meditieren" : "Meditating" + default: return $0 + } + } + } + + var focusAllLabels: [String] { + var labels = focusDefaultLabels + for custom in state.vibe.customLabels where !labels.contains(custom) { + labels.append(custom) + } + return labels + } + + func setFocusMode(_ mode: FocusMode) { + guard !state.vibe.timer.isSessionActive else { return } + state.vibe.timer.mode = mode + state.vibe.linkedQuest = nil + state.vibe.timer.elapsedSeconds = mode == .pomodoro ? state.vibe.timer.pomodoroDuration : 0 + saveState() + } + + func setPomodoroDuration(minutes: Int) { + guard !state.vibe.timer.isSessionActive else { return } + state.vibe.timer.pomodoroDuration = max(1, minutes) * 60 + state.vibe.linkedQuest = nil + state.vibe.timer.elapsedSeconds = state.vibe.timer.pomodoroDuration + saveState() + } + + func prepareFocusSession(durationMinutes: Int, linked: LinkedFocusQuest?) { + state.vibe.timer.mode = .pomodoro + state.vibe.timer.pomodoroDuration = max(1, durationMinutes) * 60 + state.vibe.timer.elapsedSeconds = state.vibe.timer.pomodoroDuration + state.vibe.linkedQuest = linked + saveState() + } + + func startFocusSession(withLabel label: String) { + guard !state.vibe.timer.isSessionActive else { return } + + state.vibe.timer.isSessionActive = true + state.vibe.currentSessionLabel = label + state.vibe.timer.startTime = Date() + + if state.vibe.timer.mode == .pomodoro { + state.vibe.timer.endTime = Date().addingTimeInterval(TimeInterval(state.vibe.timer.pomodoroDuration)) + state.vibe.timer.elapsedSeconds = state.vibe.timer.pomodoroDuration + } else { + state.vibe.timer.endTime = nil + state.vibe.timer.elapsedSeconds = 0 + } + + startFocusTicking() + saveState() + } + + func stopFocusSessionManually() { + guard state.vibe.timer.isSessionActive else { return } + let elapsedMinutes = Int(floor(Double(state.vibe.timer.elapsedSeconds) / 60.0)) + + if state.vibe.timer.mode == .stopwatch && elapsedMinutes >= 2 { + completeFocusSession(minutes: elapsedMinutes) + return + } + + resetFocusTimerState() + saveState() + } + + func addCustomFocusLabel(_ label: String) { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if !state.vibe.customLabels.contains(trimmed) { + state.vibe.customLabels.append(trimmed) + saveState() + } + } + + func deleteCustomFocusLabel(_ label: String) { + state.vibe.customLabels.removeAll { $0 == label } + saveState() + } + + private func startFocusTicking() { + focusTickTimer?.invalidate() + focusTickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.focusTick() + } + } + } + + private func focusTick() { + guard state.vibe.timer.isSessionActive else { + focusTickTimer?.invalidate() + focusTickTimer = nil + return + } + + if state.vibe.timer.mode == .pomodoro { + let remaining = Int(round(state.vibe.timer.endTime?.timeIntervalSinceNow ?? 0)) + state.vibe.timer.elapsedSeconds = max(0, remaining) + if state.vibe.timer.elapsedSeconds <= 0 { + let minutes = max(1, state.vibe.timer.pomodoroDuration / 60) + completeFocusSession(minutes: minutes) + return + } + } else { + if let start = state.vibe.timer.startTime { + state.vibe.timer.elapsedSeconds = max(0, Int(round(Date().timeIntervalSince(start)))) + } + } + } + + private func completeFocusSession(minutes: Int) { + let linked = state.vibe.linkedQuest + let label = state.vibe.currentSessionLabel ?? (state.settings.language == .de ? "Lernen" : "Learning") + + resetFocusTimerState() + + state.vibe.sessions.append(FocusSession(id: UUID(), date: Date(), duration: minutes, label: label)) + + if let linkedQuest = linked { + if linkedQuest.type == .quest { + completeQuest(linkedQuest.id) + } else { + completeFreeExercise(linkedQuest.id) + } + saveState() + return + } + + let goldEarned = minutes * 4 + let manaEarned = minutes * 2 + let enduranceGain = minutes / 40 + + state.character.gold += goldEarned + state.character.mana += manaEarned + state.character.totalGoldEarned += goldEarned + + if enduranceGain > 0 { + state.character.stats[.durchhaltevermoegen, default: 0] += Double(enduranceGain) + } + + levelUpCheck() + checkAchievement(.focus_time) + checkAchievement(.gold) + + saveState() + showToast("+\(goldEarned) Gold | +\(manaEarned) Mana") + } + + private func resetFocusTimerState() { + focusTickTimer?.invalidate() + focusTickTimer = nil + + state.vibe.timer.isSessionActive = false + state.vibe.timer.startTime = nil + state.vibe.timer.endTime = nil + state.vibe.linkedQuest = nil + state.vibe.currentSessionLabel = nil + state.vibe.timer.elapsedSeconds = state.vibe.timer.mode == .pomodoro ? state.vibe.timer.pomodoroDuration : 0 + } + + func focusSummaryByLabel() -> [(label: String, minutes: Int, sessions: Int)] { + var grouped: [String: (Int, Int)] = [:] + for session in state.vibe.sessions { + let current = grouped[session.label] ?? (0, 0) + grouped[session.label] = (current.0 + session.duration, current.1 + 1) + } + + return grouped + .map { (label: $0.key, minutes: $0.value.0, sessions: $0.value.1) } + .sorted { $0.minutes > $1.minutes } + } + + // MARK: - Character labels + + func calculateCharacterLabel() -> CharacterLabel { + let stats = state.character.stats + let total = stats.values.reduce(0, +) + if total < 15 { + return SeedData.characterLabels.first(where: { $0.key == "neuling" }) ?? SeedData.characterLabels[0] + } + + let normalized = stats.mapValues { $0 / total } + let sorted = normalized.sorted { $0.value > $1.value } + + guard sorted.count >= 3 else { + return SeedData.characterLabels.first(where: { $0.key == "allrounder" }) ?? SeedData.characterLabels[0] + } + + let top = sorted[0] + let second = sorted[1] + let third = sorted[2] + let threshold = 0.1 + + if top.value - second.value > threshold { + return singleStatLabel(for: top.key) + } + + if top.value - third.value > threshold { + return twoStatLabel(top.key, second.key) + } + + if third.value > top.value - threshold { + return SeedData.characterLabels.first(where: { $0.key == "allrounder" }) ?? SeedData.characterLabels[0] + } + + return SeedData.characterLabels.first(where: { $0.key == "allrounder" }) ?? SeedData.characterLabels[0] + } + + private func singleStatLabel(for stat: StatKey) -> CharacterLabel { + let key: String + switch stat { + case .kraft: key = "kraftprotz" + case .ausdauer: key = "marathoner" + case .beweglichkeit: key = "akrobat" + case .durchhaltevermoegen: key = "stoiker" + case .willenskraft: key = "eiserner_wille" + } + return SeedData.characterLabels.first(where: { $0.key == key }) + ?? SeedData.characterLabels.first(where: { $0.key == "allrounder" }) + ?? SeedData.characterLabels[0] + } + + private func twoStatLabel(_ stat1: StatKey, _ stat2: StatKey) -> CharacterLabel { + let combo = Set([stat1, stat2]) + let key: String + switch combo { + case Set([.kraft, .willenskraft]): key = "tank" + case Set([.kraft, .ausdauer]): key = "powerlaeufer" + case Set([.kraft, .beweglichkeit]): key = "kraftakrobat" + case Set([.ausdauer, .durchhaltevermoegen]): key = "langlaeufer" + case Set([.beweglichkeit, .willenskraft]): key = "praezisionskuenstler" + case Set([.durchhaltevermoegen, .willenskraft]): key = "unermuedlicher" + default: + key = "allrounder" + } + + return SeedData.characterLabels.first(where: { $0.key == key }) + ?? SeedData.characterLabels.first(where: { $0.key == "allrounder" }) + ?? SeedData.characterLabels[0] + } + + // MARK: - Dungeon + + func rollDungeonSpawnIfNeeded() { + guard !hasRolledDungeonSpawn else { return } + hasRolledDungeonSpawn = true + + state.dungeonProgress.activeDungeon = false + if Double.random(in: 0...1) < 0.05 { + state.dungeonProgress.activeDungeon = true + } + saveState() + } + + func openDungeon() { + state.dungeonProgress.globalLevel += 1 + + let monster = SeedData.dungeon.monsters.randomElement() ?? SeedData.dungeon.monsters[0] + let level = max(1, state.dungeonProgress.globalLevel) + + let scaledHp = Int(round(Double(monster.baseHp) * (1 + 0.18 * Double(level - 1)))) + let scaledDamage = Int(round(Double(monster.baseDmg) * (1 + 0.15 * Double(level - 1)))) + + let equipment = calculateEquipmentStats(character: state.character) + let scaledPlayerMaxHP = Int(round(Double(state.character.combat.hpMax) * (1 + (Double(max(0, equipment.schutz)) / 100.0)))) + + activeDungeon = DungeonEncounter( + dungeon: SeedData.dungeon, + monster: monster, + level: level, + monsterHpMax: scaledHp, + monsterHp: scaledHp, + monsterBaseDamage: scaledDamage, + playerHpMax: scaledPlayerMaxHP, + playerHp: scaledPlayerMaxHP, + attack: equipment.angriff, + protection: equipment.schutz + ) + + saveState() + } + + func performDungeonAction(task: DungeonTask, reps: Int) { + guard var encounter = activeDungeon else { return } + + let clampedReps = max(1, min(999, reps)) + let baseDamage = task.baseDamage * clampedReps + + let playerDamage = Int(round(Double(baseDamage) * (1 + (Double(encounter.attack) / 100.0)))) + let mitigation = max(0, min(80, encounter.protection)) + let monsterCounter = Int(round(Double(encounter.monsterBaseDamage) * (1 - Double(mitigation) / 100.0))) + + encounter.monsterHp = max(0, encounter.monsterHp - playerDamage) + encounter.playerHp = max(0, encounter.playerHp - monsterCounter) + + activeDungeon = encounter + + if encounter.monsterHp <= 0 { + applyDungeonResult(outcome: .win, finalPlayerHP: encounter.playerHp) + activeDungeon = nil + selectedTab = .exercises + showToast(state.settings.language == .de + ? "Sieg! +\(SeedData.dungeon.rewards.xp) Mana, +\(SeedData.dungeon.rewards.manaStones) Mana-Steine" + : "Victory! +\(SeedData.dungeon.rewards.xp) mana, +\(SeedData.dungeon.rewards.manaStones) mana stones") + return + } + + if encounter.playerHp <= 0 { + applyDungeonResult(outcome: .loss, finalPlayerHP: encounter.playerHp) + activeDungeon = nil + selectedTab = .exercises + showToast(state.settings.language == .de ? "Niederlage!" : "Defeat", isPenalty: true) + return + } + + showToast(state.settings.language == .de + ? "Du triffst für \(playerDamage). Du erleidest \(monsterCounter)." + : "You hit for \(playerDamage). You take \(monsterCounter).") + } + + private enum DungeonOutcome { + case win + case loss + } + + private func applyDungeonResult(outcome: DungeonOutcome, finalPlayerHP: Int) { + let clampedHP = max(0, min(state.character.combat.hpMax, finalPlayerHP)) + state.character.combat.hpCurrent = clampedHP + + if outcome == .win { + state.character.mana += SeedData.dungeon.rewards.xp + let stonesToAdd = SeedData.dungeon.rewards.manaStones + let currentConsumables = state.character.inventory.filter { $0.type == .consumable }.count + let availableSlots = max(0, 5 - currentConsumables) + let gain = min(stonesToAdd, availableSlots) + + if gain > 0 { + for _ in 0.. Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(state) + } + + func importData(_ data: Data) throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let imported = try decoder.decode(GameState.self, from: data) + + focusTickTimer?.invalidate() + focusTickTimer = nil + + state = imported + state.vibe.timer.isSessionActive = false + state.vibe.timer.startTime = nil + state.vibe.timer.endTime = nil + state.vibe.linkedQuest = nil + state.vibe.currentSessionLabel = nil + + selectedTab = .exercises + activeDungeon = nil + showOnboarding = !state.tutorial.completed + + saveState() + } + + // MARK: - Helpers + + func labelKeyForExercise(_ nameKey: String) -> String { + switch nameKey { + case "read_15pages", "read_for_school": + return "focus_label_reading" + case "learn_something", "learn_new_skill", "learn_language", "learn_math", "learn_science": + return "focus_label_learning" + default: + return "focus_label_learning" + } + } + + func isRestDay() -> Bool { + guard state.settings.goal != .sick else { return false } + + let dayOfWeek = Calendar.current.component(.weekday, from: Date()) - 1 + let activeRestDays: [Int] + + switch state.settings.restDays { + case 1: activeRestDays = [0] + case 2: activeRestDays = [2, 6] + case 3: activeRestDays = [0, 2, 4] + default: activeRestDays = [] + } + + return activeRestDays.contains(dayOfWeek) + } + + private func showToast(_ text: String, isPenalty: Bool = false) { + toast = ToastMessage(text: text, isPenalty: isPenalty) + let toastID = toast?.id + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self else { return } + if self.toast?.id == toastID { + self.toast = nil + } + } + } + + private func loadState() { + guard FileManager.default.fileExists(atPath: saveURL.path) else { + state = SeedData.defaultState() + generateDailyQuestsIfNeeded(forceRegenerate: true) + saveState() + return + } + + do { + let data = try Data(contentsOf: saveURL) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + state = try decoder.decode(GameState.self, from: data) + } catch { + print("Failed to load state: \(error)") + state = SeedData.defaultState() + generateDailyQuestsIfNeeded(forceRegenerate: true) + saveState() + } + } + + private func saveState() { + do { + let directory = saveURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(state) + try data.write(to: saveURL, options: [.atomic]) + } catch { + print("Failed to save state: \(error)") + } + } + + private static func makeSaveURL() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + return base.appendingPathComponent("DailyQuest/state.json") + } +} + +struct FeatureTip: Identifiable, Hashable { + let id = UUID() + let key: String + let title: String + let text: String + + static let exercises = FeatureTip( + key: "exercises", + title: "Daily Quests", + text: "Schließe alle Daily Quests bis Mitternacht ab, um Strafen zu vermeiden." + ) + + static let focus = FeatureTip( + key: "focus", + title: "Fokus", + text: "Längere Fokus-Sessions geben Gold, Mana und Durchhaltevermögen." + ) + + static let character = FeatureTip( + key: "character", + title: "Charakter", + text: "Hier siehst du Stats, Inventar, Streak und deinen Fortschritt." + ) + + static let shop = FeatureTip( + key: "shop", + title: "Shop", + text: "Kaufe Ausrüstung für Dungeon-Kämpfe und nützliche Verbrauchsitems." + ) + + static let extraQuest = FeatureTip( + key: "extraQuest", + title: "Extra-Quest", + text: "Hohes Risiko, hohe Belohnung. Scheitern hat harte Konsequenzen." + ) +} diff --git a/DailyQuest-SwiftApp/DailyQuest/Models.swift b/DailyQuest-SwiftApp/DailyQuest/Models.swift new file mode 100755 index 0000000..b2e1fee --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/Models.swift @@ -0,0 +1,373 @@ +import Foundation + +enum AppLanguage: String, Codable, CaseIterable, Identifiable { + case de + case en + + var id: String { rawValue } + + var title: String { + switch self { + case .de: return "Deutsch" + case .en: return "English" + } + } +} + +enum AppTheme: String, Codable, CaseIterable, Identifiable { + case dark + case light + + var id: String { rawValue } +} + +enum MainTab: String, Codable, CaseIterable, Identifiable { + case exercises + case focus + case character + case shop + case extraQuest + + var id: String { rawValue } +} + +enum TrainingGoal: String, Codable, CaseIterable, Identifiable { + case muscle + case endurance + case fatloss + case kraft_abnehmen + case calisthenics + case sick + case restday + case learning + case general_workout + + var id: String { rawValue } +} + +enum ExerciseType: String, Codable { + case reps + case time + case check + case focus + case link +} + +enum StatKey: String, Codable, CaseIterable, Hashable, Identifiable { + case kraft + case ausdauer + case beweglichkeit + case durchhaltevermoegen + case willenskraft + + var id: String { rawValue } +} + +struct ExerciseTemplate: Codable, Identifiable, Hashable { + let id: Int + let category: TrainingGoal + let nameKey: String + let type: ExerciseType + let baseValue: Int + let mana: Int + let gold: Int + let statPoints: [StatKey: Double]? + let directStatGain: [StatKey: Double]? + let timerDuration: Int? +} + +struct DailyQuest: Codable, Identifiable, Hashable { + let id: Int + var date: String + var nameKey: String + var type: ExerciseType + var target: Int + var manaReward: Int + var goldReward: Int + var completed: Bool + var goal: TrainingGoal +} + +enum InventoryItemType: String, Codable, CaseIterable { + case weapon + case armor + case consumable + case streak_freeze +} + +struct ShopItem: Codable, Identifiable, Hashable { + let id: Int + var name: String + var description: String + var cost: Int + var type: InventoryItemType + var bonus: [String: Int]? + var effect: [String: Int]? + var iconSymbol: String? +} + +struct ItemInstance: Codable, Identifiable, Hashable { + let id: UUID + var baseItemID: Int + var name: String + var description: String + var cost: Int + var type: InventoryItemType + var bonus: [String: Int]? + var effect: [String: Int]? + var iconSymbol: String? + + init(from item: ShopItem) { + self.id = UUID() + self.baseItemID = item.id + self.name = item.name + self.description = item.description + self.cost = item.cost + self.type = item.type + self.bonus = item.bonus + self.effect = item.effect + self.iconSymbol = item.iconSymbol + } + + init( + baseItemID: Int, + name: String, + description: String, + cost: Int, + type: InventoryItemType, + bonus: [String: Int]? = nil, + effect: [String: Int]? = nil, + iconSymbol: String? = nil + ) { + self.id = UUID() + self.baseItemID = baseItemID + self.name = name + self.description = description + self.cost = cost + self.type = type + self.bonus = bonus + self.effect = effect + self.iconSymbol = iconSymbol + } +} + +struct EquipmentSet: Codable, Hashable { + var weapons: [ItemInstance] + var armor: ItemInstance? +} + +struct CombatStats: Codable, Hashable { + var attack: Int + var protection: Int + var hpMax: Int + var hpCurrent: Int +} + +enum WeightDirection: String, Codable, CaseIterable, Identifiable { + case lose + case gain + + var id: String { rawValue } +} + +enum AchievementKey: String, Codable, CaseIterable, Hashable, Identifiable { + case level + case quests + case gold + case shop + case strength + case streak + case focus_time + + var id: String { rawValue } +} + +struct AchievementProgress: Codable, Hashable { + var tier: Int + var claimable: Bool +} + +struct Character: Codable, Hashable { + var id: Int + var name: String + var level: Int + var mana: Int + var manaToNextLevel: Int + var gold: Int + var stats: [StatKey: Double] + var statProgress: [StatKey: Double] + var equipment: EquipmentSet + var inventory: [ItemInstance] + var weightTrackingEnabled: Bool + var targetWeight: Double? + var weightDirection: WeightDirection + var combat: CombatStats + var achievements: [AchievementKey: AchievementProgress] + var totalGoldEarned: Int + var totalQuestsCompleted: Int + var totalItemsPurchased: Int +} + +struct StreakData: Codable, Hashable { + var streak: Int + var lastDate: String? +} + +struct GameSettings: Codable, Hashable { + var id: Int + var language: AppLanguage + var theme: AppTheme + var difficulty: Int + var goal: TrainingGoal + var restDays: Int +} + +struct ExtraQuestDefinition: Codable, Identifiable, Hashable { + let id: Int + let nameKey: String + let mana: Int + let gold: Int + let statPoints: [StatKey: Double] +} + +struct ActiveExtraQuest: Codable, Hashable, Identifiable { + var id: Int + var nameKey: String + var manaReward: Int + var goldReward: Int + var startTime: Date + var deadline: Date + var completed: Bool +} + +struct WeightEntry: Codable, Identifiable, Hashable { + var id: UUID + var date: String + var time: Date + var weight: Double +} + +enum LinkedFocusType: String, Codable { + case quest + case free +} + +struct LinkedFocusQuest: Codable, Hashable { + var type: LinkedFocusType + var id: Int + var labelKey: String? +} + +enum FocusMode: String, Codable, CaseIterable, Identifiable { + case pomodoro + case stopwatch + + var id: String { rawValue } +} + +struct FocusTimerState: Codable, Hashable { + var mode: FocusMode + var startTime: Date? + var endTime: Date? + var pomodoroDuration: Int + var elapsedSeconds: Int + var isSessionActive: Bool +} + +struct FocusSession: Codable, Identifiable, Hashable { + var id: UUID + var date: Date + var duration: Int + var label: String +} + +struct VibeState: Codable, Hashable { + var sessions: [FocusSession] + var timer: FocusTimerState + var linkedQuest: LinkedFocusQuest? + var currentSessionLabel: String? + var customLabels: [String] +} + +struct TutorialState: Codable, Hashable { + var completed: Bool + var seenFeatures: Set +} + +struct DungeonProgress: Codable, Hashable { + var globalLevel: Int + var activeDungeon: Bool +} + +struct DungeonMonster: Codable, Identifiable, Hashable { + let id: String + let name: String + let imageName: String + let baseHp: Int + let baseDmg: Int +} + +struct DungeonTask: Codable, Identifiable, Hashable { + let id: String + let label: String + let baseDamage: Int +} + +struct DungeonRewards: Codable, Hashable { + let xp: Int + let manaStones: Int +} + +struct DungeonDefinition: Codable, Identifiable, Hashable { + let id: String + let name: String + let monsters: [DungeonMonster] + let tasks: [DungeonTask] + let rewards: DungeonRewards +} + +struct DungeonEncounter: Identifiable, Hashable { + let id = UUID() + var dungeon: DungeonDefinition + var monster: DungeonMonster + var level: Int + var monsterHpMax: Int + var monsterHp: Int + var monsterBaseDamage: Int + var playerHpMax: Int + var playerHp: Int + var attack: Int + var protection: Int +} + +struct GameState: Codable, Hashable { + var settings: GameSettings + var character: Character + var streak: StreakData + var dailyQuests: [DailyQuest] + var nextQuestID: Int + var extraQuest: ActiveExtraQuest? + var exercises: [ExerciseTemplate] + var shopItems: [ShopItem] + var weightEntries: [WeightEntry] + var vibe: VibeState + var tutorial: TutorialState + var dungeonProgress: DungeonProgress + var lastPenaltyCheck: String? +} + +struct AchievementDefinition: Hashable { + let key: AchievementKey + let icon: String + let nameKey: String + let descriptionKey: String + let tiers: [Int] +} + +struct CharacterLabel: Hashable { + let key: String + let name: String + let description: String + let colorHex: String + let stats: [String] +} diff --git a/DailyQuest-SwiftApp/DailyQuest/OnboardingView.swift b/DailyQuest-SwiftApp/DailyQuest/OnboardingView.swift new file mode 100755 index 0000000..dabd09d --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/OnboardingView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct OnboardingView: View { + @EnvironmentObject private var store: GameStore + + @State private var name = "" + @State private var hasEquipment = false + @State private var goal: TrainingGoal = .muscle + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(store.state.settings.language == .de ? "Willkommen bei DailyQuest" : "Welcome to DailyQuest") + .font(.largeTitle.bold()) + + Text(store.state.settings.language == .de + ? "Richte deinen Hunter ein. Danach werden tägliche Quests, Fokus-Training und Fortschritt automatisch für dich erzeugt." + : "Set up your hunter. After that, daily quests, focus training and progression are generated automatically.") + .foregroundStyle(.secondary) + + sectionCard { + TextField(store.t("name"), text: $name) + .textFieldStyle(.roundedBorder) + } + + sectionCard { + Picker(store.t("language"), selection: Binding( + get: { store.state.settings.language }, + set: { store.updateLanguage($0) } + )) { + ForEach(AppLanguage.allCases) { language in + Text(language.title).tag(language) + } + } + + Picker(store.t("goal"), selection: $goal) { + Text(store.state.settings.language == .de ? "Muskelaufbau" : "Muscle").tag(TrainingGoal.muscle) + Text(store.state.settings.language == .de ? "Ausdauer" : "Endurance").tag(TrainingGoal.endurance) + Text(store.state.settings.language == .de ? "Abnehmen" : "Fat Loss").tag(TrainingGoal.fatloss) + Text(store.state.settings.language == .de ? "Kraft + Abnehmen" : "Strength + Fat Loss").tag(TrainingGoal.kraft_abnehmen) + Text(store.state.settings.language == .de ? "Lernen" : "Learning").tag(TrainingGoal.learning) + Text("Calisthenics").tag(TrainingGoal.calisthenics) + Text(store.state.settings.language == .de ? "Krank" : "Sick").tag(TrainingGoal.sick) + } + + Toggle(store.state.settings.language == .de ? "Ich trainiere mit Equipment" : "I train with equipment", isOn: $hasEquipment) + .disabled(goal != .muscle) + .opacity(goal == .muscle ? 1 : 0.45) + } + + sectionCard { + Toggle(store.t("theme"), isOn: Binding( + get: { store.state.settings.theme == .light }, + set: { store.updateTheme($0 ? .light : .dark) } + )) + + VStack(alignment: .leading, spacing: 4) { + Text(store.t("difficulty")) + Slider( + value: Binding( + get: { Double(store.state.settings.difficulty) }, + set: { store.updateDifficulty(Int($0.rounded())) } + ), + in: 1...5, + step: 1 + ) + Text("\(store.state.settings.difficulty)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Button { + store.completeOnboarding(name: name, hasEquipment: hasEquipment, trainingGoal: goal) + } label: { + Text(store.state.settings.language == .de ? "Abenteuer starten" : "Start Adventure") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + .padding(16) + } + .onAppear { + name = store.state.character.name == "Unknown Hunter" ? "" : store.state.character.name + goal = store.state.settings.goal + } + } + } + + @ViewBuilder + private func sectionCard(@ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 12, content: content) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/SeedData.swift b/DailyQuest-SwiftApp/DailyQuest/SeedData.swift new file mode 100755 index 0000000..d438a6a --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/SeedData.swift @@ -0,0 +1,470 @@ +import Foundation + +enum SeedData { + static func statDict(_ pairs: (StatKey, Double)...) -> [StatKey: Double] { + var dict: [StatKey: Double] = [:] + for (key, value) in pairs { + dict[key] = value + } + return dict + } + + static let exercises: [ExerciseTemplate] = [ + // muscle + ExerciseTemplate(id: 101, category: .muscle, nameKey: "bicep_curls", type: .reps, baseValue: 10, mana: 20, gold: 6, statPoints: statDict((.kraft, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 102, category: .muscle, nameKey: "dumbbell_rows", type: .reps, baseValue: 8, mana: 22, gold: 7, statPoints: statDict((.kraft, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 103, category: .muscle, nameKey: "push_ups_narrow", type: .reps, baseValue: 8, mana: 20, gold: 5, statPoints: statDict((.kraft, 1), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 104, category: .muscle, nameKey: "weighted_squats", type: .reps, baseValue: 10, mana: 25, gold: 7, statPoints: statDict((.kraft, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 105, category: .muscle, nameKey: "barbell_rows", type: .reps, baseValue: 8, mana: 30, gold: 10, statPoints: statDict((.kraft, 1.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 106, category: .muscle, nameKey: "dumbbell_press", type: .reps, baseValue: 8, mana: 30, gold: 10, statPoints: statDict((.kraft, 1.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 107, category: .muscle, nameKey: "shoulder_press", type: .reps, baseValue: 8, mana: 25, gold: 8, statPoints: statDict((.kraft, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 108, category: .muscle, nameKey: "deadlifts", type: .reps, baseValue: 5, mana: 40, gold: 15, statPoints: statDict((.kraft, 2)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 109, category: .muscle, nameKey: "pistol_squats", type: .reps, baseValue: 5, mana: 28, gold: 40, statPoints: statDict((.kraft, 1.5), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 110, category: .muscle, nameKey: "pike_push_ups", type: .reps, baseValue: 8, mana: 25, gold: 35, statPoints: statDict((.kraft, 1), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 111, category: .muscle, nameKey: "diamond_push_ups", type: .reps, baseValue: 6, mana: 28, gold: 40, statPoints: statDict((.kraft, 1.5), (.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 112, category: .muscle, nameKey: "single_leg_glute_bridge", type: .reps, baseValue: 10, mana: 30, gold: 38, statPoints: statDict((.kraft, 1.5), (.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + + // endurance + ExerciseTemplate(id: 201, category: .endurance, nameKey: "burpees", type: .reps, baseValue: 10, mana: 35, gold: 12, statPoints: statDict((.ausdauer, 1), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 202, category: .endurance, nameKey: "jumping_jacks", type: .time, baseValue: 60, mana: 20, gold: 6, statPoints: statDict((.ausdauer, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 203, category: .endurance, nameKey: "high_knees", type: .time, baseValue: 45, mana: 20, gold: 7, statPoints: statDict((.ausdauer, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 204, category: .endurance, nameKey: "dumbbell_swings", type: .reps, baseValue: 15, mana: 23, gold: 32, statPoints: statDict((.kraft, 0.5), (.ausdauer, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 205, category: .endurance, nameKey: "jump_squats", type: .reps, baseValue: 12, mana: 22, gold: 29, statPoints: statDict((.beweglichkeit, 1.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 206, category: .endurance, nameKey: "pistol_squats", type: .reps, baseValue: 5, mana: 28, gold: 40, statPoints: statDict((.kraft, 1.5), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 207, category: .endurance, nameKey: "leg_raises", type: .reps, baseValue: 12, mana: 26, gold: 34, statPoints: statDict((.kraft, 1), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 208, category: .endurance, nameKey: "russian_twists", type: .reps, baseValue: 20, mana: 18, gold: 24, statPoints: statDict((.kraft, 0.5), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 210, category: .endurance, nameKey: "hollow_body_hold", type: .time, baseValue: 45, mana: 20, gold: 27, statPoints: statDict((.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 213, category: .endurance, nameKey: "single_leg_glute_bridge", type: .reps, baseValue: 10, mana: 30, gold: 38, statPoints: statDict((.kraft, 1.5), (.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 214, category: .endurance, nameKey: "hula_hoop", type: .time, baseValue: 600, mana: 40, gold: 15, statPoints: statDict((.ausdauer, 1), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 215, category: .endurance, nameKey: "stair_climbing", type: .time, baseValue: 900, mana: 50, gold: 20, statPoints: statDict((.ausdauer, 2)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 216, category: .endurance, nameKey: "jogging", type: .time, baseValue: 1200, mana: 60, gold: 25, statPoints: statDict((.ausdauer, 2.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 217, category: .endurance, nameKey: "running", type: .time, baseValue: 1800, mana: 80, gold: 35, statPoints: statDict((.ausdauer, 3)), directStatGain: nil, timerDuration: nil), + + // fatloss + ExerciseTemplate(id: 301, category: .fatloss, nameKey: "mountain_climbers", type: .time, baseValue: 30, mana: 30, gold: 9, statPoints: statDict((.ausdauer, 1), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 302, category: .fatloss, nameKey: "jump_squats", type: .reps, baseValue: 15, mana: 25, gold: 8, statPoints: statDict((.ausdauer, 1), (.kraft, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 303, category: .fatloss, nameKey: "interval_sprint", type: .check, baseValue: 1, mana: 50, gold: 20, statPoints: statDict((.ausdauer, 2)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 304, category: .fatloss, nameKey: "walking_lunges", type: .reps, baseValue: 20, mana: 20, gold: 6, statPoints: statDict((.ausdauer, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 305, category: .fatloss, nameKey: "shadowboxing", type: .time, baseValue: 120, mana: 30, gold: 10, statPoints: statDict((.ausdauer, 1), (.kraft, 0.5)), directStatGain: nil, timerDuration: nil), + + // restday + ExerciseTemplate(id: 401, category: .restday, nameKey: "walk_30min", type: .check, baseValue: 1, mana: 15, gold: 5, statPoints: nil, directStatGain: statDict((.durchhaltevermoegen, 1)), timerDuration: nil), + ExerciseTemplate(id: 402, category: .restday, nameKey: "read_15pages", type: .focus, baseValue: 1, mana: 25, gold: 10, statPoints: nil, directStatGain: statDict((.willenskraft, 1)), timerDuration: 15), + ExerciseTemplate(id: 403, category: .restday, nameKey: "healthy_snack", type: .check, baseValue: 1, mana: 10, gold: 10, statPoints: statDict((.willenskraft, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 404, category: .restday, nameKey: "stretch_10min", type: .check, baseValue: 1, mana: 10, gold: 5, statPoints: statDict((.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 405, category: .restday, nameKey: "learn_something", type: .focus, baseValue: 1, mana: 40, gold: 15, statPoints: nil, directStatGain: statDict((.willenskraft, 1.5)), timerDuration: 25), + + // learning + ExerciseTemplate(id: 801, category: .learning, nameKey: "learn_new_skill", type: .focus, baseValue: 1, mana: 40, gold: 15, statPoints: statDict((.willenskraft, 1.5)), directStatGain: nil, timerDuration: 25), + ExerciseTemplate(id: 802, category: .learning, nameKey: "read_for_school", type: .focus, baseValue: 1, mana: 25, gold: 10, statPoints: statDict((.willenskraft, 1)), directStatGain: nil, timerDuration: 15), + ExerciseTemplate(id: 803, category: .learning, nameKey: "learn_language", type: .focus, baseValue: 1, mana: 40, gold: 15, statPoints: statDict((.willenskraft, 1.5)), directStatGain: nil, timerDuration: 25), + ExerciseTemplate(id: 804, category: .learning, nameKey: "learn_math", type: .focus, baseValue: 1, mana: 45, gold: 18, statPoints: statDict((.willenskraft, 2)), directStatGain: nil, timerDuration: 25), + ExerciseTemplate(id: 805, category: .learning, nameKey: "learn_science", type: .focus, baseValue: 1, mana: 45, gold: 18, statPoints: statDict((.willenskraft, 2)), directStatGain: nil, timerDuration: 25), + + // calisthenics + ExerciseTemplate(id: 601, category: .calisthenics, nameKey: "push_ups_normal", type: .reps, baseValue: 15, mana: 25, gold: 8, statPoints: statDict((.kraft, 1), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 602, category: .calisthenics, nameKey: "push_ups_narrow", type: .reps, baseValue: 12, mana: 22, gold: 7, statPoints: statDict((.kraft, 1), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 603, category: .calisthenics, nameKey: "push_ups_wide", type: .reps, baseValue: 12, mana: 22, gold: 7, statPoints: statDict((.kraft, 1), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 604, category: .calisthenics, nameKey: "squats", type: .reps, baseValue: 20, mana: 20, gold: 6, statPoints: statDict((.kraft, 0.5), (.ausdauer, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 605, category: .calisthenics, nameKey: "situps", type: .reps, baseValue: 15, mana: 18, gold: 5, statPoints: statDict((.kraft, 0.5), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 606, category: .calisthenics, nameKey: "burpees", type: .reps, baseValue: 8, mana: 30, gold: 10, statPoints: statDict((.ausdauer, 1), (.kraft, 0.5), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 607, category: .calisthenics, nameKey: "pistol_squats", type: .reps, baseValue: 5, mana: 28, gold: 40, statPoints: statDict((.kraft, 1.5), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 608, category: .calisthenics, nameKey: "pike_push_ups", type: .reps, baseValue: 8, mana: 25, gold: 35, statPoints: statDict((.kraft, 1), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 609, category: .calisthenics, nameKey: "diamond_push_ups", type: .reps, baseValue: 6, mana: 28, gold: 40, statPoints: statDict((.kraft, 1.5), (.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 610, category: .calisthenics, nameKey: "leg_raises", type: .reps, baseValue: 12, mana: 26, gold: 34, statPoints: statDict((.kraft, 1), (.beweglichkeit, 1)), directStatGain: nil, timerDuration: nil), + + // sick + ExerciseTemplate(id: 701, category: .sick, nameKey: "drink_tea", type: .check, baseValue: 1, mana: 10, gold: 2, statPoints: statDict((.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 702, category: .sick, nameKey: "short_walk", type: .check, baseValue: 1, mana: 15, gold: 3, statPoints: statDict((.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 703, category: .sick, nameKey: "stretch_5min", type: .check, baseValue: 1, mana: 5, gold: 1, statPoints: statDict((.beweglichkeit, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 704, category: .sick, nameKey: "take_nap", type: .check, baseValue: 1, mana: 10, gold: 2, statPoints: statDict((.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 705, category: .sick, nameKey: "take_medicine", type: .check, baseValue: 1, mana: 5, gold: 5, statPoints: statDict((.willenskraft, 0.5)), directStatGain: nil, timerDuration: nil), + + // kraft_abnehmen + ExerciseTemplate(id: 501, category: .kraft_abnehmen, nameKey: "plank", type: .time, baseValue: 30, mana: 25, gold: 8, statPoints: statDict((.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 502, category: .kraft_abnehmen, nameKey: "situps", type: .reps, baseValue: 15, mana: 20, gold: 6, statPoints: statDict((.kraft, 0.5), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 503, category: .kraft_abnehmen, nameKey: "knee_push_ups", type: .reps, baseValue: 10, mana: 18, gold: 5, statPoints: statDict((.kraft, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 504, category: .kraft_abnehmen, nameKey: "tricep_dips_chair", type: .reps, baseValue: 10, mana: 22, gold: 7, statPoints: statDict((.kraft, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 505, category: .kraft_abnehmen, nameKey: "lunges", type: .reps, baseValue: 16, mana: 20, gold: 6, statPoints: statDict((.kraft, 0.5), (.beweglichkeit, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 506, category: .kraft_abnehmen, nameKey: "sumo_squats", type: .reps, baseValue: 12, mana: 25, gold: 8, statPoints: statDict((.kraft, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 507, category: .kraft_abnehmen, nameKey: "glute_bridges", type: .reps, baseValue: 15, mana: 18, gold: 5, statPoints: statDict((.kraft, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 508, category: .kraft_abnehmen, nameKey: "tricep_extensions", type: .reps, baseValue: 12, mana: 20, gold: 7, statPoints: statDict((.kraft, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 509, category: .kraft_abnehmen, nameKey: "side_plank", type: .time, baseValue: 20, mana: 22, gold: 7, statPoints: statDict((.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 510, category: .kraft_abnehmen, nameKey: "pilates_5_exercises", type: .check, baseValue: 1, mana: 60, gold: 20, statPoints: statDict((.beweglichkeit, 1.5), (.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 511, category: .kraft_abnehmen, nameKey: "russian_twists", type: .reps, baseValue: 20, mana: 18, gold: 24, statPoints: statDict((.kraft, 0.5), (.durchhaltevermoegen, 0.5)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 512, category: .kraft_abnehmen, nameKey: "hollow_body_hold", type: .time, baseValue: 45, mana: 20, gold: 27, statPoints: statDict((.durchhaltevermoegen, 1)), directStatGain: nil, timerDuration: nil), + ExerciseTemplate(id: 513, category: .kraft_abnehmen, nameKey: "reverse_flys", type: .reps, baseValue: 12, mana: 17, gold: 21, statPoints: statDict((.kraft, 0.5)), directStatGain: nil, timerDuration: nil), + + // general workout + ExerciseTemplate(id: 901, category: .general_workout, nameKey: "general_workout", type: .check, baseValue: 1, mana: 100, gold: 100, statPoints: statDict((.kraft, 2), (.ausdauer, 2), (.beweglichkeit, 1), (.durchhaltevermoegen, 2), (.willenskraft, 1)), directStatGain: nil, timerDuration: nil) + ] + + static let extraQuestPool: [ExtraQuestDefinition] = [ + ExtraQuestDefinition(id: 601, nameKey: "extra_clean_room", mana: 400, gold: 200, statPoints: statDict((.durchhaltevermoegen, 2), (.willenskraft, 2))), + ExtraQuestDefinition(id: 602, nameKey: "extra_walk_hour", mana: 500, gold: 250, statPoints: statDict((.ausdauer, 2), (.durchhaltevermoegen, 3))), + ExtraQuestDefinition(id: 603, nameKey: "extra_learn_hour", mana: 600, gold: 300, statPoints: statDict((.willenskraft, 4))), + ExtraQuestDefinition(id: 604, nameKey: "extra_no_sweets", mana: 450, gold: 220, statPoints: statDict((.willenskraft, 3), (.durchhaltevermoegen, 1))), + ExtraQuestDefinition(id: 605, nameKey: "extra_go_jogging", mana: 700, gold: 350, statPoints: statDict((.ausdauer, 4), (.durchhaltevermoegen, 2))), + ExtraQuestDefinition(id: 606, nameKey: "extra_finish_project", mana: 800, gold: 400, statPoints: statDict((.willenskraft, 3), (.durchhaltevermoegen, 2))), + ExtraQuestDefinition(id: 607, nameKey: "extra_do_hobby", mana: 550, gold: 270, statPoints: statDict((.willenskraft, 2), (.beweglichkeit, 1))), + ExtraQuestDefinition(id: 608, nameKey: "extra_meditate", mana: 350, gold: 180, statPoints: statDict((.willenskraft, 3))), + ExtraQuestDefinition(id: 609, nameKey: "extra_call_friend", mana: 300, gold: 150, statPoints: statDict((.willenskraft, 2))), + ExtraQuestDefinition(id: 610, nameKey: "extra_digital_detox", mana: 750, gold: 380, statPoints: statDict((.willenskraft, 5))) + ] + + static let shopItems: [ShopItem] = [ + ShopItem(id: 101, name: "Trainings-Schwert", description: "+5 Angriff", cost: 100, type: .weapon, bonus: ["angriff": 5], effect: nil, iconSymbol: nil), + ShopItem(id: 102, name: "Stahl-Klinge", description: "+15 Angriff", cost: 400, type: .weapon, bonus: ["angriff": 15], effect: nil, iconSymbol: nil), + ShopItem(id: 103, name: "Ninja-Sterne", description: "+25 Angriff", cost: 850, type: .weapon, bonus: ["angriff": 25], effect: nil, iconSymbol: nil), + ShopItem(id: 104, name: "Meister-Hantel", description: "Legendär. +40 Angriff", cost: 1500, type: .weapon, bonus: ["angriff": 40], effect: nil, iconSymbol: nil), + ShopItem(id: 105, name: "Magier-Stab", description: "Episch. +60 Angriff", cost: 2500, type: .weapon, bonus: ["angriff": 60], effect: nil, iconSymbol: nil), + ShopItem(id: 106, name: "Himmels-Speer", description: "Mythisch. +85 Angriff", cost: 4000, type: .weapon, bonus: ["angriff": 85], effect: nil, iconSymbol: nil), + ShopItem(id: 107, name: "Dämonen-Klinge", description: "Verflucht. +120 Angriff", cost: 6500, type: .weapon, bonus: ["angriff": 120], effect: nil, iconSymbol: nil), + ShopItem(id: 108, name: "Götter-Hammer", description: "Göttlich. +175 Angriff", cost: 10000, type: .weapon, bonus: ["angriff": 175], effect: nil, iconSymbol: nil), + + ShopItem(id: 201, name: "Leder-Bandagen", description: "+5 Schutz", cost: 100, type: .armor, bonus: ["schutz": 5], effect: nil, iconSymbol: nil), + ShopItem(id: 202, name: "Kettenhemd", description: "+15 Schutz", cost: 400, type: .armor, bonus: ["schutz": 15], effect: nil, iconSymbol: nil), + ShopItem(id: 203, name: "Spiegel-Schild", description: "+25 Schutz", cost: 850, type: .armor, bonus: ["schutz": 25], effect: nil, iconSymbol: nil), + ShopItem(id: 204, name: "Titan-Panzer", description: "Legendär. +40 Schutz", cost: 1500, type: .armor, bonus: ["schutz": 40], effect: nil, iconSymbol: nil), + ShopItem(id: 205, name: "Drachenrobe", description: "Episch. +60 Schutz", cost: 2500, type: .armor, bonus: ["schutz": 60], effect: nil, iconSymbol: nil), + ShopItem(id: 206, name: "Runen-Weste", description: "Mythisch. +85 Schutz", cost: 4000, type: .armor, bonus: ["schutz": 85], effect: nil, iconSymbol: nil), + ShopItem(id: 207, name: "Kristall-Harnisch", description: "Unzerbrechlich. +120 Schutz", cost: 6500, type: .armor, bonus: ["schutz": 120], effect: nil, iconSymbol: nil), + ShopItem(id: 208, name: "Götter-Aura", description: "Göttlich. +175 Schutz", cost: 10000, type: .armor, bonus: ["schutz": 175], effect: nil, iconSymbol: nil), + + ShopItem(id: 301, name: "Kleiner Mana-Stein", description: "Stellt 50 Mana wieder her.", cost: 65, type: .consumable, bonus: nil, effect: ["mana": 50], iconSymbol: nil), + ShopItem(id: 302, name: "Mittlerer Mana-Stein", description: "Stellt 250 Mana wieder her.", cost: 280, type: .consumable, bonus: nil, effect: ["mana": 250], iconSymbol: nil), + ShopItem(id: 303, name: "Großer Mana-Stein", description: "Stellt 1000 Mana wieder her.", cost: 960, type: .consumable, bonus: nil, effect: ["mana": 1000], iconSymbol: nil), + + ShopItem(id: 401, name: "Streak Freeze", description: "Rettet deine Streak einmal, wenn du einen Tag verpasst.", cost: 3000, type: .streak_freeze, bonus: nil, effect: nil, iconSymbol: "snowflake") + ] + + static let achievementDefinitions: [AchievementDefinition] = [ + AchievementDefinition(key: .level, icon: "star.fill", nameKey: "ach_level_name", descriptionKey: "ach_level_desc", tiers: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100]), + AchievementDefinition(key: .quests, icon: "flame.fill", nameKey: "ach_quests_name", descriptionKey: "ach_quests_desc", tiers: [10, 25, 50, 75, 100, 150, 200, 250, 300, 400, 500, 600, 750, 900, 1000]), + AchievementDefinition(key: .gold, icon: "dollarsign.circle.fill", nameKey: "ach_gold_name", descriptionKey: "ach_gold_desc", tiers: [1000, 2500, 5000, 7500, 10000, 15000, 20000, 30000, 40000, 50000, 65000, 80000, 100000, 125000, 150000]), + AchievementDefinition(key: .shop, icon: "cart.fill", nameKey: "ach_shop_name", descriptionKey: "ach_shop_desc", tiers: [1, 2, 4, 6, 8, 10, 12, 15, 20, 25, 30, 35, 40, 45, 50]), + AchievementDefinition(key: .strength, icon: "bolt.fill", nameKey: "ach_strength_name", descriptionKey: "ach_strength_desc", tiers: [10, 13, 16, 19, 22, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70]), + AchievementDefinition(key: .streak, icon: "crown.fill", nameKey: "ach_streak_name", descriptionKey: "ach_streak_desc", tiers: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150]), + AchievementDefinition(key: .focus_time, icon: "brain.head.profile", nameKey: "ach_focus_name", descriptionKey: "ach_focus_desc", tiers: [60, 300, 600, 1500, 3000, 6000, 9000, 12000, 15000, 20000, 25000, 30000, 40000, 50000, 60000]) + ] + + static let dungeon = DungeonDefinition( + id: "forest-trial", + name: "Waldprüfung", + monsters: [ + DungeonMonster(id: "wolf", name: "Schattenwolf", imageName: "wolf", baseHp: 30, baseDmg: 8), + DungeonMonster(id: "bear", name: "Höhlenbär", imageName: "bear", baseHp: 36, baseDmg: 9), + DungeonMonster(id: "zombie", name: "Morast-Zombie", imageName: "zombie", baseHp: 28, baseDmg: 7) + ], + tasks: [ + DungeonTask(id: "pushups", label: "Liegestütze", baseDamage: 10), + DungeonTask(id: "squats", label: "Squats", baseDamage: 6), + DungeonTask(id: "situps", label: "Sit-Ups", baseDamage: 5) + ], + rewards: DungeonRewards(xp: 50, manaStones: 3) + ) + + static let defaultFocusLabels: [String] = ["focus_label_reading", "focus_label_learning", "focus_label_meditating"] + + static let characterLabels: [CharacterLabel] = [ + CharacterLabel(key: "kraftprotz", name: "Kraftprotz", description: "Robust und stark bei schweren Übungen", colorHex: "#8b5cf6", stats: ["kraft"]), + CharacterLabel(key: "marathoner", name: "Marathoner", description: "Ausdauerstark, lange Sets möglich", colorHex: "#4ecdc4", stats: ["ausdauer"]), + CharacterLabel(key: "akrobat", name: "Akrobat", description: "Flink, gute Technik bei Bewegungsübungen", colorHex: "#45b7d1", stats: ["beweglichkeit"]), + CharacterLabel(key: "stoiker", name: "Stoiker", description: "Sehr konstante Leistung über Zeit", colorHex: "#96ceb4", stats: ["durchhaltevermoegen"]), + CharacterLabel(key: "eiserner_wille", name: "Eiserner Wille", description: "Hoher Fokus, verlässliche Completion-Rate", colorHex: "#10b981", stats: ["willenskraft"]), + CharacterLabel(key: "tank", name: "Tank", description: "Stark und belastbar; ideal für schwere Sätze", colorHex: "#ff9ff3", stats: ["kraft", "willenskraft"]), + CharacterLabel(key: "powerlaeufer", name: "Powerläufer", description: "Kraftvoll und ausdauernd zugleich", colorHex: "#54a0ff", stats: ["kraft", "ausdauer"]), + CharacterLabel(key: "kraftakrobat", name: "Kraftakrobat", description: "Stark und technisch beweglich", colorHex: "#5f27cd", stats: ["kraft", "beweglichkeit"]), + CharacterLabel(key: "langlaeufer", name: "Langläufer", description: "Perfekte Ausdauer-Profil", colorHex: "#00d2d3", stats: ["ausdauer", "durchhaltevermoegen"]), + CharacterLabel(key: "praezisionskuenstler", name: "Präzisionskünstler", description: "Kontrolliert & fokussiert", colorHex: "#06b6d4", stats: ["beweglichkeit", "willenskraft"]), + CharacterLabel(key: "unermuedlicher", name: "Unermüdlicher", description: "Hält lange durch, mentale Stärke", colorHex: "#2ed573", stats: ["durchhaltevermoegen", "willenskraft"]), + CharacterLabel(key: "allrounder", name: "Allrounder", description: "Vielseitig, keine Spezialisierung", colorHex: "#636e72", stats: ["balanced"]), + CharacterLabel(key: "neuling", name: "Neuling", description: "Noch nicht genügend Daten für Analyse", colorHex: "#b2bec3", stats: ["insufficient_data"]) + ] + + static let strings: [AppLanguage: [String: String]] = [ + .de: [ + "exercises": "Übungen", + "focus": "Fokus", + "character": "Charakter", + "shop": "Shop", + "extra_quest": "Extra-Quest", + "daily_quests": "Daily Quests", + "free_training": "Freies Training", + "search": "Suchen", + "settings": "Einstellungen", + "achievements": "Erfolge", + "difficulty": "Schwierigkeit", + "goal": "Trainingsziel", + "rest_days": "Rest Days / Woche", + "theme": "Theme", + "name": "Name", + "weight_tracking": "Gewichts-Tracking", + "tracking_enabled": "Tracking aktiv", + "target_weight": "Zielgewicht (kg)", + "goal_lose_weight": "Abnehmen", + "goal_gain_weight": "Zunehmen", + "delete_weight_data": "Alle Gewichtsdaten löschen", + "export": "Exportieren", + "import": "Importieren", + "reset_tutorial": "Intro und Tutorial neu starten", + "streak": "Streak", + "level": "Level", + "mana": "Mana", + "gold": "Gold", + "attack": "Angriff", + "protection": "Schutz", + "base_stats": "Basis-Stats", + "focus_stats": "Fokus-Stats", + "weight_history": "Gewichtsverlauf", + "current": "Aktuell", + "target": "Ziel", + "add_weight": "Neuen Eintrag hinzufügen", + "save": "Speichern", + "inventory": "Inventar", + "equipment": "Ausrüstung", + "shop_buy": "Kaufen", + "sell": "Verkaufen", + "equip": "Ausrüsten", + "unequip": "Ablegen", + "use": "Benutzen", + "extra_start": "Quest annehmen", + "extra_complete": "Quest abschließen", + "extra_task": "AUFGABE", + "extra_time": "ZEITLIMIT", + "start": "Start", + "stop": "Stopp", + "timer": "Timer", + "stopwatch": "Stopuhr", + "focus_label_title": "Worauf konzentrierst du dich?", + "focus_add_label": "Neues Label erstellen", + "focus_new_label": "Neues Label", + "claim": "Abholen", + "restday_info_box": "Heute ist dein Restday! Genieße den Tag, hake entspannt deine Daily Quests ab und gönn dir schöne Dinge.", + "dungeon_spawn": "Dungeon erschienen", + "dungeon_go": "Los!" + ], + .en: [ + "exercises": "Workouts", + "focus": "Focus", + "character": "Character", + "shop": "Shop", + "extra_quest": "Extra Quest", + "daily_quests": "Daily Quests", + "free_training": "Free Training", + "search": "Search", + "settings": "Settings", + "achievements": "Achievements", + "difficulty": "Difficulty", + "goal": "Training Goal", + "rest_days": "Rest Days / Week", + "theme": "Theme", + "name": "Name", + "weight_tracking": "Weight Tracking", + "tracking_enabled": "Enable Tracking", + "target_weight": "Target Weight (kg)", + "goal_lose_weight": "Lose Weight", + "goal_gain_weight": "Gain Weight", + "delete_weight_data": "Delete All Weight Data", + "export": "Export", + "import": "Import", + "reset_tutorial": "Restart intro and tutorial", + "streak": "Streak", + "level": "Level", + "mana": "Mana", + "gold": "Gold", + "attack": "Attack", + "protection": "Protection", + "base_stats": "Base Stats", + "focus_stats": "Focus Stats", + "weight_history": "Weight History", + "current": "Current", + "target": "Target", + "add_weight": "Add New Entry", + "save": "Save", + "inventory": "Inventory", + "equipment": "Equipment", + "shop_buy": "Buy", + "sell": "Sell", + "equip": "Equip", + "unequip": "Unequip", + "use": "Use", + "extra_start": "Accept Quest", + "extra_complete": "Complete Quest", + "extra_task": "TASK", + "extra_time": "TIME LIMIT", + "start": "Start", + "stop": "Stop", + "timer": "Timer", + "stopwatch": "Stopwatch", + "focus_label_title": "What are you focusing on?", + "focus_add_label": "Create new label", + "focus_new_label": "New Label", + "claim": "Claim", + "restday_info_box": "Today is your rest day. Enjoy the day and complete your quests calmly.", + "dungeon_spawn": "Dungeon spawned", + "dungeon_go": "Go!" + ] + ] + + static let achievementTexts: [AppLanguage: [String: String]] = [ + .de: [ + "ach_level_name": "Held von DailyQuest", + "ach_level_desc": "Erreiche neue Höhen und steige im Level auf.", + "ach_quests_name": "Der Auserwählte", + "ach_quests_desc": "Schließe täglich Quests ab und bleib konstant.", + "ach_gold_name": "Schatzmeister", + "ach_gold_desc": "Häufe Reichtümer durch Quests und Handel an.", + "ach_shop_name": "Großinvestor", + "ach_shop_desc": "Investiere in Gegenstände im Shop.", + "ach_strength_name": "Titanen-Kraft", + "ach_strength_desc": "Steigere deinen Kraft-Stat auf legendäre Werte.", + "ach_streak_name": "Streak-Meister", + "ach_streak_desc": "Halte deine tägliche Serie aufrecht.", + "ach_focus_name": "Fokus-Meister", + "ach_focus_desc": "Sammle Fokusminuten und beweise mentale Ausdauer." + ], + .en: [ + "ach_level_name": "Hero of DailyQuest", + "ach_level_desc": "Reach new heights and level up.", + "ach_quests_name": "The Chosen One", + "ach_quests_desc": "Complete quests consistently every day.", + "ach_gold_name": "Treasurer", + "ach_gold_desc": "Build wealth through quests and trading.", + "ach_shop_name": "Big Spender", + "ach_shop_desc": "Invest in items from the shop.", + "ach_strength_name": "Titan's Strength", + "ach_strength_desc": "Increase your strength stat to legendary levels.", + "ach_streak_name": "Streak Master", + "ach_streak_desc": "Keep your daily streak alive.", + "ach_focus_name": "Focus Master", + "ach_focus_desc": "Collect focus minutes and prove mental endurance." + ] + ] + + static let exerciseNames: [AppLanguage: [String: String]] = [ + .de: [ + "bicep_curls": "Bizeps Curls", "dumbbell_rows": "Hantel-Rudern", "push_ups_narrow": "Enge Liegestütze", + "weighted_squats": "Kniebeugen", "barbell_rows": "Langhantel-Rudern", "dumbbell_press": "Hantel-Bankdrücken", + "shoulder_press": "Schulterdrücken", "deadlifts": "Kreuzheben", "burpees": "Burpees", + "jumping_jacks": "Hampelmänner", "high_knees": "High Knees", "dumbbell_swings": "Hantel Swings", + "jump_squats": "Jump Squats", "mountain_climbers": "Mountain Climbers", "interval_sprint": "Intervall-Sprint", + "walking_lunges": "Walking Lunges", "shadowboxing": "Shadowboxing", "walk_30min": "Spaziergang", + "read_15pages": "Lesen", "healthy_snack": "Gesunder Snack", "stretch_10min": "Dehnen", + "learn_something": "Lernen", "general_workout": "Allgemeines Workout", "pistol_squats": "Pistol Squats", + "leg_raises": "Beinheben", "russian_twists": "Russian Twists", "pike_push_ups": "Pike Push-ups", + "hollow_body_hold": "Hollow Body Hold", "diamond_push_ups": "Diamond Push-ups", "reverse_flys": "Reverse Flys", + "single_leg_glute_bridge": "Einbeinige Brücke", "plank": "Plank", "situps": "Situps", + "knee_push_ups": "Knie Liegestütze", "tricep_dips_chair": "Trizep Dips", "lunges": "Ausfallschritte", + "sumo_squats": "Sumo Squats", "glute_bridges": "Brücke", "tricep_extensions": "Trizepsheben", + "side_plank": "Seitliche Plank", "pilates_5_exercises": "Pilates", "drink_tea": "Tee trinken", + "short_walk": "Spaziergang", "stretch_5min": "Dehnen", "take_nap": "Mittagsschlaf", + "take_medicine": "Medizin", "learn_new_skill": "Neue Fähigkeit", "read_for_school": "Schule lesen", + "learn_language": "Sprache lernen", "learn_math": "Mathe lernen", "learn_science": "Naturwissenschaften", + "push_ups_normal": "Liegestütze", "push_ups_wide": "Breite Liegestütze", "squats": "Squats", + "hula_hoop": "Hula Hoop", "stair_climbing": "Treppen Rennen", "jogging": "Joggen", "running": "Rennen" + ], + .en: [ + "bicep_curls": "Bicep Curls", "dumbbell_rows": "Dumbbell Rows", "push_ups_narrow": "Narrow Push-ups", + "weighted_squats": "Squats", "barbell_rows": "Barbell Rows", "dumbbell_press": "Dumbbell Press", + "shoulder_press": "Shoulder Press", "deadlifts": "Deadlifts", "burpees": "Burpees", + "jumping_jacks": "Jumping Jacks", "high_knees": "High Knees", "dumbbell_swings": "Dumbbell Swings", + "jump_squats": "Jump Squats", "mountain_climbers": "Mountain Climbers", "interval_sprint": "Interval Sprint", + "walking_lunges": "Walking Lunges", "shadowboxing": "Shadowboxing", "walk_30min": "Walk", + "read_15pages": "Reading", "healthy_snack": "Healthy Snack", "stretch_10min": "Stretching", + "learn_something": "Learning", "general_workout": "General Workout", "pistol_squats": "Pistol Squats", + "leg_raises": "Leg Raises", "russian_twists": "Russian Twists", "pike_push_ups": "Pike Push-ups", + "hollow_body_hold": "Hollow Body Hold", "diamond_push_ups": "Diamond Push-ups", "reverse_flys": "Reverse Flys", + "single_leg_glute_bridge": "Single Leg Glute Bridge", "plank": "Plank", "situps": "Situps", + "knee_push_ups": "Knee Push-ups", "tricep_dips_chair": "Tricep Dips", "lunges": "Lunges", + "sumo_squats": "Sumo Squats", "glute_bridges": "Glute Bridges", "tricep_extensions": "Tricep Extensions", + "side_plank": "Side Plank", "pilates_5_exercises": "Pilates", "drink_tea": "Drink Tea", + "short_walk": "Short Walk", "stretch_5min": "Stretching", "take_nap": "Nap", + "take_medicine": "Medicine", "learn_new_skill": "New Skill", "read_for_school": "School Reading", + "learn_language": "Language Learning", "learn_math": "Math Learning", "learn_science": "Science Learning", + "push_ups_normal": "Push-ups", "push_ups_wide": "Wide Push-ups", "squats": "Squats", + "hula_hoop": "Hula Hoop", "stair_climbing": "Stair Climbing", "jogging": "Jogging", "running": "Running" + ] + ] + + static let extraQuestNames: [AppLanguage: [String: String]] = [ + .de: [ + "extra_clean_room": "Ein Zimmer aufräumen", + "extra_walk_hour": "1 Stunde Spaziergang", + "extra_learn_hour": "1 Stunde etwas Neues lernen", + "extra_no_sweets": "Keine Süßigkeiten bis Tagesende", + "extra_go_jogging": "Joggen gehen", + "extra_finish_project": "Ein angefangenes Projekt beenden", + "extra_do_hobby": "Einem produktiven Hobby nachgehen", + "extra_meditate": "15 Minuten meditieren", + "extra_call_friend": "Freund/Familie anrufen", + "extra_digital_detox": "2 Stunden ohne Social Media" + ], + .en: [ + "extra_clean_room": "Clean a room", + "extra_walk_hour": "1 hour walk", + "extra_learn_hour": "Learn something new for 1 hour", + "extra_no_sweets": "No sweets until end of day", + "extra_go_jogging": "Go jogging", + "extra_finish_project": "Finish a started project", + "extra_do_hobby": "Engage in a productive hobby", + "extra_meditate": "Meditate for 15 minutes", + "extra_call_friend": "Call a friend/family", + "extra_digital_detox": "2 hours without social media" + ] + ] + + static let exerciseExplanations: [AppLanguage: [String: String]] = [ + .de: [:], + .en: [:] + ] + + static func defaultState() -> GameState { + let achievementProgress = Dictionary(uniqueKeysWithValues: AchievementKey.allCases.map { + ($0, AchievementProgress(tier: 0, claimable: false)) + }) + + let character = Character( + id: 1, + name: "Unknown Hunter", + level: 1, + mana: 0, + manaToNextLevel: 100, + gold: 200, + stats: [.kraft: 5, .ausdauer: 5, .beweglichkeit: 5, .durchhaltevermoegen: 5, .willenskraft: 5], + statProgress: [.kraft: 0, .ausdauer: 0, .beweglichkeit: 0, .durchhaltevermoegen: 0, .willenskraft: 0], + equipment: EquipmentSet(weapons: [], armor: nil), + inventory: [], + weightTrackingEnabled: true, + targetWeight: nil, + weightDirection: .lose, + combat: CombatStats(attack: 0, protection: 0, hpMax: 100, hpCurrent: 100), + achievements: achievementProgress, + totalGoldEarned: 200, + totalQuestsCompleted: 0, + totalItemsPurchased: 0 + ) + + return GameState( + settings: GameSettings(id: 1, language: .de, theme: .dark, difficulty: 3, goal: .muscle, restDays: 2), + character: character, + streak: StreakData(streak: 0, lastDate: nil), + dailyQuests: [], + nextQuestID: 1, + extraQuest: nil, + exercises: exercises, + shopItems: shopItems, + weightEntries: [], + vibe: VibeState( + sessions: [], + timer: FocusTimerState(mode: .pomodoro, startTime: nil, endTime: nil, pomodoroDuration: 25 * 60, elapsedSeconds: 0, isSessionActive: false), + linkedQuest: nil, + currentSessionLabel: nil, + customLabels: [] + ), + tutorial: TutorialState(completed: false, seenFeatures: []), + dungeonProgress: DungeonProgress(globalLevel: 1, activeDungeon: false), + lastPenaltyCheck: nil + ) + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/SettingsView.swift b/DailyQuest-SwiftApp/DailyQuest/SettingsView.swift new file mode 100755 index 0000000..c1ff3e8 --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/SettingsView.swift @@ -0,0 +1,188 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct SettingsView: View { + @EnvironmentObject private var store: GameStore + @Environment(\.dismiss) private var dismiss + + @State private var characterName: String = "" + + @State private var showExporter = false + @State private var showImporter = false + @State private var exportDocument = BackupDocument() + @State private var alertMessage: String? + + var body: some View { + NavigationStack { + Form { + generalSection + trainingSection + weightSection + tutorialSection + dataSection + } + .navigationTitle(store.t("settings")) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + .onAppear { + characterName = store.state.character.name + } + .fileExporter( + isPresented: $showExporter, + document: exportDocument, + contentType: .json, + defaultFilename: "dailyquest-backup-\(store.todayString())" + ) { result in + if case .failure(let error) = result { + alertMessage = error.localizedDescription + } + } + .fileImporter(isPresented: $showImporter, allowedContentTypes: [.json]) { result in + switch result { + case .success(let url): + do { + let data = try Data(contentsOf: url) + try store.importData(data) + } catch { + alertMessage = error.localizedDescription + } + case .failure(let error): + alertMessage = error.localizedDescription + } + } + .alert("Error", isPresented: Binding( + get: { alertMessage != nil }, + set: { if !$0 { alertMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(alertMessage ?? "") + } + } + } + + private var generalSection: some View { + Section(store.t("settings")) { + TextField(store.t("name"), text: Binding( + get: { characterName }, + set: { newValue in + characterName = newValue + store.updateCharacterName(newValue) + } + )) + + Picker(store.t("language"), selection: Binding( + get: { store.state.settings.language }, + set: { store.updateLanguage($0) } + )) { + ForEach(AppLanguage.allCases) { language in + Text(language.title).tag(language) + } + } + + Toggle(store.t("theme"), isOn: Binding( + get: { store.state.settings.theme == .light }, + set: { store.updateTheme($0 ? .light : .dark) } + )) + } + } + + private var trainingSection: some View { + Section(store.state.settings.language == .de ? "Training" : "Training") { + VStack(alignment: .leading) { + Text(store.t("difficulty")) + Slider( + value: Binding( + get: { Double(store.state.settings.difficulty) }, + set: { store.updateDifficulty(Int($0.rounded())) } + ), + in: 1...5, + step: 1 + ) + Text("\(store.state.settings.difficulty)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Picker(store.t("goal"), selection: Binding( + get: { store.state.settings.goal }, + set: { store.updateGoal($0) } + )) { + Text(store.state.settings.language == .de ? "Muskelaufbau" : "Muscle").tag(TrainingGoal.muscle) + Text(store.state.settings.language == .de ? "Ausdauer" : "Endurance").tag(TrainingGoal.endurance) + Text(store.state.settings.language == .de ? "Abnehmen" : "Fat Loss").tag(TrainingGoal.fatloss) + Text(store.state.settings.language == .de ? "Kraft + Abnehmen" : "Strength + Fat Loss").tag(TrainingGoal.kraft_abnehmen) + Text("Calisthenics").tag(TrainingGoal.calisthenics) + Text(store.state.settings.language == .de ? "Krank" : "Sick").tag(TrainingGoal.sick) + } + + Picker(store.t("rest_days"), selection: Binding( + get: { store.state.settings.restDays }, + set: { store.updateRestDays($0) } + )) { + ForEach(0...3, id: \.self) { value in + Text("\(value)").tag(value) + } + } + } + } + + private var weightSection: some View { + Section(store.t("weight_tracking")) { + Toggle(store.t("tracking_enabled"), isOn: Binding( + get: { store.state.character.weightTrackingEnabled }, + set: { store.updateWeightTrackingEnabled($0) } + )) + + if store.state.character.weightTrackingEnabled { + TextField(store.t("target_weight"), value: Binding( + get: { store.state.character.targetWeight }, + set: { store.updateTargetWeight($0) } + ), format: .number) + .keyboardType(.decimalPad) + + Picker(store.t("goal"), selection: Binding( + get: { store.state.character.weightDirection }, + set: { store.updateWeightDirection($0) } + )) { + Text(store.t("goal_lose_weight")).tag(WeightDirection.lose) + Text(store.t("goal_gain_weight")).tag(WeightDirection.gain) + } + + Button(role: .destructive) { + store.deleteWeightData() + } label: { + Text(store.t("delete_weight_data")) + } + } + } + } + + private var tutorialSection: some View { + Section("Tutorial") { + Button(store.t("reset_tutorial")) { + store.resetTutorial() + } + } + } + + private var dataSection: some View { + Section("Data") { + Button(store.t("export")) { + do { + exportDocument = BackupDocument(data: try store.exportData()) + showExporter = true + } catch { + alertMessage = error.localizedDescription + } + } + + Button(store.t("import")) { + showImporter = true + } + } + } +} diff --git a/DailyQuest-SwiftApp/DailyQuest/ShopView.swift b/DailyQuest-SwiftApp/DailyQuest/ShopView.swift new file mode 100755 index 0000000..a7fcddd --- /dev/null +++ b/DailyQuest-SwiftApp/DailyQuest/ShopView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct ShopView: View { + @EnvironmentObject private var store: GameStore + + enum ShopFilter: String, CaseIterable, Identifiable { + case all + case weapon + case armor + case consumable + case streakFreeze + + var id: String { rawValue } + } + + @State private var filter: ShopFilter = .all + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(store.t("shop")) + .font(.title3.bold()) + Spacer() + Text("\(store.t("gold")): \(store.state.character.gold)") + .font(.subheadline.weight(.semibold)) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ShopFilter.allCases) { entry in + Button(title(for: entry)) { + filter = entry + } + .buttonStyle(.bordered) + .tint(filter == entry ? .accentColor : .secondary) + } + } + .padding(.vertical, 2) + } + + LazyVStack(spacing: 10) { + ForEach(filteredItems) { item in + VStack(alignment: .leading, spacing: 8) { + Text(item.name) + .font(.headline) + Text(item.description) + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack { + Text("\(item.cost) Gold") + .fontWeight(.semibold) + Spacer() + Button(store.t("shop_buy")) { + store.buyItem(item) + } + .buttonStyle(.borderedProminent) + .disabled(store.state.character.gold < item.cost) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + } + } + } + .padding(16) + } + } + + private var filteredItems: [ShopItem] { + let base = store.state.shopItems + let result: [ShopItem] + + switch filter { + case .all: + result = base + case .weapon: + result = base.filter { $0.type == .weapon } + case .armor: + result = base.filter { $0.type == .armor } + case .consumable: + result = base.filter { $0.type == .consumable } + case .streakFreeze: + result = base.filter { $0.type == .streak_freeze } + } + + return result.sorted { $0.id < $1.id } + } + + private func title(for filter: ShopFilter) -> String { + switch filter { + case .all: + return store.state.settings.language == .de ? "Alle" : "All" + case .weapon: + return store.state.settings.language == .de ? "Waffen" : "Weapons" + case .armor: + return store.state.settings.language == .de ? "Rüstung" : "Armor" + case .consumable: + return store.state.settings.language == .de ? "Mana" : "Mana" + case .streakFreeze: + return store.state.settings.language == .de ? "Weiteres" : "Other" + } + } +} diff --git a/Funktion-Erklaerungen/01_Character_System.md b/Funktion-Erklaerungen/01_Character_System.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/02_Daily_Quests.md b/Funktion-Erklaerungen/02_Daily_Quests.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/03_Shop_System.md b/Funktion-Erklaerungen/03_Shop_System.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/04_Achievements_System.md b/Funktion-Erklaerungen/04_Achievements_System.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/05_Extra_Quest.md b/Funktion-Erklaerungen/05_Extra_Quest.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/06_Fokus_Timer_System.md b/Funktion-Erklaerungen/06_Fokus_Timer_System.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/07_Dungeon_System.md b/Funktion-Erklaerungen/07_Dungeon_System.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/08_Character_Stats.md b/Funktion-Erklaerungen/08_Character_Stats.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/09_Character_Inventory.md b/Funktion-Erklaerungen/09_Character_Inventory.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/10_Character_Labels.md b/Funktion-Erklaerungen/10_Character_Labels.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/11_Tutorial_System.md b/Funktion-Erklaerungen/11_Tutorial_System.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/12_Database_IndexDB.md b/Funktion-Erklaerungen/12_Database_IndexDB.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/13_Export_Import.md b/Funktion-Erklaerungen/13_Export_Import.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/14_Streak_System.md b/Funktion-Erklaerungen/14_Streak_System.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/15_Settings.md b/Funktion-Erklaerungen/15_Settings.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/16_Weight_Tracking.md b/Funktion-Erklaerungen/16_Weight_Tracking.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/17_Exercises_Pool.md b/Funktion-Erklaerungen/17_Exercises_Pool.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/18_Translations.md b/Funktion-Erklaerungen/18_Translations.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/19_Dungeons_Data.md b/Funktion-Erklaerungen/19_Dungeons_Data.md old mode 100644 new mode 100755 diff --git a/Funktion-Erklaerungen/20_Achievements_Data.md b/Funktion-Erklaerungen/20_Achievements_Data.md old mode 100644 new mode 100755 diff --git a/Jugendforscht-Bericht/DailyQuest Bericht - In Text/Jugendforscht-Bericht-DailyQuest.md b/Jugendforscht-Bericht/DailyQuest Bericht - In Text/Jugendforscht-Bericht-DailyQuest.md old mode 100644 new mode 100755 diff --git a/Jugendforscht-Bericht/Jugendforscht-Bericht-DailyQuest.md b/Jugendforscht-Bericht/Jugendforscht-Bericht-DailyQuest.md old mode 100644 new mode 100755 diff --git a/Jugendforscht-Bericht/Jugendforscht-Bericht-Plan.md b/Jugendforscht-Bericht/Jugendforscht-Bericht-Plan.md old mode 100644 new mode 100755 diff --git a/Jugendforscht-Bericht/Struckturplan-Jugendforscht2026.md b/Jugendforscht-Bericht/Struckturplan-Jugendforscht2026.md old mode 100644 new mode 100755 diff --git a/Lerndateien/01_Ueberblick_Projekt.md b/Lerndateien/01_Ueberblick_Projekt.md old mode 100644 new mode 100755 diff --git a/Lerndateien/02_Startpunkt_Index_und_Main.md b/Lerndateien/02_Startpunkt_Index_und_Main.md old mode 100644 new mode 100755 diff --git a/Lerndateien/03_Datenbank_und_Speichern.md b/Lerndateien/03_Datenbank_und_Speichern.md old mode 100644 new mode 100755 diff --git a/Lerndateien/04_Erfolge_und_Achievements.md b/Lerndateien/04_Erfolge_und_Achievements.md old mode 100644 new mode 100755 diff --git a/Lerndateien/05_Spieler_Charakter_System.md b/Lerndateien/05_Spieler_Charakter_System.md old mode 100644 new mode 100755 diff --git a/Lerndateien/06_Aufgaben_und_Training.md b/Lerndateien/06_Aufgaben_und_Training.md old mode 100644 new mode 100755 diff --git a/Lerndateien/07_Der_Dungeon.md b/Lerndateien/07_Der_Dungeon.md old mode 100644 new mode 100755 diff --git a/Lerndateien/08_Belohnung_Shop_Items.md b/Lerndateien/08_Belohnung_Shop_Items.md old mode 100644 new mode 100755 diff --git a/Lerndateien/09_Design_System_CSS.md b/Lerndateien/09_Design_System_CSS.md old mode 100644 new mode 100755 diff --git a/Lerndateien/10_Code_Architektur_Best_Practices.md b/Lerndateien/10_Code_Architektur_Best_Practices.md old mode 100644 new mode 100755 diff --git a/Mathematische-Konzepte.md b/Mathematische-Konzepte.md old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git "a/Screenshots f\303\274r README/Basis Stats Nahaufnahme.png" "b/Screenshots f\303\274r README/Basis Stats Nahaufnahme.png" old mode 100644 new mode 100755 diff --git "a/Screenshots f\303\274r README/Character Seite.png" "b/Screenshots f\303\274r README/Character Seite.png" old mode 100644 new mode 100755 diff --git "a/Screenshots f\303\274r README/Erfolge PopUp.png" "b/Screenshots f\303\274r README/Erfolge PopUp.png" old mode 100644 new mode 100755 diff --git "a/Screenshots f\303\274r README/Extra Quests.png" "b/Screenshots f\303\274r README/Extra Quests.png" old mode 100644 new mode 100755 diff --git "a/Screenshots f\303\274r README/Fokus Timer.png" "b/Screenshots f\303\274r README/Fokus Timer.png" old mode 100644 new mode 100755 diff --git "a/Screenshots f\303\274r README/Main Page - Daily Quests.png" "b/Screenshots f\303\274r README/Main Page - Daily Quests.png" old mode 100644 new mode 100755 diff --git "a/Screenshots f\303\274r README/Shop Seite.png" "b/Screenshots f\303\274r README/Shop Seite.png" old mode 100644 new mode 100755 diff --git "a/Screenshots f\303\274r README/desktop.ini" "b/Screenshots f\303\274r README/desktop.ini" deleted file mode 100644 index 5ae10ee..0000000 --- "a/Screenshots f\303\274r README/desktop.ini" +++ /dev/null @@ -1,3 +0,0 @@ -[LocalizedFileNames] -Main Page - Daily Quests.png=@Main Page - Daily Quests,0 -Fokus Timer.png=@Fokus Timer,0 diff --git a/css/components/buttons.css b/css/components/buttons.css old mode 100644 new mode 100755 diff --git a/css/components/cards.css b/css/components/cards.css old mode 100644 new mode 100755 diff --git a/css/components/popups.css b/css/components/popups.css old mode 100644 new mode 100755 diff --git a/css/main.css b/css/main.css old mode 100644 new mode 100755 diff --git a/css/pages/achievements.css b/css/pages/achievements.css old mode 100644 new mode 100755 diff --git a/css/pages/character.css b/css/pages/character.css old mode 100644 new mode 100755 diff --git a/css/pages/dungeon.css b/css/pages/dungeon.css old mode 100644 new mode 100755 diff --git a/css/pages/exercises.css b/css/pages/exercises.css old mode 100644 new mode 100755 diff --git a/css/pages/extra-quest.css b/css/pages/extra-quest.css old mode 100644 new mode 100755 diff --git a/css/pages/fokus.css b/css/pages/fokus.css old mode 100644 new mode 100755 diff --git a/css/pages/shop.css b/css/pages/shop.css old mode 100644 new mode 100755 diff --git a/data/achievements.js b/data/achievements.js old mode 100644 new mode 100755 diff --git a/data/dungeons.js b/data/dungeons.js old mode 100644 new mode 100755 diff --git a/data/exercises.js b/data/exercises.js old mode 100644 new mode 100755 diff --git a/data/translations.js b/data/translations.js old mode 100644 new mode 100755 diff --git a/icon.png b/icon.png old mode 100644 new mode 100755 diff --git a/index.html b/index.html old mode 100644 new mode 100755 diff --git a/info/design_guide.md b/info/design_guide.md old mode 100644 new mode 100755 diff --git a/info/technical_documentation.md b/info/technical_documentation.md old mode 100644 new mode 100755 diff --git a/info/theories_gamification.md b/info/theories_gamification.md old mode 100644 new mode 100755 diff --git a/js/character/page_character_inventory.js b/js/character/page_character_inventory.js old mode 100644 new mode 100755 diff --git a/js/character/page_character_labels.js b/js/character/page_character_labels.js old mode 100644 new mode 100755 diff --git a/js/character/page_character_main.js b/js/character/page_character_main.js old mode 100644 new mode 100755 diff --git a/js/character/page_character_stats.js b/js/character/page_character_stats.js old mode 100644 new mode 100755 diff --git a/js/database.js b/js/database.js old mode 100644 new mode 100755 diff --git a/js/dungeons/dungeon_combat.js b/js/dungeons/dungeon_combat.js old mode 100644 new mode 100755 diff --git a/js/dungeons/page_dungeon_main.js b/js/dungeons/page_dungeon_main.js old mode 100644 new mode 100755 diff --git a/js/fallback-check.js b/js/fallback-check.js old mode 100644 new mode 100755 diff --git a/js/page_achievements.js b/js/page_achievements.js old mode 100644 new mode 100755 diff --git a/js/page_exercises.js b/js/page_exercises.js old mode 100644 new mode 100755 diff --git a/js/page_extra_quest.js b/js/page_extra_quest.js old mode 100644 new mode 100755 diff --git a/js/page_shop.js b/js/page_shop.js old mode 100644 new mode 100755 diff --git a/js/ui.js b/js/ui.js old mode 100644 new mode 100755 diff --git a/js/vibe-fokus/page_fokus_main.js b/js/vibe-fokus/page_fokus_main.js old mode 100644 new mode 100755 diff --git a/js/vibe-fokus/page_fokus_timer.js b/js/vibe-fokus/page_fokus_timer.js old mode 100644 new mode 100755 diff --git a/js/vibe-fokus/vibe_state.js b/js/vibe-fokus/vibe_state.js old mode 100644 new mode 100755 diff --git a/main.js b/main.js old mode 100644 new mode 100755 diff --git a/manifest.json b/manifest.json old mode 100644 new mode 100755 diff --git a/service-worker.js b/service-worker.js old mode 100644 new mode 100755 diff --git a/tutorial/README.md b/tutorial/README.md old mode 100644 new mode 100755 diff --git a/tutorial/css/tutorial.css b/tutorial/css/tutorial.css old mode 100644 new mode 100755 diff --git a/tutorial/js/tutorial_dynamic.js b/tutorial/js/tutorial_dynamic.js old mode 100644 new mode 100755 diff --git a/tutorial/js/tutorial_main.js b/tutorial/js/tutorial_main.js old mode 100644 new mode 100755 diff --git a/tutorial/js/tutorial_progressive.js b/tutorial/js/tutorial_progressive.js old mode 100644 new mode 100755 diff --git a/tutorial/js/tutorial_state.js b/tutorial/js/tutorial_state.js old mode 100644 new mode 100755 diff --git a/tutorial/js/tutorial_tour.js b/tutorial/js/tutorial_tour.js old mode 100644 new mode 100755 diff --git a/tutorial/js/tutorial_triggers.js b/tutorial/js/tutorial_triggers.js old mode 100644 new mode 100755