Explorar el Código

新增简体中文本地化(String Catalog)+ App 内语言手动选择

搭建 Localizable.xcstrings(主 App + LiveActivity 扩展)与 InfoPlist.xcstrings,
覆盖全部界面文案(设置、闹钟、远程控制、治疗记录、实时活动等),系统语言为中文时自动显示中文。

同时在通用设置新增语言选项(系统跟随 / English / 简体中文),可手动覆盖系统语言。

术语与 Trio-Diy 中文本地化保持一致(大剂量、活性碳水、临时配置等)。
zengxifeng@ttq.so hace 1 semana
padre
commit
8bf7b7443d
Se han modificado 58 ficheros con 8487 adiciones y 295 borrados
  1. 9 0
      LoopFollow.xcodeproj/project.pbxproj
  2. 1 1
      LoopFollow/Alarm/AddAlarm/AlarmTile.swift
  3. 31 25
      LoopFollow/Alarm/Alarm.swift
  4. 1 1
      LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift
  5. 1 1
      LoopFollow/Alarm/AlarmEditing/Components/AlarmActiveSection.swift
  6. 2 2
      LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift
  7. 7 7
      LoopFollow/Alarm/AlarmEditing/Editors/BatteryDropAlarmEditor.swift
  8. 3 3
      LoopFollow/Alarm/AlarmEditing/Editors/BuildExpireAlarmEditor.swift
  9. 3 3
      LoopFollow/Alarm/AlarmEditing/Editors/COBAlarmEditor.swift
  10. 10 10
      LoopFollow/Alarm/AlarmEditing/Editors/FastDropAlarmEditor.swift
  11. 10 12
      LoopFollow/Alarm/AlarmEditing/Editors/FastRiseAlarmEditor.swift
  12. 7 8
      LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift
  13. 6 7
      LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift
  14. 16 16
      LoopFollow/Alarm/AlarmEditing/Editors/IOBAlarmEditor.swift
  15. 9 12
      LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift
  16. 20 22
      LoopFollow/Alarm/AlarmEditing/Editors/MissedBolusAlarmEditor.swift
  17. 3 3
      LoopFollow/Alarm/AlarmEditing/Editors/MissedReadingEditor.swift
  18. 11 12
      LoopFollow/Alarm/AlarmEditing/Editors/NotLoopingAlarmEditor.swift
  19. 3 3
      LoopFollow/Alarm/AlarmEditing/Editors/PhoneBatteryAlarmEditor.swift
  20. 3 3
      LoopFollow/Alarm/AlarmEditing/Editors/PumpBatteryAlarmEditor.swift
  21. 4 4
      LoopFollow/Alarm/AlarmEditing/Editors/PumpChangeAlarmEditor.swift
  22. 4 5
      LoopFollow/Alarm/AlarmEditing/Editors/PumpVolumeAlarmEditor.swift
  23. 4 4
      LoopFollow/Alarm/AlarmEditing/Editors/RecBolusAlarmEditor.swift
  24. 8 10
      LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift
  25. 8 8
      LoopFollow/Alarm/AlarmEditing/Editors/TemporaryAlarmEditor.swift
  26. 1 1
      LoopFollow/Alarm/AlarmManager.swift
  27. 8 0
      LoopFollow/Alarm/AlarmType/AlarmType.swift
  28. 3 0
      LoopFollow/Application/AppDelegate.swift
  29. 50 0
      LoopFollow/Helpers/AppLanguage.swift
  30. 3 3
      LoopFollow/Helpers/AppearanceMode.swift
  31. 11 11
      LoopFollow/Helpers/TabPosition.swift
  32. 1 1
      LoopFollow/Helpers/Views/NavigationRow.swift
  33. 4 4
      LoopFollow/Helpers/Views/QuickPickSectionHeader.swift
  34. 1 1
      LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift
  35. 14 14
      LoopFollow/LiveActivity/LAAppGroupSettings.swift
  36. 6 6
      LoopFollow/LiveActivity/LiveActivityManager.swift
  37. 1 1
      LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift
  38. 1 1
      LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift
  39. 2 2
      LoopFollow/Remote/Settings/RemoteSettingsView.swift
  40. 1 1
      LoopFollow/Remote/TRC/BolusView.swift
  41. 1 1
      LoopFollow/Remote/TRC/MealView.swift
  42. 78 0
      LoopFollow/Resources/InfoPlist.xcstrings
  43. 7123 0
      LoopFollow/Resources/Localizable.xcstrings
  44. 12 12
      LoopFollow/Settings/ContactSettingsView.swift
  45. 27 3
      LoopFollow/Settings/GeneralSettingsView.swift
  46. 5 5
      LoopFollow/Settings/GraphSettingsView.swift
  47. 4 4
      LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift
  48. 1 1
      LoopFollow/Settings/SettingsMenuView.swift
  49. 1 1
      LoopFollow/Settings/TabCustomizationModal.swift
  50. 2 2
      LoopFollow/Settings/UnitsConfigurationView.swift
  51. 10 10
      LoopFollow/Stats/AggregatedStatsView.swift
  52. 2 0
      LoopFollow/Storage/Storage.swift
  53. 16 16
      LoopFollow/Treatments/TreatmentsView.swift
  54. 6 6
      LoopFollow/ViewControllers/MainViewController.swift
  55. 4 4
      LoopFollow/ViewControllers/MoreMenuView.swift
  56. 2 2
      LoopFollow/ViewControllers/NightScoutViewController.swift
  57. 72 0
      LoopFollowLAExtension/Localizable.xcstrings
  58. 830 0
      Scripts/gen_zh_catalog.py

+ 9 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -426,6 +426,8 @@
 		FC97881C2485969B00A7906C /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC97881B2485969B00A7906C /* MainViewController.swift */; };
 		FC97881E2485969B00A7906C /* NightScoutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC97881D2485969B00A7906C /* NightScoutViewController.swift */; };
 		FC9788262485969C00A7906C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FC9788252485969C00A7906C /* Assets.xcassets */; };
+		AA10CA7A00000000000FF002 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA10CA7A00000000000FF001 /* Localizable.xcstrings */; };
+		AA10CA7A00000000000FF004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA10CA7A00000000000FF003 /* InfoPlist.xcstrings */; };
 		FC9788292485969C00A7906C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FC9788272485969C00A7906C /* LaunchScreen.storyboard */; };
 		FCA2DDE62501095000254A8C /* Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCA2DDE52501095000254A8C /* Timers.swift */; };
 		FCC0FAC224922A22003E610E /* DictionaryKeyPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */; };
@@ -894,6 +896,8 @@
 		FC97881B2485969B00A7906C /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
 		FC97881D2485969B00A7906C /* NightScoutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScoutViewController.swift; sourceTree = "<group>"; };
 		FC9788252485969C00A7906C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		AA10CA7A00000000000FF001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
+		AA10CA7A00000000000FF003 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
 		FC9788282485969C00A7906C /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 		FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = "<group>"; };
 		FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = "<group>"; };
@@ -1629,6 +1633,8 @@
 				FC7CE4A8248ABE0D001F83B8 /* Alarm Sounds */,
 				FC9788252485969C00A7906C /* Assets.xcassets */,
 				FC7CE59B248D33A9001F83B8 /* dragbar.png */,
+				AA10CA7A00000000000FF001 /* Localizable.xcstrings */,
+				AA10CA7A00000000000FF003 /* InfoPlist.xcstrings */,
 			);
 			path = Resources;
 			sourceTree = "<group>";
