diff --git a/GdeiAssistant-iOS/Core/Config/AppEnvironment.swift b/GdeiAssistant-iOS/Core/Config/AppEnvironment.swift index 37acdb7..abc3051 100644 --- a/GdeiAssistant-iOS/Core/Config/AppEnvironment.swift +++ b/GdeiAssistant-iOS/Core/Config/AppEnvironment.swift @@ -43,25 +43,63 @@ final class AppEnvironment: ObservableObject { let isDebug: Bool let clientType: String + var allowsRuntimeDebugOptions: Bool { + isDebug && AppConstants.API.allowsRuntimeDebugOptions + } + init( networkEnvironment: NetworkEnvironment, dataSourceMode: DataSourceMode, isDebug: Bool? = nil, clientType: String? = nil ) { - self.networkEnvironment = networkEnvironment - self.baseURL = networkEnvironment.baseURL - self.dataSourceMode = dataSourceMode - self.isDebug = isDebug ?? _isDebugAssertConfiguration() + let resolvedIsDebug = isDebug ?? _isDebugAssertConfiguration() + self.isDebug = resolvedIsDebug self.clientType = clientType ?? AppConstants.API.clientType + self.networkEnvironment = Self.sanitizedNetworkEnvironment( + networkEnvironment, + isDebug: resolvedIsDebug + ) + self.baseURL = self.networkEnvironment.baseURL + self.dataSourceMode = Self.sanitizedDataSourceMode( + dataSourceMode, + isDebug: resolvedIsDebug + ) } func updateDataSourceMode(_ mode: DataSourceMode) { - dataSourceMode = mode + dataSourceMode = sanitizedDataSourceMode(mode) } func updateNetworkEnvironment(_ environment: NetworkEnvironment) { - networkEnvironment = environment - baseURL = environment.baseURL + let nextEnvironment = sanitizedNetworkEnvironment(environment) + networkEnvironment = nextEnvironment + baseURL = nextEnvironment.baseURL + } + + private func sanitizedDataSourceMode(_ mode: DataSourceMode) -> DataSourceMode { + Self.sanitizedDataSourceMode(mode, isDebug: isDebug) + } + + private func sanitizedNetworkEnvironment(_ environment: NetworkEnvironment) -> NetworkEnvironment { + Self.sanitizedNetworkEnvironment(environment, isDebug: isDebug) + } + + private static func allowsRuntimeDebugOptions(isDebug: Bool) -> Bool { + isDebug && AppConstants.API.allowsRuntimeDebugOptions + } + + private static func sanitizedDataSourceMode( + _ mode: DataSourceMode, + isDebug: Bool + ) -> DataSourceMode { + allowsRuntimeDebugOptions(isDebug: isDebug) ? mode : .remote + } + + private static func sanitizedNetworkEnvironment( + _ environment: NetworkEnvironment, + isDebug: Bool + ) -> NetworkEnvironment { + allowsRuntimeDebugOptions(isDebug: isDebug) ? environment : .prod } } diff --git a/GdeiAssistant-iOS/Core/Config/AppLanguage.swift b/GdeiAssistant-iOS/Core/Config/AppLanguage.swift index a9acb4e..bca1d43 100644 --- a/GdeiAssistant-iOS/Core/Config/AppLanguage.swift +++ b/GdeiAssistant-iOS/Core/Config/AppLanguage.swift @@ -17,7 +17,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .simplifiedChinese: return "简体中文" case .traditionalChineseHongKong: - return "繁體中文(香港)" + return "繁體中文(港澳)" case .traditionalChineseTaiwan: return "繁體中文(台灣)" case .english: diff --git a/GdeiAssistant-iOS/Features/Home/Views/HomeView.swift b/GdeiAssistant-iOS/Features/Home/Views/HomeView.swift index b9de201..ad3ee22 100644 --- a/GdeiAssistant-iOS/Features/Home/Views/HomeView.swift +++ b/GdeiAssistant-iOS/Features/Home/Views/HomeView.swift @@ -4,6 +4,9 @@ struct HomeView: View { @StateObject private var viewModel: HomeViewModel @EnvironmentObject private var container: AppContainer @Environment(\.colorScheme) private var colorScheme + private let entryColumns = [ + GridItem(.adaptive(minimum: 72, maximum: 96), spacing: 8) + ] init(viewModel: HomeViewModel) { _viewModel = StateObject(wrappedValue: viewModel) @@ -67,7 +70,7 @@ struct HomeView: View { } LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), + columns: entryColumns, spacing: 16 ) { ForEach(entries) { entry in @@ -88,9 +91,11 @@ struct HomeView: View { Text(entry.title) .font(.caption2) - .lineLimit(1) - .minimumScaleFactor(0.8) + .lineLimit(2) + .minimumScaleFactor(0.85) + .multilineTextAlignment(.center) .foregroundStyle(DSColor.title) + .frame(maxWidth: .infinity) } .accessibilityElement(children: .combine) } diff --git a/GdeiAssistant-iOS/Features/Marketplace/Views/MarketplaceView.swift b/GdeiAssistant-iOS/Features/Marketplace/Views/MarketplaceView.swift index 5ee9b75..95f5a64 100644 --- a/GdeiAssistant-iOS/Features/Marketplace/Views/MarketplaceView.swift +++ b/GdeiAssistant-iOS/Features/Marketplace/Views/MarketplaceView.swift @@ -552,7 +552,7 @@ private struct MarketplaceStateChangeContext: Identifiable { case .offShelf: return localizedString("marketplace.stateOffShelf") case .sold: - return localizedString("marketplace.stateSold") + return localizedString("marketplace.stateMarkedSold") case .selling: return localizedString("marketplace.stateRelist") case .systemDeleted: diff --git a/GdeiAssistant-iOS/Features/Profile/ViewModels/SettingsViewModel.swift b/GdeiAssistant-iOS/Features/Profile/ViewModels/SettingsViewModel.swift index ed65d4a..7121d3a 100644 --- a/GdeiAssistant-iOS/Features/Profile/ViewModels/SettingsViewModel.swift +++ b/GdeiAssistant-iOS/Features/Profile/ViewModels/SettingsViewModel.swift @@ -17,6 +17,10 @@ final class SettingsViewModel: ObservableObject { environment.isDebug } + var allowsRuntimeDebugOptions: Bool { + environment.allowsRuntimeDebugOptions + } + var modeDisplayText: String { environment.dataSourceMode.displayName } @@ -42,7 +46,7 @@ final class SettingsViewModel: ObservableObject { } func updateMockEnabled(_ isEnabled: Bool) { - guard environment.isDebug else { return } + guard environment.allowsRuntimeDebugOptions else { return } preferences.setUseMockData(isEnabled) environment.updateDataSourceMode(isEnabled ? .mock : .remote) @@ -50,7 +54,7 @@ final class SettingsViewModel: ObservableObject { } func updateNetworkEnvironment(_ environment: NetworkEnvironment) { - guard self.environment.isDebug else { return } + guard self.environment.allowsRuntimeDebugOptions else { return } preferences.setNetworkEnvironment(environment) self.environment.updateNetworkEnvironment(environment) diff --git a/GdeiAssistant-iOS/Features/Profile/Views/SettingsView.swift b/GdeiAssistant-iOS/Features/Profile/Views/SettingsView.swift index e351721..3eb03b5 100644 --- a/GdeiAssistant-iOS/Features/Profile/Views/SettingsView.swift +++ b/GdeiAssistant-iOS/Features/Profile/Views/SettingsView.swift @@ -9,40 +9,38 @@ struct SettingsView: View { var body: some View { List { - Section { - Toggle(LocalizedStringKey("settings.useMockData"), isOn: mockBinding) - .disabled(!viewModel.isDebug) - - Text(LocalizedStringKey(viewModel.isDebug ? "settings.mockDataEnabled" : "settings.mockDataDisabled")) - .font(.footnote) - .foregroundStyle(DSColor.subtitle) + if viewModel.allowsRuntimeDebugOptions { + Section { + Toggle(LocalizedStringKey("settings.useMockData"), isOn: mockBinding) - if viewModel.showReloadHint { - Text(LocalizedStringKey("settings.reloadHint")) + Text(LocalizedStringKey("settings.mockDataEnabled")) .font(.footnote) - .foregroundStyle(DSColor.warning) - } - } header: { - Text(LocalizedStringKey("settings.debugDataSource")) - } + .foregroundStyle(DSColor.subtitle) - Section { - Picker(LocalizedStringKey("settings.apiEnvironmentLabel"), selection: networkEnvironmentBinding) { - ForEach(NetworkEnvironment.allCases, id: \.self) { environment in - Text(environment.displayName).tag(environment) + if viewModel.showReloadHint { + Text(LocalizedStringKey("settings.reloadHint")) + .font(.footnote) + .foregroundStyle(DSColor.warning) } + } header: { + Text(LocalizedStringKey("settings.debugDataSource")) } - .pickerStyle(.segmented) - .disabled(!viewModel.isDebug) - Text(LocalizedStringKey(viewModel.isDebug ? "settings.apiDebugHint" : "settings.apiReleaseHint")) - .font(.footnote) - .foregroundStyle(DSColor.subtitle) - } header: { - Text(LocalizedStringKey("settings.apiEnvironment")) - } + Section { + Picker(LocalizedStringKey("settings.apiEnvironmentLabel"), selection: networkEnvironmentBinding) { + ForEach(NetworkEnvironment.allCases, id: \.self) { environment in + Text(environment.displayName).tag(environment) + } + } + .pickerStyle(.segmented) + + Text(LocalizedStringKey("settings.apiDebugHint")) + .font(.footnote) + .foregroundStyle(DSColor.subtitle) + } header: { + Text(LocalizedStringKey("settings.apiEnvironment")) + } - if viewModel.isDebug { Section { infoRow(title: "networkEnvironment", value: viewModel.networkEnvironmentText) infoRow(title: "baseURL", value: viewModel.baseURLText) diff --git a/GdeiAssistant-iOS/Resources/en.lproj/Localizable.strings b/GdeiAssistant-iOS/Resources/en.lproj/Localizable.strings index c7b3ffb..3496f10 100644 --- a/GdeiAssistant-iOS/Resources/en.lproj/Localizable.strings +++ b/GdeiAssistant-iOS/Resources/en.lproj/Localizable.strings @@ -973,11 +973,6 @@ "messages.systemNoticeLoadFailed" = "Failed to load announcements"; "messages.interactionLoadFailed" = "Failed to load interactions"; "messages.updateStatusFailed" = "Failed to update status"; -"messages.loadFailed" = "Failed to load messages"; -"messages.newsLoadFailed" = "Failed to load news"; -"messages.systemNoticeLoadFailed" = "Failed to load announcements"; -"messages.interactionLoadFailed" = "Failed to load interactions"; -"messages.updateStatusFailed" = "Failed to update status"; // MARK: - Marketplace "marketplace.search" = "Search marketplace"; @@ -1008,7 +1003,7 @@ "marketplace.confirmSelling" = "Confirm relist"; "marketplace.confirm" = "Confirm"; "marketplace.stateOffShelf" = "Item removed"; -"marketplace.stateSold" = "Item marked as sold"; +"marketplace.stateMarkedSold" = "Item marked as sold"; "marketplace.stateRelist" = "Item relisted"; "marketplace.stateUpdated" = "Status updated"; "marketplace.actionFailed" = "Operation failed"; diff --git a/GdeiAssistant-iOS/Resources/ja.lproj/Localizable.strings b/GdeiAssistant-iOS/Resources/ja.lproj/Localizable.strings index f5c078e..c4eee2d 100644 --- a/GdeiAssistant-iOS/Resources/ja.lproj/Localizable.strings +++ b/GdeiAssistant-iOS/Resources/ja.lproj/Localizable.strings @@ -1003,7 +1003,7 @@ "marketplace.confirmSelling" = "再出品を確認"; "marketplace.confirm" = "確認"; "marketplace.stateOffShelf" = "商品を出品停止にしました"; -"marketplace.stateSold" = "商品を売却済みにしました"; +"marketplace.stateMarkedSold" = "商品を売却済みにしました"; "marketplace.stateRelist" = "商品を再出品しました"; "marketplace.stateUpdated" = "ステータスを更新しました"; "marketplace.actionFailed" = "操作に失敗しました"; diff --git a/GdeiAssistant-iOS/Resources/ko.lproj/Localizable.strings b/GdeiAssistant-iOS/Resources/ko.lproj/Localizable.strings index 3356df3..57212b2 100644 --- a/GdeiAssistant-iOS/Resources/ko.lproj/Localizable.strings +++ b/GdeiAssistant-iOS/Resources/ko.lproj/Localizable.strings @@ -1003,7 +1003,7 @@ "marketplace.confirmSelling" = "다시 올리기 확인"; "marketplace.confirm" = "확인"; "marketplace.stateOffShelf" = "상품이 내려갔습니다"; -"marketplace.stateSold" = "상품이 판매완료로 표시되었습니다"; +"marketplace.stateMarkedSold" = "상품이 판매완료로 표시되었습니다"; "marketplace.stateRelist" = "상품이 다시 올라갔습니다"; "marketplace.stateUpdated" = "상태가 업데이트되었습니다"; "marketplace.actionFailed" = "작업 실패"; diff --git a/GdeiAssistant-iOS/Resources/zh-HK.lproj/Localizable.strings b/GdeiAssistant-iOS/Resources/zh-HK.lproj/Localizable.strings index f723d70..b0397b0 100644 --- a/GdeiAssistant-iOS/Resources/zh-HK.lproj/Localizable.strings +++ b/GdeiAssistant-iOS/Resources/zh-HK.lproj/Localizable.strings @@ -1003,7 +1003,7 @@ "marketplace.confirmSelling" = "確認上架"; "marketplace.confirm" = "確認"; "marketplace.stateOffShelf" = "商品已下架"; -"marketplace.stateSold" = "商品已標記為售出"; +"marketplace.stateMarkedSold" = "商品已標記為售出"; "marketplace.stateRelist" = "商品已重新上架"; "marketplace.stateUpdated" = "狀態已更新"; "marketplace.actionFailed" = "操作失敗"; diff --git a/GdeiAssistant-iOS/Resources/zh-Hans.lproj/Localizable.strings b/GdeiAssistant-iOS/Resources/zh-Hans.lproj/Localizable.strings index 97759d2..223b9c3 100644 --- a/GdeiAssistant-iOS/Resources/zh-Hans.lproj/Localizable.strings +++ b/GdeiAssistant-iOS/Resources/zh-Hans.lproj/Localizable.strings @@ -973,11 +973,6 @@ "messages.systemNoticeLoadFailed" = "系统公告加载失败"; "messages.interactionLoadFailed" = "互动消息加载失败"; "messages.updateStatusFailed" = "更新消息状态失败"; -"messages.loadFailed" = "加载资讯信息失败"; -"messages.newsLoadFailed" = "新闻加载失败"; -"messages.systemNoticeLoadFailed" = "系统公告加载失败"; -"messages.interactionLoadFailed" = "互动消息加载失败"; -"messages.updateStatusFailed" = "更新消息状态失败"; // MARK: - Marketplace "marketplace.search" = "搜索二手交易"; @@ -1008,7 +1003,7 @@ "marketplace.confirmSelling" = "确认上架"; "marketplace.confirm" = "确认"; "marketplace.stateOffShelf" = "商品已下架"; -"marketplace.stateSold" = "商品已标记为售出"; +"marketplace.stateMarkedSold" = "商品已标记为售出"; "marketplace.stateRelist" = "商品已重新上架"; "marketplace.stateUpdated" = "状态已更新"; "marketplace.actionFailed" = "操作失败"; diff --git a/GdeiAssistant-iOS/Resources/zh-TW.lproj/Localizable.strings b/GdeiAssistant-iOS/Resources/zh-TW.lproj/Localizable.strings index c10dcfb..66c8ebd 100644 --- a/GdeiAssistant-iOS/Resources/zh-TW.lproj/Localizable.strings +++ b/GdeiAssistant-iOS/Resources/zh-TW.lproj/Localizable.strings @@ -1003,7 +1003,7 @@ "marketplace.confirmSelling" = "確認上架"; "marketplace.confirm" = "確認"; "marketplace.stateOffShelf" = "商品已下架"; -"marketplace.stateSold" = "商品已標記為售出"; +"marketplace.stateMarkedSold" = "商品已標記為售出"; "marketplace.stateRelist" = "商品已重新上架"; "marketplace.stateUpdated" = "狀態已更新"; "marketplace.actionFailed" = "操作失敗"; diff --git a/GdeiAssistant-iOSTests/Localization/AppLanguageTests.swift b/GdeiAssistant-iOSTests/Localization/AppLanguageTests.swift index a7aa1c3..586a22d 100644 --- a/GdeiAssistant-iOSTests/Localization/AppLanguageTests.swift +++ b/GdeiAssistant-iOSTests/Localization/AppLanguageTests.swift @@ -11,6 +11,12 @@ final class AppLanguageTests: XCTestCase { XCTAssertEqual(AppLanguage.normalizedIdentifier(from: "ko"), "ko") } + func testNativeNamesSeparateHongKongMacauAndTaiwanTraditionalChinese() { + XCTAssertEqual(AppLanguage.traditionalChineseHongKong.nativeName, "繁體中文(港澳)") + XCTAssertEqual(AppLanguage.traditionalChineseTaiwan.nativeName, "繁體中文(台灣)") + XCTAssertFalse(AppLanguage.allCases.map(\.nativeName).contains("繁體中文(香港)")) + } + func testNormalizeMapsLocaleVariantsToSupportedIdentifiers() { XCTAssertEqual(AppLanguage.normalizedIdentifier(from: "zh-Hans"), "zh-CN") XCTAssertEqual(AppLanguage.normalizedIdentifier(from: "zh-Hans-CN"), "zh-CN") diff --git a/GdeiAssistant-iOSTests/Profile/SettingsViewModelTests.swift b/GdeiAssistant-iOSTests/Profile/SettingsViewModelTests.swift index 15ae8c3..6e30e80 100644 --- a/GdeiAssistant-iOSTests/Profile/SettingsViewModelTests.swift +++ b/GdeiAssistant-iOSTests/Profile/SettingsViewModelTests.swift @@ -43,6 +43,51 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertFalse(viewModel.showReloadHint) } + func testReleaseEnvironmentSanitizesDebugDataSourceInputs() { + let defaults = makeDefaults(testName: #function) + let preferences = UserPreferences(defaults: defaults) + let environment = AppEnvironment( + networkEnvironment: .dev, + dataSourceMode: .mock, + isDebug: false, + clientType: "IOS" + ) + let viewModel = SettingsViewModel(environment: environment, preferences: preferences) + TestLifetimeRetainer.retain(viewModel) + + XCTAssertFalse(viewModel.allowsRuntimeDebugOptions) + XCTAssertEqual(environment.networkEnvironment, .prod) + XCTAssertEqual(environment.baseURL, NetworkEnvironment.prod.baseURL) + XCTAssertEqual(environment.dataSourceMode, .remote) + XCTAssertFalse(viewModel.useMockData) + + environment.updateNetworkEnvironment(.staging) + environment.updateDataSourceMode(.mock) + + XCTAssertEqual(environment.networkEnvironment, .prod) + XCTAssertEqual(environment.baseURL, NetworkEnvironment.prod.baseURL) + XCTAssertEqual(environment.dataSourceMode, .remote) + } + + func testUpdateMockEnabledIsIgnoredOutsideDebugBuilds() { + let defaults = makeDefaults(testName: #function) + let preferences = UserPreferences(defaults: defaults) + let environment = AppEnvironment( + networkEnvironment: .prod, + dataSourceMode: .remote, + isDebug: false, + clientType: "IOS" + ) + let viewModel = SettingsViewModel(environment: environment, preferences: preferences) + TestLifetimeRetainer.retain(viewModel) + + viewModel.updateMockEnabled(true) + + XCTAssertEqual(environment.dataSourceMode, .remote) + XCTAssertEqual(preferences.currentDataSourceMode, .remote) + XCTAssertFalse(viewModel.showReloadHint) + } + func testUpdateMockEnabledSwitchesEnvironmentModeInDebug() { let defaults = makeDefaults(testName: #function) let preferences = UserPreferences(defaults: defaults)