diff --git a/ios/WorkTrack/App/AppState.swift b/ios/WorkTrack/App/AppState.swift index 4708f06..9ccd024 100644 --- a/ios/WorkTrack/App/AppState.swift +++ b/ios/WorkTrack/App/AppState.swift @@ -5,6 +5,8 @@ import SwiftUI @MainActor final class AppState: ObservableObject { @Published private(set) var language: Language + /// Lives here because a language change rebuilds the tab view. + @Published var tab: AppTab = .work private static let key = "worktrack.language" @@ -22,3 +24,7 @@ final class AppState: ObservableObject { UserDefaults.standard.set(next.rawValue, forKey: Self.key) } } + +enum AppTab: Hashable { + case work, attendance, leave, pay, profile +} diff --git a/ios/WorkTrack/App/WorkTrackApp.swift b/ios/WorkTrack/App/WorkTrackApp.swift index 8938f8e..33d5c44 100644 --- a/ios/WorkTrack/App/WorkTrackApp.swift +++ b/ios/WorkTrack/App/WorkTrackApp.swift @@ -43,6 +43,7 @@ struct WorkTrackApp: App { struct RootView: View { @EnvironmentObject private var auth: AuthStore + @EnvironmentObject private var app: AppState @ObservedObject var lock: AppLock var body: some View { @@ -51,7 +52,8 @@ struct RootView: View { case .loading: ProgressView() case .signedOut: - SignInView() + // The next person starts on Work, not the last one's tab. + SignInView().onAppear { app.tab = .work } case .signedIn: // The lock sits OVER a live session. It gates who may look, // not whether the session survives — signing out instead would @@ -75,6 +77,7 @@ struct RootView: View { /// "everything sent" while a punch sat in the other one. struct SignedInTabs: View { let client: ApiClient + @EnvironmentObject private var app: AppState @ObservedObject var lock: AppLock @StateObject private var location = LocationProvider() @@ -94,20 +97,25 @@ struct SignedInTabs: View { } var body: some View { - TabView { + TabView(selection: $app.tab) { MyWorkView(client: client, attendance: attendance, cache: cache) // A checklist, not a hammer. This is an office product that // happens to be used on sites; a tool icon narrows it to // manual trades and reads wrong to every other customer. .tabItem { Label(L.t("tab_work"), systemImage: "checklist") } + .tag(AppTab.work) AttendanceHistoryView(client: client) .tabItem { Label(L.t("tab_history"), systemImage: "clock.fill") } + .tag(AppTab.attendance) LeaveView(client: client) .tabItem { Label(L.t("tab_leave"), systemImage: "calendar") } + .tag(AppTab.leave) PayslipsView(client: client) .tabItem { Label(L.t("tab_pay"), systemImage: "doc.text.fill") } + .tag(AppTab.pay) ProfileView(attendance: attendance, lock: lock) .tabItem { Label(L.t("tab_profile"), systemImage: "person.crop.circle.fill") } + .tag(AppTab.profile) } } } diff --git a/ios/WorkTrack/Auth/AuthStore.swift b/ios/WorkTrack/Auth/AuthStore.swift index 935ed10..1770ac2 100644 --- a/ios/WorkTrack/Auth/AuthStore.swift +++ b/ios/WorkTrack/Auth/AuthStore.swift @@ -36,6 +36,8 @@ final class AuthStore: ObservableObject { /// Restores a session from the Keychain, or reports signed out. func start() async { + // A language change re-fires this; re-restoring a live session could sign the worker out. + guard state == .loading else { return } guard let refresh = Keychain.get(Self.refreshKey) else { state = .signedOut return diff --git a/ios/WorkTrack/Core/Localization.swift b/ios/WorkTrack/Core/Localization.swift index 527e272..52367e8 100644 --- a/ios/WorkTrack/Core/Localization.swift +++ b/ios/WorkTrack/Core/Localization.swift @@ -131,6 +131,10 @@ enum L { "pay_earnings": "عواید", "pay_deductions": "کسرات", "pay_income_tax": "مالیهٔ معاش", + "pay_basic": "معاش اساسی", + "pay_absence": "کسر غیرحاضری", + "leave_type_annual": "رخصتی سالانه", + "leave_type_sick": "رخصتی مریضی", "pay_total_deductions": "مجموع کسرات", "pay_days": "روزها", "pay_worked_days": "روزهای کاری", @@ -284,6 +288,10 @@ enum L { "pay_earnings": "عواید", "pay_deductions": "کسرونه", "pay_income_tax": "د معاش مالیه", + "pay_basic": "اساسي معاش", + "pay_absence": "د غیرحاضرۍ کسر", + "leave_type_annual": "کلنۍ رخصتي", + "leave_type_sick": "د ناروغۍ رخصتي", "pay_total_deductions": "د کسرونو ټولګه", "pay_days": "ورځې", "pay_worked_days": "کاري ورځې", @@ -437,6 +445,10 @@ enum L { "pay_earnings": "Earnings", "pay_deductions": "Deductions", "pay_income_tax": "Income tax", + "pay_basic": "Basic salary", + "pay_absence": "Absence deduction", + "leave_type_annual": "Annual leave", + "leave_type_sick": "Sick leave", "pay_total_deductions": "Total deductions", "pay_days": "Days", "pay_worked_days": "Days worked", diff --git a/ios/WorkTrack/Leave/LeaveApplyView.swift b/ios/WorkTrack/Leave/LeaveApplyView.swift index 728fce9..fd4079e 100644 --- a/ios/WorkTrack/Leave/LeaveApplyView.swift +++ b/ios/WorkTrack/Leave/LeaveApplyView.swift @@ -17,7 +17,7 @@ struct LeaveApplyView: View { Form { Section { Picker(L.t("leave_type"), selection: $typeId) { - ForEach(types) { type in Text(type.name).tag(type.id) } + ForEach(types) { type in Text(type.displayName).tag(type.id) } } DatePicker( L.t("leave_from"), selection: $from, displayedComponents: .date diff --git a/ios/WorkTrack/Leave/LeaveModels.swift b/ios/WorkTrack/Leave/LeaveModels.swift index 4b18f5d..481dd23 100644 --- a/ios/WorkTrack/Leave/LeaveModels.swift +++ b/ios/WorkTrack/Leave/LeaveModels.swift @@ -6,6 +6,15 @@ struct LeaveType: Codable, Identifiable, Equatable { let code: String let colorHex: String? let isPaid: Bool? + + /// Only the untouched Dari names signup seeds are translated; a company's own name stays as typed. + var displayName: String { + switch (code, name) { + case ("ANNUAL", "رخصتی سالانه"): return L.t("leave_type_annual") + case ("SICK", "رخصتی مریضی"): return L.t("leave_type_sick") + default: return name + } + } } /// One person's balance for one leave type, for one year. diff --git a/ios/WorkTrack/Leave/LeaveViewModel.swift b/ios/WorkTrack/Leave/LeaveViewModel.swift index 2f7e20c..f8f8ff9 100644 --- a/ios/WorkTrack/Leave/LeaveViewModel.swift +++ b/ios/WorkTrack/Leave/LeaveViewModel.swift @@ -9,7 +9,7 @@ final class LeaveViewModel: ObservableObject { let types: [LeaveType] func typeName(_ id: String) -> String { - types.first { $0.id == id }?.name ?? id + types.first { $0.id == id }?.displayName ?? id } } diff --git a/ios/WorkTrack/Payslips/PayslipModels.swift b/ios/WorkTrack/Payslips/PayslipModels.swift index 4c3ff69..b326330 100644 --- a/ios/WorkTrack/Payslips/PayslipModels.swift +++ b/ios/WorkTrack/Payslips/PayslipModels.swift @@ -27,6 +27,16 @@ struct PayslipLine: Codable, Identifiable, Equatable { var id: String { componentCode } var isEarning: Bool { type == .earning } + + /// Payroll names these three lines in Dari itself; company components keep their own names. + var displayName: String { + switch componentCode { + case "BASIC": return L.t("pay_basic") + case "LOP": return L.t("pay_absence") + case "TAX": return L.t("pay_income_tax") + default: return componentName + } + } } /// One month's pay. diff --git a/ios/WorkTrack/Payslips/PayslipsView.swift b/ios/WorkTrack/Payslips/PayslipsView.swift index 838e6fc..c3545ec 100644 --- a/ios/WorkTrack/Payslips/PayslipsView.swift +++ b/ios/WorkTrack/Payslips/PayslipsView.swift @@ -80,7 +80,7 @@ struct PayslipDetailView: View { Section(L.t("pay_earnings")) { ForEach(slip.earnings) { line in - amount(line.componentName, line.amount) + amount(line.displayName, line.amount) } amount(L.t("pay_gross"), slip.gross, emphasised: true) } @@ -90,7 +90,7 @@ struct PayslipDetailView: View { // adding it again from `incomeTax` listed it twice and made the // column stop adding up. ForEach(slip.deductions) { line in - amount(line.componentName, line.amount) + amount(line.displayName, line.amount) } amount(L.t("pay_total_deductions"), slip.totalDeductions, emphasised: true) } @@ -98,7 +98,7 @@ struct PayslipDetailView: View { if !slip.employerCosts.isEmpty { Section { ForEach(slip.employerCosts) { line in - amount(line.componentName, line.amount) + amount(line.displayName, line.amount) } } header: { Text(L.t("pay_employer_cost")) diff --git a/ios/WorkTrackTests/LeaveAndPayTests.swift b/ios/WorkTrackTests/LeaveAndPayTests.swift index e9a53eb..bd037a4 100644 --- a/ios/WorkTrackTests/LeaveAndPayTests.swift +++ b/ios/WorkTrackTests/LeaveAndPayTests.swift @@ -154,6 +154,40 @@ final class LeaveAndPayTests: XCTestCase { XCTAssertTrue(slip.earnings.isEmpty) } + // MARK: names in the chosen language + + override func tearDown() { + L.language = .dari + super.tearDown() + } + + func testBuiltInLeaveTypesFollowTheLanguage() { + L.language = .english + let annual = LeaveType(id: "annual", name: "رخصتی سالانه", code: "ANNUAL", colorHex: nil, isPaid: true) + let sick = LeaveType(id: "sick", name: "رخصتی مریضی", code: "SICK", colorHex: nil, isPaid: true) + XCTAssertEqual(annual.displayName, "Annual leave") + XCTAssertEqual(sick.displayName, "Sick leave") + } + + func testALeaveTypeTheCompanyNamedKeepsItsName() { + L.language = .english + let renamed = LeaveType(id: "annual", name: "رخصتی تفریحی", code: "ANNUAL", colorHex: nil, isPaid: true) + let custom = LeaveType(id: "hajj", name: "رخصتی حج", code: "HAJJ", colorHex: nil, isPaid: true) + XCTAssertEqual(renamed.displayName, "رخصتی تفریحی") + XCTAssertEqual(custom.displayName, "رخصتی حج") + } + + func testPayrollLinesFollowTheLanguageButCompanyComponentsDoNot() { + L.language = .english + func line(_ code: String, _ name: String) -> PayslipLine { + PayslipLine(componentCode: code, componentName: name, type: .earning, amount: 1) + } + XCTAssertEqual(line("BASIC", "معاش اساسی").displayName, "Basic salary") + XCTAssertEqual(line("LOP", "کسر غیرحاضری").displayName, "Absence deduction") + XCTAssertEqual(line("TAX", "مالیهٔ معاش").displayName, "Income tax") + XCTAssertEqual(line("TRANSPORT", "کمک ترانسپورت").displayName, "کمک ترانسپورت") + } + // MARK: money func testMoneyIsLocalisedAndNamed() { diff --git a/ios/project.yml b/ios/project.yml index cc75319..a757f35 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -15,7 +15,7 @@ options: settings: base: MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "2" + CURRENT_PROJECT_VERSION: "3" SWIFT_VERSION: "5.9" # Simulator builds are signed ad-hoc ("-"). That is not cosmetic: the # Keychain refuses an app with no entitlements and SecItemAdd fails with