@@ -1873,6 +1879,7 @@
 			knownRegions = (
 				en,
 				Base,
+				"zh-Hans",
 			);
 			mainGroup = FC97880B2485969B00A7906C;
 			packageReferences = (
@@ -1943,6 +1950,8 @@
 				FC7CE57D248ABE37001F83B8 /* Win_Gain.caf in Resources */,
 				FC7CE55F248ABE37001F83B8 /* Siri_Alert_Transmitter_Battery_Low.caf in Resources */,
 				FC9788292485969C00A7906C /* LaunchScreen.storyboard in Resources */,
+				AA10CA7A00000000000FF002 /* Localizable.xcstrings in Resources */,
+				AA10CA7A00000000000FF004 /* InfoPlist.xcstrings in Resources */,
 				FC7CE583248ABE37001F83B8 /* Sci-Fi_Alarm_Loop_2.caf in Resources */,
 				FC7CE549248ABE37001F83B8 /* Magical_Twinkle.caf in Resources */,
 				FC7CE540248ABE37001F83B8 /* Two_Turtle_Doves.caf in Resources */,

+ 1 - 1
LoopFollow/Alarm/AddAlarm/AlarmTile.swift

@@ -13,7 +13,7 @@ struct AlarmTile: View {
                 Image(systemName: type.icon)
                     .font(.title2)
                     .foregroundColor(.accentColor)
-                Text(type.rawValue)
+                Text(type.localizedTitle)
                     .font(.subheadline)
                     .multilineTextAlignment(.center)
                     .lineLimit(2)

+ 31 - 25
LoopFollow/Alarm/Alarm.swift

@@ -11,7 +11,13 @@ protocol DayNightDisplayable {
 
 extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
     var displayName: String {
-        rawValue == "always" ? "Day & Night" : rawValue.capitalized
+        switch rawValue {
+        case "always": return String(localized: "Day & Night")
+        case "day": return String(localized: "Day")
+        case "night": return String(localized: "Night")
+        case "never": return String(localized: "Never")
+        default: return String(localized: String.LocalizationValue(rawValue.capitalized))
+        }
     }
 }
 
@@ -259,7 +265,7 @@ struct Alarm: Identifiable, Codable, Equatable {
             }
         }()
 
-        AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze")
+        AlarmManager.shared.sendNotification(title: type.localizedTitle, actionTitle: snoozeDuration == 0 ? String(localized: "Acknowledge") : String(localized: "Snooze"))
 
         if playSound {
             AlarmSound.setSoundFile(str: soundFile.rawValue)
@@ -420,29 +426,29 @@ extension AlarmType {
 
     var blurb: String {
         switch self {
-        case .low: return "Alerts when BG goes below a limit."
-        case .high: return "Alerts when BG rises above a limit."
-        case .fastDrop: return "Rapid downward BG trend."
-        case .fastRise: return "Rapid upward BG trend."
-        case .missedReading: return "No CGM data for X minutes."
-        case .iob: return "High insulin-on-board."
-        case .cob: return "High carbs-on-board."
-        case .missedBolus: return "Carbs without bolus."
-        case .futureCarbs: return "Reminder when future carbs are due."
-        case .recBolus: return "Recommended bolus issued."
-        case .battery: return "Phone battery low."
-        case .batteryDrop: return "Battery drops quickly."
-        case .pump: return "Reservoir level low."
-        case .pumpBattery: return "Pump battery low."
-        case .pumpChange: return "Pump change due."
-        case .sensorChange: return "Sensor change due."
-        case .notLooping: return "Loop hasn’t completed."
-        case .buildExpire: return "Looping-app build expiring."
-        case .overrideStart: return "Override just started."
-        case .overrideEnd: return "Override ended."
-        case .tempTargetStart: return "Temp target started."
-        case .tempTargetEnd: return "Temp target ended."
-        case .temporary: return "One-time BG limit alert."
+        case .low: return String(localized: "Alerts when BG goes below a limit.")
+        case .high: return String(localized: "Alerts when BG rises above a limit.")
+        case .fastDrop: return String(localized: "Rapid downward BG trend.")
+        case .fastRise: return String(localized: "Rapid upward BG trend.")
+        case .missedReading: return String(localized: "No CGM data for X minutes.")
+        case .iob: return String(localized: "High insulin-on-board.")
+        case .cob: return String(localized: "High carbs-on-board.")
+        case .missedBolus: return String(localized: "Carbs without bolus.")
+        case .futureCarbs: return String(localized: "Reminder when future carbs are due.")
+        case .recBolus: return String(localized: "Recommended bolus issued.")
+        case .battery: return String(localized: "Phone battery low.")
+        case .batteryDrop: return String(localized: "Battery drops quickly.")
+        case .pump: return String(localized: "Reservoir level low.")
+        case .pumpBattery: return String(localized: "Pump battery low.")
+        case .pumpChange: return String(localized: "Pump change due.")
+        case .sensorChange: return String(localized: "Sensor change due.")
+        case .notLooping: return String(localized: "Loop hasn’t completed.")
+        case .buildExpire: return String(localized: "Looping-app build expiring.")
+        case .overrideStart: return String(localized: "Override just started.")
+        case .overrideEnd: return String(localized: "Override ended.")
+        case .tempTargetStart: return String(localized: "Temp target started.")
+        case .tempTargetEnd: return String(localized: "Temp target ended.")
+        case .temporary: return String(localized: "One-time BG limit alert.")
         }
     }
 }

+ 1 - 1
LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift

@@ -39,7 +39,7 @@ struct AlarmEditor: View {
                         }
                     }
                 }
-                .navigationTitle(alarm.type.rawValue)
+                .navigationTitle(alarm.type.localizedTitle)
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }

+ 1 - 1
LoopFollow/Alarm/AlarmEditing/Components/AlarmActiveSection.swift

@@ -8,7 +8,7 @@ struct AlarmActiveSection: View {
 
     var body: some View {
         Section(header: Text("Active During")) {
-            AlarmEnumMenuPicker(title: "Active",
+            AlarmEnumMenuPicker(title: String(localized: "Active"),
                                 selection: $alarm.activeOption)
         }
     }

+ 2 - 2
LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift

@@ -28,14 +28,14 @@ struct AlarmAudioSection: View {
             }
 
             AlarmEnumMenuPicker(
-                title: "Play",
+                title: String(localized: "Play"),
                 selection: $alarm.playSoundOption,
                 allowed: PlaySoundOption.allowed(for: alarm.activeOption)
             )
 
             if !hideRepeat {
                 AlarmEnumMenuPicker(
-                    title: "Repeat",
+                    title: String(localized: "Repeat"),
                     selection: $alarm.repeatSoundOption,
                     allowed: RepeatSoundOption.allowed(for: alarm.activeOption)
                 )

+ 7 - 7
LoopFollow/Alarm/AlarmEditing/Editors/BatteryDropAlarmEditor.swift

@@ -16,9 +16,9 @@ struct BatteryDropAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Phone Battery Drop",
-                footer: "This alerts you if the phone battery drops by this much or more.",
-                title: "Drop Amount",
+                header: String(localized: "Phone Battery Drop"),
+                footer: String(localized: "This alerts you if the phone battery drops by this much or more."),
+                title: String(localized: "Drop Amount"),
                 range: 5 ... 100,
                 step: 5,
                 unitLabel: "%",
@@ -26,12 +26,12 @@ struct BatteryDropAlarmEditor: View {
             )
 
             AlarmStepperSection(
-                header: "Over This Time",
-                footer: "How far back to look for that drop.",
-                title: "Time Window",
+                header: String(localized: "Over This Time"),
+                footer: String(localized: "How far back to look for that drop."),
+                title: String(localized: "Time Window"),
                 range: 5 ... 30,
                 step: 5,
-                unitLabel: "min",
+                unitLabel: String(localized: "min"),
                 value: $alarm.monitoringWindow
             )
 

+ 3 - 3
LoopFollow/Alarm/AlarmEditing/Editors/BuildExpireAlarmEditor.swift

@@ -15,9 +15,9 @@ struct BuildExpireAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Notice Period",
-                footer: "Choose how many days of notice you’d like before the build becomes unusable.",
-                title: "Days of notice",
+                header: String(localized: "Notice Period"),
+                footer: String(localized: "Choose how many days of notice you’d like before the build becomes unusable."),
+                title: String(localized: "Days of notice"),
                 range: 1 ... 14,
                 step: 1,
                 unitLabel: alarm.type.snoozeTimeUnit.label,

+ 3 - 3
LoopFollow/Alarm/AlarmEditing/Editors/COBAlarmEditor.swift

@@ -16,9 +16,9 @@ struct COBAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Carbs-on-Board Limit",
-                footer: "Alert when Carbs-on-Board is at or above this number.",
-                title: "At or Above",
+                header: String(localized: "Carbs-on-Board Limit"),
+                footer: String(localized: "Alert when Carbs-on-Board is at or above this number."),
+                title: String(localized: "At or Above"),
                 range: 1 ... 200,
                 step: 1,
                 unitLabel: "g",

+ 10 - 10
LoopFollow/Alarm/AlarmEditing/Editors/FastDropAlarmEditor.swift

@@ -16,27 +16,27 @@ struct FastDropAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmBGSection(
-                header: "Rate of Fall",
-                footer: "This is how much the glucose must drop to be considered a fast drop.",
-                title: "Falls by",
+                header: String(localized: "Rate of Fall"),
+                footer: String(localized: "This is how much the glucose must drop to be considered a fast drop."),
+                title: String(localized: "Falls by"),
                 range: 3 ... 54,
                 value: $alarm.delta
             )
 
             AlarmStepperSection(
-                header: "Consecutive Drops",
-                footer: "Number of drops—each meeting the rate above—required before an alert fires.",
-                title: "Number of Drops",
+                header: String(localized: "Consecutive Drops"),
+                footer: String(localized: "Number of drops—each meeting the rate above—required before an alert fires."),
+                title: String(localized: "Number of Drops"),
                 range: 1 ... 3,
                 step: 1,
                 value: $alarm.monitoringWindow
             )
 
             AlarmBGLimitSection(
-                header: "BG Limit",
-                footer: "When enabled, this alert only fires if the glucose is below the limit you set.",
-                toggleText: "Use BG Limit",
-                pickerTitle: "Dropping below",
+                header: String(localized: "BG Limit"),
+                footer: String(localized: "When enabled, this alert only fires if the glucose is below the limit you set."),
+                toggleText: String(localized: "Use BG Limit"),
+                pickerTitle: String(localized: "Dropping below"),
                 range: 40 ... 300,
                 defaultOnValue: 120,
                 value: $alarm.belowBG

+ 10 - 12
LoopFollow/Alarm/AlarmEditing/Editors/FastRiseAlarmEditor.swift

@@ -18,29 +18,27 @@ struct FastRiseAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmBGSection(
-                header: "Rate of Rise",
-                footer: "This is how much the glucose must rise to be considered a fast rise.",
-                title: "Rises by",
+                header: String(localized: "Rate of Rise"),
+                footer: String(localized: "This is how much the glucose must rise to be considered a fast rise."),
+                title: String(localized: "Rises by"),
                 range: 3 ... 54,
                 value: $alarm.delta
             )
 
             AlarmStepperSection(
-                header: "Consecutive Rises",
-                footer: "Number of rises—each meeting the rate above—"
-                    + "required before an alert fires.",
-                title: "Rises in a row",
+                header: String(localized: "Consecutive Rises"),
+                footer: String(localized: "Number of rises—each meeting the rate above—required before an alert fires."),
+                title: String(localized: "Rises in a row"),
                 range: 1 ... 3,
                 step: 1,
                 value: $alarm.monitoringWindow
             )
 
             AlarmBGLimitSection(
-                header: "BG Limit",
-                footer: "When enabled, this alert only fires if the glucose is "
-                    + "above the limit you set.",
-                toggleText: "Use BG Limit",
-                pickerTitle: "Rising above",
+                header: String(localized: "BG Limit"),
+                footer: String(localized: "When enabled, this alert only fires if the glucose is above the limit you set."),
+                toggleText: String(localized: "Use BG Limit"),
+                pickerTitle: String(localized: "Rising above"),
                 range: 40 ... 300,
                 defaultOnValue: 200,
                 value: $alarm.aboveBG

+ 7 - 8
LoopFollow/Alarm/AlarmEditing/Editors/FutureCarbsAlarmEditor.swift

@@ -18,20 +18,19 @@ struct FutureCarbsAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Max Lookahead",
-                footer: "Only track carb entries scheduled up to this many minutes " +
-                    "in the future. Entries beyond this window are ignored.",
-                title: "Lookahead",
+                header: String(localized: "Max Lookahead"),
+                footer: String(localized: "Only track carb entries scheduled up to this many minutes in the future. Entries beyond this window are ignored."),
+                title: String(localized: "Lookahead"),
                 range: 5 ... 120,
                 step: 5,
-                unitLabel: "min",
+                unitLabel: String(localized: "min"),
                 value: $alarm.threshold
             )
 
             AlarmStepperSection(
-                header: "Minimum Carbs",
-                footer: "Ignore carb entries below this amount.",
-                title: "At or Above",
+                header: String(localized: "Minimum Carbs"),
+                footer: String(localized: "Ignore carb entries below this amount."),
+                title: String(localized: "At or Above"),
                 range: 0 ... 50,
                 step: 1,
                 unitLabel: "g",

+ 6 - 7
LoopFollow/Alarm/AlarmEditing/Editors/HighBgAlarmEditor.swift

@@ -16,18 +16,17 @@ struct HighBgAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmBGSection(
-                header: "High Glucose Limit",
-                footer: "The alert becomes eligible once any reading is at or above this value.",
-                title: "BG",
+                header: String(localized: "High Glucose Limit"),
+                footer: String(localized: "The alert becomes eligible once any reading is at or above this value."),
+                title: String(localized: "BG"),
                 range: 120 ... 350,
                 value: $alarm.aboveBG
             )
 
             AlarmStepperSection(
-                header: "Persistent High",
-                footer: "How long glucose must remain above the threshold before the "
-                    + "alarm actually fires.  Set to 0 for an immediate alert.",
-                title: "Persistent for",
+                header: String(localized: "Persistent High"),
+                footer: String(localized: "How long glucose must remain above the threshold before the alarm actually fires.  Set to 0 for an immediate alert."),
+                title: String(localized: "Persistent for"),
                 range: 0 ... 120,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,

+ 16 - 16
LoopFollow/Alarm/AlarmEditing/Editors/IOBAlarmEditor.swift

@@ -17,42 +17,42 @@ struct IOBAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Boluses Size Limit",
-                footer: "This counts only boluses larger than this size.",
-                title: "Above",
+                header: String(localized: "Boluses Size Limit"),
+                footer: String(localized: "This counts only boluses larger than this size."),
+                title: String(localized: "Above"),
                 range: 0.1 ... 20,
                 step: 0.1,
-                unitLabel: "Units",
+                unitLabel: String(localized: "Units"),
                 value: $alarm.delta
             )
 
             AlarmStepperSection(
-                header: "Bolus Count",
-                footer: "Number of qualifying boluses needed to trigger.",
-                title: "Count",
+                header: String(localized: "Bolus Count"),
+                footer: String(localized: "Number of qualifying boluses needed to trigger."),
+                title: String(localized: "Count"),
                 range: 1 ... 10,
                 step: 1,
-                unitLabel: "Boluses",
+                unitLabel: String(localized: "Boluses"),
                 value: $alarm.monitoringWindow
             )
 
             AlarmStepperSection(
-                header: "Time Window",
-                footer: "How far back to look for those boluses.",
-                title: "Time",
+                header: String(localized: "Time Window"),
+                footer: String(localized: "How far back to look for those boluses."),
+                title: String(localized: "Time"),
                 range: 5 ... 120,
                 step: 5,
-                unitLabel: "min",
+                unitLabel: String(localized: "min"),
                 value: $alarm.predictiveMinutes
             )
 
             AlarmStepperSection(
-                header: "Insulin On Board",
-                footer: "Alert if current IOB or total boluses reach this.",
-                title: "IOB Above",
+                header: String(localized: "Insulin On Board"),
+                footer: String(localized: "Alert if current IOB or total boluses reach this."),
+                title: String(localized: "IOB Above"),
                 range: 1 ... 20,
                 step: 0.5,
-                unitLabel: "Units",
+                unitLabel: String(localized: "Units"),
                 value: $alarm.threshold
             )
 

+ 9 - 12
LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift

@@ -13,18 +13,17 @@ struct LowBgAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmBGSection(
-                header: "Low Limit",
-                footer: "Alert when any reading or prediction is at or below this value.",
-                title: "BG",
+                header: String(localized: "Low Limit"),
+                footer: String(localized: "Alert when any reading or prediction is at or below this value."),
+                title: String(localized: "BG"),
                 range: 40 ... 150,
                 value: $alarm.belowBG
             )
 
             AlarmStepperSection(
-                header: "PERSISTENCE",
-                footer: "Glucose must stay below the threshold for this many minutes "
-                    + "before the alert sounds. Set 0 to alert immediately.",
-                title: "Persistent",
+                header: String(localized: "PERSISTENCE"),
+                footer: String(localized: "Glucose must stay below the threshold for this many minutes before the alert sounds. Set 0 to alert immediately."),
+                title: String(localized: "Persistent"),
                 range: 0 ... 120,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,
@@ -32,11 +31,9 @@ struct LowBgAlarmEditor: View {
             )
 
             AlarmStepperSection(
-                header: "PREDICTION",
-                footer: "Look ahead this many minutes in Loop’s prediction; "
-                    + "if any future value is at or below the threshold, "
-                    + "you’ll be warned early. Set 0 to disable.",
-                title: "Predictive",
+                header: String(localized: "PREDICTION"),
+                footer: String(localized: "Look ahead this many minutes in Loop’s prediction; if any future value is at or below the threshold, you’ll be warned early. Set 0 to disable."),
+                title: String(localized: "Predictive"),
                 range: 0 ... 60,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,

+ 20 - 22
LoopFollow/Alarm/AlarmEditing/Editors/MissedBolusAlarmEditor.swift

@@ -18,52 +18,50 @@ struct MissedBolusAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Delay",
-                footer: "Minutes to wait after the carb entry before checking " +
-                    "for a bolus.",
-                title: "Delay",
+                header: String(localized: "Delay"),
+                footer: String(localized: "Minutes to wait after the carb entry before checking for a bolus."),
+                title: String(localized: "Delay"),
                 range: 5 ... 60,
                 step: 5,
-                unitLabel: "min",
+                unitLabel: String(localized: "min"),
                 value: $alarm.monitoringWindow
             )
 
             AlarmStepperSection(
-                header: "Pre-bolus",
-                footer: "Count boluses given up to this many minutes before " +
-                    "the carb entry as valid.",
-                title: "Pre-Bolus Time",
+                header: String(localized: "Pre-bolus"),
+                footer: String(localized: "Count boluses given up to this many minutes before the carb entry as valid."),
+                title: String(localized: "Pre-Bolus Time"),
                 range: 0 ... 45,
                 step: 5,
-                unitLabel: "min",
+                unitLabel: String(localized: "min"),
                 value: $alarm.predictiveMinutes
             )
 
             AlarmStepperSection(
-                header: "Ignore small boluses",
-                footer: "Boluses at or below this size are ignored.",
-                title: "At or Below",
+                header: String(localized: "Ignore small boluses"),
+                footer: String(localized: "Boluses at or below this size are ignored."),
+                title: String(localized: "At or Below"),
                 range: 0.05 ... 2,
                 step: 0.05,
-                unitLabel: "Units",
+                unitLabel: String(localized: "Units"),
                 value: $alarm.delta
             )
 
             AlarmStepperSection(
-                header: "Ignore small carbs",
-                footer: "Carb entries at or below this amount will not trigger the alarm.",
-                title: "At or Below",
+                header: String(localized: "Ignore small carbs"),
+                footer: String(localized: "Carb entries at or below this amount will not trigger the alarm."),
+                title: String(localized: "At or Below"),
                 range: 0 ... 15,
                 step: 1,
-                unitLabel: "Grams",
+                unitLabel: String(localized: "Grams"),
                 value: $alarm.threshold
             )
 
             AlarmBGLimitSection(
-                header: "Ignore low BG",
-                footer: "Only alert if the current BG is above this value.",
-                toggleText: "Use BG Limit",
-                pickerTitle: "Above",
+                header: String(localized: "Ignore low BG"),
+                footer: String(localized: "Only alert if the current BG is above this value."),
+                toggleText: String(localized: "Use BG Limit"),
+                pickerTitle: String(localized: "Above"),
                 range: 40 ... 140,
                 defaultOnValue: 70,
                 value: $alarm.aboveBG

+ 3 - 3
LoopFollow/Alarm/AlarmEditing/Editors/MissedReadingEditor.swift

@@ -13,9 +13,9 @@ struct MissedReadingEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Reading Delay",
-                footer: "Choose how long the app should wait before alerting.",
-                title: "No reading for",
+                header: String(localized: "Reading Delay"),
+                footer: String(localized: "Choose how long the app should wait before alerting."),
+                title: String(localized: "No reading for"),
                 range: 11 ... 121,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,

+ 11 - 12
LoopFollow/Alarm/AlarmEditing/Editors/NotLoopingAlarmEditor.swift

@@ -18,10 +18,9 @@ struct NotLoopingAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "No Loop for…",
-                footer: "Number of minutes since the last successful loop. "
-                    + "When this time has elapsed, the alarm becomes eligible.",
-                title: "Elapsed time",
+                header: String(localized: "No Loop for…"),
+                footer: String(localized: "Number of minutes since the last successful loop. When this time has elapsed, the alarm becomes eligible."),
+                title: String(localized: "Elapsed time"),
                 range: 16 ... 61,
                 step: 5,
                 unitLabel: alarm.type.snoozeTimeUnit.label,
@@ -29,20 +28,20 @@ struct NotLoopingAlarmEditor: View {
             )
 
             AlarmBGLimitSection(
-                header: "Low Limit",
-                footer: "Alert only if BG is equal to or below this value.",
-                toggleText: "Enable low limit",
-                pickerTitle: "Below",
+                header: String(localized: "Low Limit"),
+                footer: String(localized: "Alert only if BG is equal to or below this value."),
+                toggleText: String(localized: "Enable low limit"),
+                pickerTitle: String(localized: "Below"),
                 range: bgRange,
                 defaultOnValue: 100,
                 value: $alarm.belowBG
             )
 
             AlarmBGLimitSection(
-                header: "High Limit",
-                footer: "Alert only if BG is equal to or above this value.",
-                toggleText: "Enable high limit",
-                pickerTitle: "Above",
+                header: String(localized: "High Limit"),
+                footer: String(localized: "Alert only if BG is equal to or above this value."),
+                toggleText: String(localized: "Enable high limit"),
+                pickerTitle: String(localized: "Above"),
                 range: bgRange,
                 defaultOnValue: 160,
                 value: $alarm.aboveBG

+ 3 - 3
LoopFollow/Alarm/AlarmEditing/Editors/PhoneBatteryAlarmEditor.swift

@@ -16,9 +16,9 @@ struct PhoneBatteryAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Phone Battery Level",
-                footer: "This alerts you when the phone battery drops to or below this level.",
-                title: "At or Below",
+                header: String(localized: "Phone Battery Level"),
+                footer: String(localized: "This alerts you when the phone battery drops to or below this level."),
+                title: String(localized: "At or Below"),
                 range: 0 ... 100,
                 step: 5,
                 unitLabel: "%",

+ 3 - 3
LoopFollow/Alarm/AlarmEditing/Editors/PumpBatteryAlarmEditor.swift

@@ -16,9 +16,9 @@ struct PumpBatteryAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Pump Battery Level",
-                footer: "This alerts you when the pump battery drops to or below this level.",
-                title: "At or Below",
+                header: String(localized: "Pump Battery Level"),
+                footer: String(localized: "This alerts you when the pump battery drops to or below this level."),
+                title: String(localized: "At or Below"),
                 range: 0 ... 100,
                 step: 5,
                 unitLabel: "%",

+ 4 - 4
LoopFollow/Alarm/AlarmEditing/Editors/PumpChangeAlarmEditor.swift

@@ -18,12 +18,12 @@ struct PumpChangeAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Advance Notice",
-                footer: "How many hours before the 3-day limit you’d like a reminder.",
-                title: "Warning Time",
+                header: String(localized: "Advance Notice"),
+                footer: String(localized: "How many hours before the 3-day limit you’d like a reminder."),
+                title: String(localized: "Warning Time"),
                 range: 1 ... 24,
                 step: 1,
-                unitLabel: "Hours",
+                unitLabel: String(localized: "Hours"),
                 value: $alarm.threshold
             )
 

+ 4 - 5
LoopFollow/Alarm/AlarmEditing/Editors/PumpVolumeAlarmEditor.swift

@@ -16,13 +16,12 @@ struct PumpVolumeAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Trigger Level",
-                footer: "An alert fires once the reservoir is at this value "
-                    + "or lower.",
-                title: "Reservoir Below",
+                header: String(localized: "Trigger Level"),
+                footer: String(localized: "An alert fires once the reservoir is at this value or lower."),
+                title: String(localized: "Reservoir Below"),
                 range: 1 ... 50,
                 step: 1,
-                unitLabel: "Units",
+                unitLabel: String(localized: "Units"),
                 value: $alarm.threshold
             )
 

+ 4 - 4
LoopFollow/Alarm/AlarmEditing/Editors/RecBolusAlarmEditor.swift

@@ -17,12 +17,12 @@ struct RecBolusAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Threshold",
-                footer: "Alert when recommended bolus is at or above this value.",
-                title: "At or Above",
+                header: String(localized: "Threshold"),
+                footer: String(localized: "Alert when recommended bolus is at or above this value."),
+                title: String(localized: "At or Above"),
                 range: 0.1 ... 50,
                 step: 0.1,
-                unitLabel: "Units",
+                unitLabel: String(localized: "Units"),
                 value: $alarm.threshold
             )
 

+ 8 - 10
LoopFollow/Alarm/AlarmEditing/Editors/SensorAgeAlarmEditor.swift

@@ -27,24 +27,22 @@ struct SensorAgeAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmStepperSection(
-                header: "Sensor Lifetime",
-                footer: "Number of days your CGM sensor lasts " +
-                    "(e.g. 10 for Dexcom G6, 15 for G7 15-day).",
-                title: "Lifetime",
+                header: String(localized: "Sensor Lifetime"),
+                footer: String(localized: "Number of days your CGM sensor lasts (e.g. 10 for Dexcom G6, 15 for G7 15-day)."),
+                title: String(localized: "Lifetime"),
                 range: 7 ... 15,
                 step: 1,
-                unitLabel: "days",
+                unitLabel: String(localized: "days"),
                 value: lifetimeBinding
             )
 
             AlarmStepperSection(
-                header: "Early Reminder",
-                footer: "Number of hours before the \(lifetimeDays)-day mark that the alert " +
-                    "will fire.",
-                title: "Reminder Time",
+                header: String(localized: "Early Reminder"),
+                footer: String(localized: "Number of hours before the \(lifetimeDays)-day mark that the alert will fire."),
+                title: String(localized: "Reminder Time"),
                 range: 1 ... 24,
                 step: 1,
-                unitLabel: "hours",
+                unitLabel: String(localized: "hours"),
                 value: $alarm.threshold
             )
 

+ 8 - 8
LoopFollow/Alarm/AlarmEditing/Editors/TemporaryAlarmEditor.swift

@@ -19,19 +19,19 @@ struct TemporaryAlarmEditor: View {
             AlarmGeneralSection(alarm: $alarm)
 
             AlarmBGLimitSection(
-                header: "Low Limit",
-                footer: "Alert if BG is equal to or below this value.",
-                toggleText: "Enable low limit",
-                pickerTitle: "Below",
+                header: String(localized: "Low Limit"),
+                footer: String(localized: "Alert if BG is equal to or below this value."),
+                toggleText: String(localized: "Enable low limit"),
+                pickerTitle: String(localized: "Below"),
                 range: bgRange,
                 value: $alarm.belowBG
             )
 
             AlarmBGLimitSection(
-                header: "High Limit",
-                footer: "Alert if BG is equal to or above this value.",
-                toggleText: "Enable high limit",
-                pickerTitle: "Above",
+                header: String(localized: "High Limit"),
+                footer: String(localized: "Alert if BG is equal to or above this value."),
+                toggleText: String(localized: "Enable high limit"),
+                pickerTitle: String(localized: "Above"),
                 range: bgRange,
                 value: $alarm.aboveBG
             )

+ 1 - 1
LoopFollow/Alarm/AlarmManager.swift

@@ -149,7 +149,7 @@ class AlarmManager {
         }
 
         if isLatestReadingRecent, Storage.shared.persistentNotification.value, !alarmTriggered, let latestDate = data.bgReadings.last?.date, latestDate > Storage.shared.persistentNotificationLastBGTime.value {
-            sendNotification(title: "Latest BG")
+            sendNotification(title: String(localized: "Latest BG"))
             Storage.shared.persistentNotificationLastBGTime.value = now
         }
     }

+ 8 - 0
LoopFollow/Alarm/AlarmType/AlarmType.swift

@@ -38,6 +38,14 @@ extension AlarmType {
 }
 
 extension AlarmType {
+    /// Localized, user-facing title. `rawValue` stays the stable identity/storage key
+    /// (used for the alarm's default name and logs); this is only for display.
+    var localizedTitle: String {
+        String(localized: String.LocalizationValue(rawValue))
+    }
+}
+
+extension AlarmType {
     /// `true` for alarms whose primary trigger is a blood-glucose value
     /// or its rate of change.
     var isBGBased: Bool {

+ 3 - 0
LoopFollow/Application/AppDelegate.swift

@@ -13,6 +13,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         LogManager.shared.log(category: .general, message: "App started")
         LogManager.shared.cleanupOldLogs()
 
+        // Keep the AppleLanguages default in sync with the user's manual language choice.
+        AppLanguage.applyStored()
+
         let options: UNAuthorizationOptions = [.alert, .sound, .badge]
         notificationCenter.requestAuthorization(options: options) {
             didAllow, _ in

+ 50 - 0
LoopFollow/Helpers/AppLanguage.swift

@@ -0,0 +1,50 @@
+// LoopFollow
+// AppLanguage.swift
+
+import Foundation
+
+/// User-selectable app interface language.
+///
+/// `.system` follows the device language (the default). The other cases override
+/// it by writing the `AppleLanguages` user default, which iOS reads at launch to
+/// pick the bundle localization — so a change takes full effect after the app is
+/// restarted.
+enum AppLanguage: String, CaseIterable, Codable {
+    case system
+    case english
+    case chineseSimplified
+
+    var displayName: String {
+        switch self {
+        case .system: return String(localized: "System")
+        case .english: return "English"
+        case .chineseSimplified: return "简体中文"
+        }
+    }
+
+    /// The `AppleLanguages` code this option maps to, or `nil` to follow the system.
+    var languageCode: String? {
+        switch self {
+        case .system: return nil
+        case .english: return "en"
+        case .chineseSimplified: return "zh-Hans"
+        }
+    }
+
+    /// Writes (or clears) the `AppleLanguages` preference so iOS localizes the app
+    /// bundle accordingly on the next launch.
+    func applyToAppleLanguages() {
+        let defaults = UserDefaults.standard
+        if let code = languageCode {
+            defaults.set([code], forKey: "AppleLanguages")
+        } else {
+            defaults.removeObject(forKey: "AppleLanguages")
+        }
+    }
+
+    /// Re-asserts the stored preference at launch to keep `AppleLanguages` in sync
+    /// with the value shown in Settings.
+    static func applyStored() {
+        Storage.shared.appLanguage.value.applyToAppleLanguages()
+    }
+}

+ 3 - 3
LoopFollow/Helpers/AppearanceMode.swift

@@ -10,9 +10,9 @@ enum AppearanceMode: String, CaseIterable, Codable {
 
     var displayName: String {
         switch self {
-        case .system: return "System"
-        case .light: return "Light"
-        case .dark: return "Dark"
+        case .system: return String(localized: "System")
+        case .light: return String(localized: "Light")
+        case .dark: return String(localized: "Dark")
         }
     }
 

+ 11 - 11
LoopFollow/Helpers/TabPosition.swift

@@ -12,11 +12,11 @@ enum TabPosition: String, CaseIterable, Codable, Comparable {
 
     var displayName: String {
         switch self {
-        case .position1: return "Tab 1"
-        case .position2: return "Tab 2"
-        case .position3: return "Tab 3"
-        case .position4: return "Tab 4"
-        case .menu, .more, .disabled: return "Menu"
+        case .position1: return String(localized: "Tab 1")
+        case .position2: return String(localized: "Tab 2")
+        case .position3: return String(localized: "Tab 3")
+        case .position4: return String(localized: "Tab 4")
+        case .menu, .more, .disabled: return String(localized: "Menu")
         }
     }
 
@@ -67,13 +67,13 @@ enum TabItem: String, CaseIterable, Codable, Identifiable {
 
     var displayName: String {
         switch self {
-        case .home: return "Home"
-        case .alarms: return "Alarms"
-        case .remote: return "Remote"
+        case .home: return String(localized: "Home")
+        case .alarms: return String(localized: "Alarms")
+        case .remote: return String(localized: "Remote")
         case .nightscout: return "Nightscout"
-        case .snoozer: return "Snoozer"
-        case .treatments: return "Treatments"
-        case .stats: return "Statistics"
+        case .snoozer: return String(localized: "Snoozer")
+        case .treatments: return String(localized: "Treatments")
+        case .stats: return String(localized: "Statistics")
         }
     }
 

+ 1 - 1
LoopFollow/Helpers/Views/NavigationRow.swift

@@ -4,7 +4,7 @@
 import SwiftUI
 
 struct NavigationRow<Value: Hashable>: View {
-    let title: String
+    let title: LocalizedStringKey
     let icon: String
     var iconTint: Color = .white
     let value: Value

+ 4 - 4
LoopFollow/Helpers/Views/QuickPickSectionHeader.swift

@@ -51,19 +51,19 @@ private struct QuickPickInfoSheet: View {
 }
 
 extension QuickPickSectionHeader {
-    static let bolusInfoText = """
+    static let bolusInfoText = String(localized: """
     These buttons show your most-used recent bolus amounts.
 
     They're based on what you've sent before at similar times on similar days — so if you usually give 4 units before breakfast on weekdays, that button will show up on weekday mornings.
 
     Tap a button to fill in the amount. Nothing is sent until you review and confirm.
-    """
+    """)
 
-    static let mealInfoText = """
+    static let mealInfoText = String(localized: """
     These buttons show your most-used recent meals.
 
     They're based on what you've sent before at similar times on similar days — so if you usually send the same breakfast on weekday mornings, it'll appear as an option.
 
     Tap a button to fill in the details. Nothing is sent until you review and confirm.
-    """
+    """)
 }

+ 1 - 1
LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift

@@ -50,7 +50,7 @@ class SimpleQRCodeScannerViewController: UIViewController {
             target: self,
             action: #selector(cancelTapped)
         )
-        navigationItem.title = "Scan QR Code"
+        navigationItem.title = String(localized: "Scan QR Code")
 
         setupCamera()
     }

+ 14 - 14
LoopFollow/LiveActivity/LAAppGroupSettings.swift

@@ -44,28 +44,28 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable {
     /// Human-readable label shown in the slot picker in Settings.
     var displayName: String {
         switch self {
-        case .none: "Empty"
-        case .delta: "Delta"
-        case .projectedBG: "Projected BG"
-        case .minMax: "Min/Max"
+        case .none: String(localized: "Empty")
+        case .delta: String(localized: "Delta")
+        case .projectedBG: String(localized: "Projected BG")
+        case .minMax: String(localized: "Min/Max")
         case .iob: "IOB"
         case .cob: "COB"
-        case .recBolus: "Rec. Bolus"
-        case .autosens: "Autosens"
+        case .recBolus: String(localized: "Rec. Bolus")
+        case .autosens: String(localized: "Autosens")
         case .tdd: "TDD"
-        case .basal: "Basal"
-        case .pump: "Pump"
-        case .pumpBattery: "Pump Battery"
-        case .battery: "Battery"
-        case .target: "Target"
+        case .basal: String(localized: "Basal")
+        case .pump: String(localized: "Pump")
+        case .pumpBattery: String(localized: "Pump Battery")
+        case .battery: String(localized: "Battery")
+        case .target: String(localized: "Target")
         case .isf: "ISF"
         case .carbRatio: "CR"
         case .sage: "SAGE"
         case .cage: "CAGE"
         case .iage: "IAGE"
-        case .carbsToday: "Carbs today"
-        case .override: "Override"
-        case .profile: "Profile"
+        case .carbsToday: String(localized: "Carbs today")
+        case .override: String(localized: "Override")
+        case .profile: String(localized: "Profile")
         }
     }
 

+ 6 - 6
LoopFollow/LiveActivity/LiveActivityManager.swift

@@ -1321,8 +1321,8 @@ final class LiveActivityManager {
 
     private func scheduleRenewalFailedNotification() {
         let content = UNMutableNotificationContent()
-        content.title = "Live Activity Expiring"
-        content.body = "Live Activity will expire soon. Open LoopFollow to restart."
+        content.title = String(localized: "Live Activity Expiring")
+        content.body = String(localized: "Live Activity will expire soon. Open LoopFollow to restart.")
         content.sound = .default
         let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
         let request = UNNotificationRequest(
@@ -1346,8 +1346,8 @@ final class LiveActivityManager {
 
     private func scheduleApnsCredentialsMissingNotification() {
         let content = UNMutableNotificationContent()
-        content.title = "Live Activity Setup Needed"
-        content.body = "APNs credentials are missing or invalid. Configure them in Settings → APN."
+        content.title = String(localized: "Live Activity Setup Needed")
+        content.body = String(localized: "APNs credentials are missing or invalid. Configure them in Settings → APN.")
         content.sound = .default
         let request = UNNotificationRequest(
             identifier: LiveActivityManager.apnsCredentialsNotificationID,
@@ -1359,8 +1359,8 @@ final class LiveActivityManager {
 
     private func schedulePushToStartTokenMissingNotification() {
         let content = UNMutableNotificationContent()
-        content.title = "Live Activity Could Not Start"
-        content.body = "Live Activity could not start — try again in a moment."
+        content.title = String(localized: "Live Activity Could Not Start")
+        content.body = String(localized: "Live Activity could not start — try again in a moment.")
         content.sound = .default
         let request = UNNotificationRequest(
             identifier: LiveActivityManager.pushToStartTokenNotificationID,

+ 1 - 1
LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift

@@ -74,7 +74,7 @@ struct LoopAPNSBolusView: View {
                     }
 
                     if !quickPickBoluses.quickPickBoluses.isEmpty {
-                        Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) {
+                        Section(header: QuickPickSectionHeader(title: String(localized: "Quick-Pick Boluses"), infoText: QuickPickSectionHeader.bolusInfoText)) {
                             ScrollView(.horizontal, showsIndicators: false) {
                                 HStack(spacing: 12) {
                                     ForEach(quickPickBoluses.quickPickBoluses) { bolus in

+ 1 - 1
LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift

@@ -104,7 +104,7 @@ struct LoopAPNSCarbsView: View {
             VStack {
                 Form {
                     if !quickPickMeals.quickPickMeals.isEmpty {
-                        Section(header: QuickPickSectionHeader(title: "Quick-Pick Meals", infoText: QuickPickSectionHeader.mealInfoText)) {
+                        Section(header: QuickPickSectionHeader(title: String(localized: "Quick-Pick Meals"), infoText: QuickPickSectionHeader.mealInfoText)) {
                             ScrollView(.horizontal, showsIndicators: false) {
                                 HStack(spacing: 12) {
                                     ForEach(quickPickMeals.quickPickMeals) { meal in

+ 2 - 2
LoopFollow/Remote/Settings/RemoteSettingsView.swift

@@ -510,7 +510,7 @@ struct RemoteSettingsView: View {
             }
             if let mismatch = viewModel.diagnostics.bundleMismatch {
                 diagnosticWarning(
-                    title: "Profile uploaded by a different app",
+                    title: String(localized: "Profile uploaded by a different app"),
                     detail: "The current Nightscout profile was uploaded by \(mismatch.observedBundleId), but you're configured for \(mismatch.expectedDevice). When Loop and Trio share a Nightscout, they overwrite each other's profile."
                 )
             }
@@ -519,7 +519,7 @@ struct RemoteSettingsView: View {
             }
             if let future = viewModel.diagnostics.futureStartDate {
                 diagnosticWarning(
-                    title: "Future-dated profile record found",
+                    title: String(localized: "Future-dated profile record found"),
                     detail: "A profile record has startDate \(dateTimeUtils.formattedDate(from: future.startDate)). LoopFollow ignores future-dated records, but it will still appear as the current profile in your Nightscout dashboard. Consider deleting it — it usually means a phone with the wrong system clock is uploading."
                 )
             }

+ 1 - 1
LoopFollow/Remote/TRC/BolusView.swift

@@ -69,7 +69,7 @@ struct BolusView: View {
                     recommendedBlocks(now: context.date)
 
                     if !quickPickBoluses.quickPickBoluses.isEmpty {
-                        Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) {
+                        Section(header: QuickPickSectionHeader(title: String(localized: "Quick-Pick Boluses"), infoText: QuickPickSectionHeader.bolusInfoText)) {
                             ScrollView(.horizontal, showsIndicators: false) {
                                 HStack(spacing: 12) {
                                     ForEach(quickPickBoluses.quickPickBoluses) { bolus in

+ 1 - 1
LoopFollow/Remote/TRC/MealView.swift

@@ -48,7 +48,7 @@ struct MealView: View {
             VStack {
                 Form {
                     if !quickPickMeals.quickPickMeals.isEmpty {
-                        Section(header: QuickPickSectionHeader(title: "Quick-Pick Meals", infoText: QuickPickSectionHeader.mealInfoText)) {
+                        Section(header: QuickPickSectionHeader(title: String(localized: "Quick-Pick Meals"), infoText: QuickPickSectionHeader.mealInfoText)) {
                             ScrollView(.horizontal, showsIndicators: false) {
                                 HStack(spacing: 12) {
                                     ForEach(quickPickMeals.quickPickMeals) { meal in

+ 78 - 0
LoopFollow/Resources/InfoPlist.xcstrings

@@ -0,0 +1,78 @@
+{
+  "sourceLanguage" : "en",
+  "strings" : {
+    "NSBluetoothAlwaysUsageDescription" : {
+      "comment" : "Bluetooth usage",
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "本应用使用蓝牙连接设备,以管理后台运行操作。"
+          }
+        }
+      }
+    },
+    "NSCalendarsFullAccessUsageDescription" : {
+      "comment" : "Calendar full access",
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Loop Follow 需要访问你的日历,以更新血糖读数。"
+          }
+        }
+      }
+    },
+    "NSCalendarsUsageDescription" : {
+      "comment" : "Calendar access",
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Loop Follow 需要访问你的日历,以保存血糖读数。"
+          }
+        }
+      }
+    },
+    "NSCameraUsageDescription" : {
+      "comment" : "Camera usage",
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "用于扫描二维码以进行远程身份验证。"
+          }
+        }
+      }
+    },
+    "NSContactsUsageDescription" : {
+      "comment" : "Contacts usage",
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "本应用需要访问通讯录,以便用实时血糖信息更新联系人头像。"
+          }
+        }
+      }
+    },
+    "NSFaceIDUsageDescription" : {
+      "comment" : "Face ID usage",
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "本应用需要使用面容 ID 进行安全身份验证。"
+          }
+        }
+      }
+    }
+  },
+  "version" : "1.0"
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 7123 - 0
LoopFollow/Resources/Localizable.xcstrings


+ 12 - 12
LoopFollow/Settings/ContactSettingsView.swift

@@ -37,7 +37,7 @@ struct ContactSettingsView: View {
 
                     Picker("Background Color", selection: $viewModel.contactBackgroundColor) {
                         ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
-                            Text(option.rawValue.capitalized).tag(option.rawValue)
+                            Text(LocalizedStringKey(option.rawValue.capitalized)).tag(option.rawValue)
                         }
                     }
 
@@ -50,7 +50,7 @@ struct ContactSettingsView: View {
                     if viewModel.contactColorMode == .staticColor {
                         Picker("Text Color", selection: $viewModel.contactTextColor) {
                             ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
-                                Text(option.rawValue.capitalized).tag(option.rawValue)
+                                Text(LocalizedStringKey(option.rawValue.capitalized)).tag(option.rawValue)
                             }
                         }
                     } else {
@@ -70,7 +70,7 @@ struct ContactSettingsView: View {
                         .font(.subheadline)
                     Picker("Show Trend", selection: $viewModel.contactTrend) {
                         ForEach(ContactIncludeOption.allCases, id: \.self) { option in
-                            Text(option.rawValue).tag(option)
+                            Text(LocalizedStringKey(option.rawValue)).tag(option)
                         }
                     }
                     .pickerStyle(SegmentedPickerStyle())
@@ -78,7 +78,7 @@ struct ContactSettingsView: View {
                     if viewModel.contactTrend == .include {
                         Picker("Include Trend in", selection: $viewModel.contactTrendTarget) {
                             ForEach(viewModel.availableTargets(for: .Trend), id: \.self) { target in
-                                Text(target.rawValue).tag(target)
+                                Text(LocalizedStringKey(target.rawValue)).tag(target)
                             }
                         }
                     }
@@ -87,7 +87,7 @@ struct ContactSettingsView: View {
                         .font(.subheadline)
                     Picker("Show Delta", selection: $viewModel.contactDelta) {
                         ForEach(ContactIncludeOption.allCases, id: \.self) { option in
-                            Text(option.rawValue).tag(option)
+                            Text(LocalizedStringKey(option.rawValue)).tag(option)
                         }
                     }
                     .pickerStyle(SegmentedPickerStyle())
@@ -95,7 +95,7 @@ struct ContactSettingsView: View {
                     if viewModel.contactDelta == .include {
                         Picker("Include Delta in", selection: $viewModel.contactDeltaTarget) {
                             ForEach(viewModel.availableTargets(for: .Delta), id: \.self) { target in
-                                Text(target.rawValue).tag(target)
+                                Text(LocalizedStringKey(target.rawValue)).tag(target)
                             }
                         }
                     }
@@ -104,7 +104,7 @@ struct ContactSettingsView: View {
                         .font(.subheadline)
                     Picker("Show IOB", selection: $viewModel.contactIOB) {
                         ForEach(ContactIncludeOption.allCases, id: \.self) { option in
-                            Text(option.rawValue).tag(option)
+                            Text(LocalizedStringKey(option.rawValue)).tag(option)
                         }
                     }
                     .pickerStyle(SegmentedPickerStyle())
@@ -112,7 +112,7 @@ struct ContactSettingsView: View {
                     if viewModel.contactIOB == .include {
                         Picker("Include IOB in", selection: $viewModel.contactIOBTarget) {
                             ForEach(viewModel.availableTargets(for: .IOB), id: \.self) { target in
-                                Text(target.rawValue).tag(target)
+                                Text(LocalizedStringKey(target.rawValue)).tag(target)
                             }
                         }
                     }
@@ -137,19 +137,19 @@ struct ContactSettingsView: View {
                 DispatchQueue.main.async {
                     if !granted {
                         viewModel.contactEnabled = false
-                        showAlert(title: "Access Denied", message: "Please allow access to Contacts in Settings to enable this feature.")
+                        showAlert(title: String(localized: "Access Denied"), message: String(localized: "Please allow access to Contacts in Settings to enable this feature."))
                     }
                 }
             }
         } else if status == .denied {
             viewModel.contactEnabled = false
-            showAlert(title: "Access Denied", message: "Access to Contacts is denied. Please go to Settings and enable Contacts access.")
+            showAlert(title: String(localized: "Access Denied"), message: String(localized: "Access to Contacts is denied. Please go to Settings and enable Contacts access."))
         } else if status == .restricted {
             viewModel.contactEnabled = false
-            showAlert(title: "Access Restricted", message: "Access to Contacts is restricted.")
+            showAlert(title: String(localized: "Access Restricted"), message: String(localized: "Access to Contacts is restricted."))
         } else {
             viewModel.contactEnabled = false
-            showAlert(title: "Error", message: "An unknown error occurred while checking Contacts access.")
+            showAlert(title: String(localized: "Error"), message: String(localized: "An unknown error occurred while checking Contacts access."))
         }
     }
 

+ 27 - 3
LoopFollow/Settings/GeneralSettingsView.swift

@@ -7,6 +7,9 @@ struct GeneralSettingsView: View {
     @ObservedObject var colorBGText = Storage.shared.colorBGText
     @ObservedObject var appBadge = Storage.shared.appBadge
     @ObservedObject var appearanceMode = Storage.shared.appearanceMode
+    @ObservedObject var appLanguage = Storage.shared.appLanguage
+
+    @State private var showLanguageRestartAlert = false
     @ObservedObject var showStats = Storage.shared.showStats
     @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph
     @ObservedObject var screenlockSwitchState = Storage.shared.screenlockSwitchState
@@ -38,6 +41,22 @@ struct GeneralSettingsView: View {
                 Toggle("Persistent Notification", isOn: $persistentNotification.value)
             }
 
+            Section {
+                Picker("App Language", selection: $appLanguage.value) {
+                    ForEach(AppLanguage.allCases, id: \.self) { lang in
+                        Text(lang.displayName).tag(lang)
+                    }
+                }
+                .onChange(of: appLanguage.value) { newValue in
+                    newValue.applyToAppleLanguages()
+                    showLanguageRestartAlert = true
+                }
+            } header: {
+                Text("Language")
+            } footer: {
+                Text("Overrides the system language for this app. Restart the app to apply.")
+            }
+
             Section("Display") {
                 Picker("Appearance", selection: $appearanceMode.value) {
                     ForEach(AppearanceMode.allCases, id: \.self) { mode in
@@ -106,7 +125,7 @@ struct GeneralSettingsView: View {
 
                         if speakLowBG.value || speakProactiveLowBG.value {
                             BGPicker(
-                                title: "Low BG Limit",
+                                title: String(localized: "Low BG Limit"),
                                 range: 40 ... 108,
                                 value: $speakLowBGLimit.value
                             )
@@ -114,7 +133,7 @@ struct GeneralSettingsView: View {
 
                         if speakProactiveLowBG.value {
                             BGPicker(
-                                title: "Fast Drop Delta",
+                                title: String(localized: "Fast Drop Delta"),
                                 range: 3 ... 20,
                                 value: $speakFastDropDelta.value
                             )
@@ -124,7 +143,7 @@ struct GeneralSettingsView: View {
 
                         if speakHighBG.value {
                             BGPicker(
-                                title: "High BG Limit",
+                                title: String(localized: "High BG Limit"),
                                 range: 140 ... 300,
                                 value: $speakHighBGLimit.value
                             )
@@ -146,6 +165,11 @@ struct GeneralSettingsView: View {
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("General Settings", displayMode: .inline)
+        .alert("Restart Required", isPresented: $showLanguageRestartAlert) {
+            Button("OK", role: .cancel) {}
+        } message: {
+            Text("Fully close and reopen LoopFollow for the language change to take effect.")
+        }
     }
 
     private func markChartSettingsDirty() {

+ 5 - 5
LoopFollow/Settings/GraphSettingsView.swift

@@ -65,7 +65,7 @@ struct GraphSettingsView: View {
             // ── Small Graph ──────────────────────────────────────────────
             Section("Small Graph") {
                 SettingsStepperRow(
-                    title: "Height",
+                    title: String(localized: "Height"),
                     range: 40 ... 80,
                     step: 5,
                     value: $smallGraphHeight.value,
@@ -78,7 +78,7 @@ struct GraphSettingsView: View {
             if nightscoutEnabled {
                 Section("Prediction") {
                     SettingsStepperRow(
-                        title: "Hours of Prediction",
+                        title: String(localized: "Hours of Prediction"),
                         range: 0 ... 6,
                         step: 0.25,
                         value: $predictionToLoad.value,
@@ -100,7 +100,7 @@ struct GraphSettingsView: View {
             if nightscoutEnabled {
                 Section("Basal / BG Scale") {
                     SettingsStepperRow(
-                        title: "Min Basal",
+                        title: String(localized: "Min Basal"),
                         range: 0.5 ... 20,
                         step: 0.5,
                         value: $minBasalScale.value,
@@ -108,7 +108,7 @@ struct GraphSettingsView: View {
                     )
 
                     BGPicker(
-                        title: "Min BG Scale",
+                        title: String(localized: "Min BG Scale"),
                         range: 40 ... 400,
                         value: $minBGScale.value
                     )
@@ -120,7 +120,7 @@ struct GraphSettingsView: View {
             if nightscoutEnabled {
                 Section("History") {
                     SettingsStepperRow(
-                        title: "Show Days Back",
+                        title: String(localized: "Show Days Back"),
                         range: 1 ... 4,
                         step: 1,
                         value: $downloadDays.value,

+ 4 - 4
LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift

@@ -155,7 +155,7 @@ struct ImportConfirmationView: View {
                             if let url = preview.nightscoutURL, !url.isEmpty {
                                 SettingRowView(
                                     icon: "network",
-                                    title: "Nightscout URL",
+                                    title: String(localized: "Nightscout URL"),
                                     value: url,
                                     color: .blue
                                 )
@@ -164,7 +164,7 @@ struct ImportConfirmationView: View {
                             if let username = preview.dexcomUsername, !username.isEmpty {
                                 SettingRowView(
                                     icon: "person.circle",
-                                    title: "Dexcom Username",
+                                    title: String(localized: "Dexcom Username"),
                                     value: username,
                                     color: .green
                                 )
@@ -173,7 +173,7 @@ struct ImportConfirmationView: View {
                             if let remoteType = preview.remoteType, !remoteType.isEmpty, remoteType != "None" {
                                 SettingRowView(
                                     icon: "antenna.radiowaves.left.and.right",
-                                    title: "Remote Type",
+                                    title: String(localized: "Remote Type"),
                                     value: remoteType,
                                     color: .orange
                                 )
@@ -182,7 +182,7 @@ struct ImportConfirmationView: View {
                             if preview.alarmCount > 0 {
                                 SettingRowView(
                                     icon: "bell",
-                                    title: "Alarms",
+                                    title: String(localized: "Alarms"),
                                     value: "\(preview.alarmCount) alarm(s): \(preview.alarmNames.joined(separator: ", "))",
                                     color: .red
                                 )

+ 1 - 1
LoopFollow/Settings/SettingsMenuView.swift

@@ -201,7 +201,7 @@ extension UIViewController {
         let a = UIAlertController(title: title,
                                   message: message,
                                   preferredStyle: .alert)
-        a.addAction(UIAlertAction(title: "OK", style: .default))
+        a.addAction(UIAlertAction(title: String(localized: "OK"), style: .default))
         present(a, animated: true)
     }
 }

+ 1 - 1
LoopFollow/Settings/TabCustomizationModal.swift

@@ -18,7 +18,7 @@ enum TabCustomizationItem: Identifiable, Equatable, Hashable {
     var displayName: String {
         switch self {
         case let .tabItem(item): return item.displayName
-        case .settings: return "Menu"
+        case .settings: return String(localized: "Menu")
         }
     }
 

+ 2 - 2
LoopFollow/Settings/UnitsConfigurationView.swift

@@ -38,7 +38,7 @@ struct UnitsConfigurationView: View {
 
                 if rangeMode == .custom {
                     BGPicker(
-                        title: "Low",
+                        title: String(localized: "Low"),
                         range: 40 ... 120,
                         value: $lowValue
                     )
@@ -48,7 +48,7 @@ struct UnitsConfigurationView: View {
                         Observable.shared.chartSettingsChanged.value = true
                     }
                     BGPicker(
-                        title: "High",
+                        title: String(localized: "High"),
                         range: 120 ... 400,
                         value: $highValue
                     )

+ 10 - 10
LoopFollow/Stats/AggregatedStatsView.swift

@@ -274,12 +274,12 @@ struct InsulinTotalsCard: View {
                 .foregroundColor(.secondary)
 
             VStack(spacing: 10) {
-                metricRow(title: "Programmed Basal", value: programmedBasal, color: .indigo)
+                metricRow(title: String(localized: "Programmed Basal"), value: programmedBasal, color: .indigo)
                 metricRow(title: "Total Negative Basal", value: totalNegativeBasal, color: .red)
                 metricRow(title: "Total Positive Basal", value: totalPositiveBasal, color: .green)
                 metricRow(title: "Actual Basal", value: actualBasal, color: .indigo)
-                metricRow(title: "Avg Bolus", value: avgBolus, color: .purple)
-                metricRow(title: "Total Daily Dose", value: totalDailyDose, color: .pink)
+                metricRow(title: String(localized: "Avg Bolus"), value: avgBolus, color: .purple)
+                metricRow(title: String(localized: "Total Daily Dose"), value: totalDailyDose, color: .pink)
             }
         }
         .frame(maxWidth: .infinity, alignment: .leading)
@@ -334,7 +334,7 @@ struct StatsGridView: View {
                     showGMI.toggle()
                 }) {
                     StatCard(
-                        title: showGMI ? "GMI" : "Est. A1C",
+                        title: showGMI ? "GMI" : String(localized: "Est. A1C"),
                         value: showGMI ? formatGMI(simpleStats.avgGlucose) : formatEhbA1c(simpleStats.avgGlucose),
                         unit: UnitSettingsStore.shared.glycemicOutputUnit.rawValue,
                         color: .blue,
@@ -344,7 +344,7 @@ struct StatsGridView: View {
                 .buttonStyle(PlainButtonStyle())
 
                 StatCard(
-                    title: "Avg Glucose",
+                    title: String(localized: "Avg Glucose"),
                     value: formatGlucose(simpleStats.avgGlucose),
                     unit: UnitSettingsStore.shared.glucoseUnit.rawValue,
                     color: Color(red: 0.20, green: 0.99, blue: 0.70)
@@ -354,7 +354,7 @@ struct StatsGridView: View {
                     showStdDev.toggle()
                 }) {
                     StatCard(
-                        title: showStdDev ? "Std Deviation" : "CV",
+                        title: showStdDev ? String(localized: "Std Deviation") : "CV",
                         value: showStdDev ? formatStdDev(simpleStats.stdDeviation) : formatCV(simpleStats.coefficientOfVariation),
                         unit: showStdDev ? UnitSettingsStore.shared.glucoseUnit.rawValue : "%",
                         color: .orange,
@@ -365,7 +365,7 @@ struct StatsGridView: View {
 
                 if hasCarbData {
                     StatCard(
-                        title: "Avg Carbs",
+                        title: String(localized: "Avg Carbs"),
                         value: formatCarbs(simpleStats.avgCarbs),
                         unit: "g/day",
                         color: .yellow
@@ -374,7 +374,7 @@ struct StatsGridView: View {
 
                 if hasInsulinData {
                     StatCard(
-                        title: "Programmed Basal",
+                        title: String(localized: "Programmed Basal"),
                         value: formatInsulin(simpleStats.programmedBasal),
                         unit: "U/day",
                         color: .indigo
@@ -384,7 +384,7 @@ struct StatsGridView: View {
                     }
 
                     StatCard(
-                        title: "Avg Bolus",
+                        title: String(localized: "Avg Bolus"),
                         value: formatInsulin(simpleStats.avgBolus),
                         unit: "U/day",
                         color: .purple
@@ -394,7 +394,7 @@ struct StatsGridView: View {
                     }
 
                     StatCard(
-                        title: "Total Daily Dose",
+                        title: String(localized: "Total Daily Dose"),
                         value: formatInsulin(simpleStats.totalDailyDose),
                         unit: "U",
                         color: .pink

+ 2 - 0
LoopFollow/Storage/Storage.swift

@@ -70,6 +70,7 @@ class Storage {
     var appBadge = StorageValue<Bool>(key: "appBadge", defaultValue: true)
     var colorBGText = StorageValue<Bool>(key: "colorBGText", defaultValue: true)
     var appearanceMode = StorageValue<AppearanceMode>(key: "appearanceMode", defaultValue: .dark)
+    var appLanguage = StorageValue<AppLanguage>(key: "appLanguage", defaultValue: .system)
     var showStats = StorageValue<Bool>(key: "showStats", defaultValue: true)
     var useIFCC = StorageValue<Bool>(key: "useIFCC", defaultValue: false)
     var showSmallGraph = StorageValue<Bool>(key: "showSmallGraph", defaultValue: true)
@@ -308,6 +309,7 @@ class Storage {
         appBadge.reload()
         colorBGText.reload()
         appearanceMode.reload()
+        appLanguage.reload()
         showStats.reload()
         useIFCC.reload()
         showSmallGraph.reload()

+ 16 - 16
LoopFollow/Treatments/TreatmentsView.swift

@@ -882,13 +882,13 @@ enum TreatmentType: CaseIterable, Hashable {
 
     var displayName: String {
         switch self {
-        case .carb: return "Carbs"
-        case .bolusManual: return "Bolus"
-        case .bolusAutomatic: return "Automatic"
+        case .carb: return String(localized: "Carbs")
+        case .bolusManual: return String(localized: "Bolus")
+        case .bolusAutomatic: return String(localized: "Automatic")
         case .smb: return "SMB"
-        case .tempBasal: return "Basal"
-        case .override: return "Override"
-        case .tempTarget: return "Temp Target"
+        case .tempBasal: return String(localized: "Basal")
+        case .override: return String(localized: "Override")
+        case .tempTarget: return String(localized: "Temp Target")
         }
     }
 }
@@ -904,13 +904,13 @@ enum TreatmentFilter: Hashable {
 
     func displayName(automaticLabel: String) -> String {
         switch self {
-        case .all: return "All"
-        case .carb: return "Carbs"
-        case .basal: return "Basal"
-        case .bolusAll: return "All Boluses"
+        case .all: return String(localized: "All")
+        case .carb: return String(localized: "Carbs")
+        case .basal: return String(localized: "Basal")
+        case .bolusAll: return String(localized: "All Boluses")
         case .bolusAutomatic: return automaticLabel
         case .bolusSMB: return "SMB"
-        case .bolusManual: return "Manual"
+        case .bolusManual: return String(localized: "Manual")
         }
     }
 
@@ -1179,7 +1179,7 @@ class TreatmentsViewModel: ObservableObject {
                         type: .carb,
                         date: timestamp,
                         title: "\(Int(carbs))g",
-                        subtitle: "Carbs",
+                        subtitle: String(localized: "Carbs"),
                         icon: "circle.fill",
                         color: .orange,
                         bgValue: actualBG
@@ -1197,7 +1197,7 @@ class TreatmentsViewModel: ObservableObject {
                         type: .bolusManual,
                         date: timestamp,
                         title: String(format: "%.2f U", insulin),
-                        subtitle: "Bolus",
+                        subtitle: String(localized: "Bolus"),
                         icon: "circle.fill",
                         color: .blue,
                         bgValue: actualBG
@@ -1226,7 +1226,7 @@ class TreatmentsViewModel: ObservableObject {
                             type: .bolusAutomatic,
                             date: timestamp,
                             title: String(format: "%.2f U", insulin),
-                            subtitle: isSMB || isTrioSMBType ? "SMB" : "Automatic Bolus",
+                            subtitle: isSMB || isTrioSMBType ? "SMB" : String(localized: "Automatic Bolus"),
                             icon: "arrowtriangle.down.fill",
                             color: .blue,
                             bgValue: actualBG
@@ -1238,7 +1238,7 @@ class TreatmentsViewModel: ObservableObject {
                             type: .bolusManual,
                             date: timestamp,
                             title: String(format: "%.2f U", insulin),
-                            subtitle: "Bolus",
+                            subtitle: String(localized: "Bolus"),
                             icon: "circle.fill",
                             color: .blue,
                             bgValue: actualBG
@@ -1271,7 +1271,7 @@ class TreatmentsViewModel: ObservableObject {
                         type: .tempBasal,
                         date: timestamp,
                         title: String(format: "%.2f U/hr", rate),
-                        subtitle: "Temp Basal",
+                        subtitle: String(localized: "Temp Basal"),
                         icon: "chart.xyaxis.line",
                         color: .blue,
                         bgValue: 0

+ 6 - 6
LoopFollow/ViewControllers/MainViewController.swift

@@ -454,7 +454,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio
 
         let loadingLabel = UILabel()
         loadingLabel.translatesAutoresizingMaskIntoConstraints = false
-        loadingLabel.text = "Loading..."
+        loadingLabel.text = String(localized: "Loading...")
         loadingLabel.textAlignment = .center
         loadingLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
         loadingLabel.textColor = UIColor.secondaryLabel
@@ -766,7 +766,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio
     func versionAlert(title: String = "Update Available", message: String) {
         DispatchQueue.main.async {
             let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
-            alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
+            alert.addAction(UIAlertAction(title: String(localized: "OK"), style: .default, handler: nil))
             self.present(alert, animated: true)
         }
     }
@@ -787,8 +787,8 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio
 
     func expirationAlert() {
         DispatchQueue.main.async {
-            let alert = UIAlertController(title: "App Expiration Warning", message: "This app will expire in less than a week. Please rebuild to continue using it.", preferredStyle: .alert)
-            alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
+            let alert = UIAlertController(title: String(localized: "App Expiration Warning"), message: String(localized: "This app will expire in less than a week. Please rebuild to continue using it."), preferredStyle: .alert)
+            alert.addAction(UIAlertAction(title: String(localized: "OK"), style: .default, handler: nil))
             self.present(alert, animated: true)
         }
     }
@@ -1101,7 +1101,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio
         // Create Setup Nightscout button
         if setupNightscoutButton == nil {
             setupNightscoutButton = UIButton(type: .system)
-            setupNightscoutButton.setTitle("Setup Nightscout", for: .normal)
+            setupNightscoutButton.setTitle(String(localized: "Setup Nightscout"), for: .normal)
             setupNightscoutButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
             setupNightscoutButton.backgroundColor = UIColor.systemBlue
             setupNightscoutButton.setTitleColor(.white, for: .normal)
@@ -1126,7 +1126,7 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio
         // Create Setup Dexcom Share button
         if setupDexcomButton == nil {
             setupDexcomButton = UIButton(type: .system)
-            setupDexcomButton.setTitle("Setup Dexcom Share", for: .normal)
+            setupDexcomButton.setTitle(String(localized: "Setup Dexcom Share"), for: .normal)
             setupDexcomButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
             setupDexcomButton.backgroundColor = UIColor.systemGreen
             setupDexcomButton.setTitleColor(.white, for: .normal)

+ 4 - 4
LoopFollow/ViewControllers/MoreMenuView.swift

@@ -81,8 +81,8 @@ struct MoreMenuView: View {
 
             // Build Information
             Section("Build Information") {
-                buildInfoRow(title: "Version", value: currentVersion, color: versionTint)
-                buildInfoRow(title: "Latest version", value: latestVersion ?? "Fetching…", color: .secondary)
+                buildInfoRow(title: String(localized: "Version"), value: currentVersion, color: versionTint)
+                buildInfoRow(title: String(localized: "Latest version"), value: latestVersion ?? String(localized: "Fetching…"), color: .secondary)
 
                 let build = BuildDetails.default
                 if !(build.isMacApp() || build.isSimulatorBuild()) {
@@ -93,8 +93,8 @@ struct MoreMenuView: View {
                     )
                 }
 
-                buildInfoRow(title: "Built", value: dateTimeUtils.formattedDate(from: build.buildDate()), color: .secondary)
-                buildInfoRow(title: "Branch", value: build.branchAndSha, color: .secondary)
+                buildInfoRow(title: String(localized: "Built"), value: dateTimeUtils.formattedDate(from: build.buildDate()), color: .secondary)
+                buildInfoRow(title: String(localized: "Branch"), value: build.branchAndSha, color: .secondary)
             }
         }
         .listStyle(.insetGrouped)

+ 2 - 2
LoopFollow/ViewControllers/NightScoutViewController.swift

@@ -93,11 +93,11 @@ extension NightscoutViewController: WKNavigationDelegate, WKUIDelegate {
     func webView(_: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame _: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
         let alertCtrl = UIAlertController(title: nil, message: message, preferredStyle: .alert)
 
-        alertCtrl.addAction(UIAlertAction(title: "OK", style: .default) { _ in
+        alertCtrl.addAction(UIAlertAction(title: String(localized: "OK"), style: .default) { _ in
             completionHandler(true)
         })
 
-        alertCtrl.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
+        alertCtrl.addAction(UIAlertAction(title: String(localized: "Cancel"), style: .cancel) { _ in
             completionHandler(false)
         })
 

+ 72 - 0
LoopFollowLAExtension/Localizable.xcstrings

@@ -0,0 +1,72 @@
+{
+  "sourceLanguage" : "en",
+  "strings" : {
+    "Delta: %@" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "变化值:%@"
+          }
+        }
+      }
+    },
+    "Loop has not reported in 15+ minutes" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "闭环已超过 15 分钟未上报"
+          }
+        }
+      }
+    },
+    "Not Looping" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "未闭环"
+          }
+        }
+      }
+    },
+    "Tap to update" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "点按以更新"
+          }
+        }
+      }
+    },
+    "Updated at: %@" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "更新于:%@"
+          }
+        }
+      }
+    },
+    "⚠️ Not Looping" : {
+      "extractionState" : "manual",
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "⚠️ 未闭环"
+          }
+        }
+      }
+    }
+  },
+  "version" : "1.0"
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 830 - 0
Scripts/gen_zh_catalog.py