diff --git a/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj b/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj index 0c1c5cf02..37f2eb324 100644 --- a/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj +++ b/ios/FT8AF/FT8AF.xcodeproj/project.pbxproj @@ -15,17 +15,22 @@ 303BF422AF4C5F94DF696E5D /* FT8Rig in Frameworks */ = {isa = PBXBuildFile; productRef = 4E7B6368450390C0D64A5715 /* FT8Rig */; }; 33736A682F52CCBB13E8168A /* LogbookScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76108617D9AEDEE84BAD1054 /* LogbookScreen.swift */; }; 3BA2BC0EFAF62534D39C3A4A /* DecodeFilterSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88688AC6AFBEDC8AC3EF844 /* DecodeFilterSettings.swift */; }; + 3BCBA78527E44AD070AA1E48 /* AudioRoutePickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4217D8419318D6A336873605 /* AudioRoutePickerButton.swift */; }; 49DFEC8ED0237764EA3EE3BF /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF21F5D95598018B8A46ED8D /* AppState.swift */; }; 4C0C9DCC39ED6528F0D4CDFF /* RadioAudioSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89642B9763C7FA33CFF0EE57 /* RadioAudioSettings.swift */; }; 4D87F21162B4DCEC806D8785 /* QsoCelebration.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC8DAEDCAE1AD9DE02EB1480 /* QsoCelebration.swift */; }; + 4F742232FB7AE5A02DF90DE8 /* geist_mono_semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CFC0DD2F717A726A4887913B /* geist_mono_semibold.ttf */; }; 51B44F6A9F0A05A7B3CB71FC /* FrequencyPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C698656C5709343F4AEEF0 /* FrequencyPickerSheet.swift */; }; 595BBF17D95723947D5BFFC4 /* PotaScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35E490F93FDCACEC4FBF66F /* PotaScreen.swift */; }; 5C76E98A4BD4854709C3D0E9 /* AppTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53063BBF11F97C62BC0AFC9 /* AppTabView.swift */; }; 5E2B3105FFFE6C84D86ACD85 /* TransmitGlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF04207CDB5BE8A1CCDD5F5B /* TransmitGlow.swift */; }; 608EF9D6EAE6CDA82E9E86C4 /* QsoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0290AFE608DC2FC9E1A83E16 /* QsoSheet.swift */; }; 637BC14B19B728028FD01F07 /* FrequencyRuler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81C53609B2CDC9FB3B71116 /* FrequencyRuler.swift */; }; + 65095AFDD1557D1198D61B04 /* geist_mono_bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 87C51D7CB8A8FA5C3F883285 /* geist_mono_bold.ttf */; }; 65A688B883398E75D00BCD7E /* LogbookStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DB096C5FC7481CA1B7B49B /* LogbookStats.swift */; }; 666D9271E9FE64C20C29CF47 /* FT8Audio in Frameworks */ = {isa = PBXBuildFile; productRef = 8A825C5161C267D978D895B8 /* FT8Audio */; }; + 6A8A11D49F8CA3F24D9DED29 /* PotaActivationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA66E8CE63C02CC35A2BC010 /* PotaActivationStore.swift */; }; + 6F7F961AB6678F3E6E60A97A /* LoggingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0565117E35A6C64BBF519D /* LoggingSettings.swift */; }; 72300AB5D2B37428777C824D /* MapScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BCE0C13DF4842EC394EFADF /* MapScreen.swift */; }; 771EC057824C1F3698C6DC9B /* FFTProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F287F167508A8E4114F44116 /* FFTProcessor.swift */; }; 7F9F3D670DE6755252D023A3 /* TransmissionSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B08B1AA6951B99CEFAA521F /* TransmissionSettings.swift */; }; @@ -33,17 +38,23 @@ 80F7FFDDEE3AEE3E706C4A2B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 076A186C88F9B5A2FFAAB073 /* Assets.xcassets */; }; 8DA0D562E25925F56B638B00 /* GridDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E429D8A3556534F05374F12A /* GridDistance.swift */; }; 8EA75A450404FB948C0A6CDC /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504C9AD4C6667267DFCABEF9 /* SettingsScreen.swift */; }; + 8EF984B5816F012A2DF93FDF /* OnlineLogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D131B05E27A96EC4BEB28782 /* OnlineLogService.swift */; }; + 9D3A2D37F171D6B4D7AA48A3 /* inter_variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B42060FC68A03D3F64A7C2EF /* inter_variable.ttf */; }; 9F4A1DE79693EF68A7B1A4D2 /* LogbookAwards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CCA018AEEE169EDCA338FD /* LogbookAwards.swift */; }; A037B00B861024FA2CA4919F /* QsoPathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD0187218903BBFF71931246 /* QsoPathMap.swift */; }; A5C16922F13DEDBDF69DFCF6 /* FT8AFTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A41EFDE500F865A3B393171D /* FT8AFTab.swift */; }; + AA448D315E29A08A6A925951 /* geist_mono_medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BF1653386B026C49C8A42365 /* geist_mono_medium.ttf */; }; AA7457556E40D0C0B8F75958 /* FilterChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5306EE5215AE14F95F3329F8 /* FilterChip.swift */; }; AAA8FD89DE336CC404CC41E4 /* FT8Engine in Frameworks */ = {isa = PBXBuildFile; productRef = A5E95C3FF3D4A30E2177E137 /* FT8Engine */; }; AB5310FE1F9048626DC6406B /* LiveEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E09812700CD93E6187CC4BC /* LiveEngine.swift */; }; AC1D4BEDBCC1DBA945DB04D5 /* TxPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29B5962B70F4510D76A5BC0A /* TxPlayerService.swift */; }; B28BBB197398A82CDE05B480 /* AboutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CFCB3285583184390D9E9BF /* AboutScreen.swift */; }; + B2C808F5B82B417F7837E115 /* geist_mono_regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = DB80BA772AE267AC9AE1FC43 /* geist_mono_regular.ttf */; }; + B4122A07A833C6746F51D5D6 /* PotaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E27EE4248BF38A15E0F3B2D /* PotaService.swift */; }; B8FD5A2DF3B2A89740B3CB73 /* WorldMapCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDDB723455CB7AE2BB68EF0 /* WorldMapCanvas.swift */; }; BCEB9305C3EA84C758FAFBA7 /* AzimuthalMapCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB943F6B81F9D36BA85504A3 /* AzimuthalMapCanvas.swift */; }; CE1ECA0C5BB350A1EDDA7018 /* SlotTimerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800081E79EC795EBDE2E077E /* SlotTimerBar.swift */; }; + CF1120C958F019240FB25A25 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0CA452882C4980BED953078 /* Typography.swift */; }; D36ECEB73B847787DE373072 /* SpectrumStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96D9C69CFC1480E6A5B64B5 /* SpectrumStrip.swift */; }; DA94D12D7FDE6D10D116E770 /* TxStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B1202A7633AE8CBBFCF2C3 /* TxStrip.swift */; }; DFA95C747B2F0763C677F2EC /* ActiveQsoPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0244EBDFC2ADDAE77418FF53 /* ActiveQsoPanel.swift */; }; @@ -55,6 +66,7 @@ EEA0D2F1B9A7E0345F68E909 /* ExportLogSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16235B470705C04E596C8A5B /* ExportLogSheet.swift */; }; F3CB49EBF218C3BB9FAE02D6 /* DecodeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DF7123A737FE9F5236677F /* DecodeRow.swift */; }; F4E60463DA065CDAE7B84EF3 /* Accelerate.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1E706B1D26B550FE4D798B68 /* Accelerate.framework */; }; + F56F35C07C7374F28A21ADE3 /* AudioRouteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B94D7FBAC132C5854796F79 /* AudioRouteController.swift */; }; F917448B524020C565485D03 /* SplashScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A872FCC168F7F5523C8D69 /* SplashScreen.swift */; }; FEB99CFFD070BA6B893ADC83 /* WaterfallCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = B681B14FC4FED15C628471DF /* WaterfallCanvas.swift */; }; /* End PBXBuildFile section */ @@ -71,6 +83,8 @@ 29B5962B70F4510D76A5BC0A /* TxPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxPlayerService.swift; sourceTree = ""; }; 2CFCB3285583184390D9E9BF /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = ""; }; 33CCA018AEEE169EDCA338FD /* LogbookAwards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbookAwards.swift; sourceTree = ""; }; + 3B94D7FBAC132C5854796F79 /* AudioRouteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteController.swift; sourceTree = ""; }; + 4217D8419318D6A336873605 /* AudioRoutePickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoutePickerButton.swift; sourceTree = ""; }; 4A8FFDE0644205A9AE050BC0 /* BlockedCallsignsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedCallsignsSettings.swift; sourceTree = ""; }; 4B08B1AA6951B99CEFAA521F /* TransmissionSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionSettings.swift; sourceTree = ""; }; 4E09812700CD93E6187CC4BC /* LiveEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveEngine.swift; sourceTree = ""; }; @@ -81,9 +95,11 @@ 6B9E40312EA203F05764FA56 /* QsoLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QsoLogStore.swift; sourceTree = ""; }; 6F1E760AEB31EF3855951728 /* FT8AFApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FT8AFApp.swift; sourceTree = ""; }; 76108617D9AEDEE84BAD1054 /* LogbookScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbookScreen.swift; sourceTree = ""; }; + 7E27EE4248BF38A15E0F3B2D /* PotaService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotaService.swift; sourceTree = ""; }; 800081E79EC795EBDE2E077E /* SlotTimerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlotTimerBar.swift; sourceTree = ""; }; 82A0D66684F5C2F323454D9B /* FT8AFKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = FT8AFKit; path = ../FT8AFKit; sourceTree = SOURCE_ROOT; }; 8493B7CF97F9744DED622F15 /* ToastOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastOverlay.swift; sourceTree = ""; }; + 87C51D7CB8A8FA5C3F883285 /* geist_mono_bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = geist_mono_bold.ttf; sourceTree = ""; }; 89642B9763C7FA33CFF0EE57 /* RadioAudioSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioAudioSettings.swift; sourceTree = ""; }; 8BDDB723455CB7AE2BB68EF0 /* WorldMapCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldMapCanvas.swift; sourceTree = ""; }; 91DB096C5FC7481CA1B7B49B /* LogbookStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbookStats.swift; sourceTree = ""; }; @@ -95,19 +111,28 @@ A88688AC6AFBEDC8AC3EF844 /* DecodeFilterSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeFilterSettings.swift; sourceTree = ""; }; AC8DAEDCAE1AD9DE02EB1480 /* QsoCelebration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QsoCelebration.swift; sourceTree = ""; }; B35E490F93FDCACEC4FBF66F /* PotaScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotaScreen.swift; sourceTree = ""; }; + B42060FC68A03D3F64A7C2EF /* inter_variable.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = inter_variable.ttf; sourceTree = ""; }; B681B14FC4FED15C628471DF /* WaterfallCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaterfallCanvas.swift; sourceTree = ""; }; + BB0565117E35A6C64BBF519D /* LoggingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingSettings.swift; sourceTree = ""; }; BD0187218903BBFF71931246 /* QsoPathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QsoPathMap.swift; sourceTree = ""; }; + BE0DCBA7E647113F08F32531 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + BF1653386B026C49C8A42365 /* geist_mono_medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = geist_mono_medium.ttf; sourceTree = ""; }; BF21F5D95598018B8A46ED8D /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; C42FB097015693FF14B27553 /* AudioCaptureService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioCaptureService.swift; sourceTree = ""; }; C79C0E0096ADDC018120C361 /* DecodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeScreen.swift; sourceTree = ""; }; C81C53609B2CDC9FB3B71116 /* FrequencyRuler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequencyRuler.swift; sourceTree = ""; }; C96D9C69CFC1480E6A5B64B5 /* SpectrumStrip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpectrumStrip.swift; sourceTree = ""; }; CB943F6B81F9D36BA85504A3 /* AzimuthalMapCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AzimuthalMapCanvas.swift; sourceTree = ""; }; + CFC0DD2F717A726A4887913B /* geist_mono_semibold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = geist_mono_semibold.ttf; sourceTree = ""; }; + D131B05E27A96EC4BEB28782 /* OnlineLogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineLogService.swift; sourceTree = ""; }; + DB80BA772AE267AC9AE1FC43 /* geist_mono_regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = geist_mono_regular.ttf; sourceTree = ""; }; DE47CF6272CAE2614E6A2383 /* AdvancedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettings.swift; sourceTree = ""; }; DF04207CDB5BE8A1CCDD5F5B /* TransmitGlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmitGlow.swift; sourceTree = ""; }; + E0CA452882C4980BED953078 /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = ""; }; E429D8A3556534F05374F12A /* GridDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridDistance.swift; sourceTree = ""; }; F287F167508A8E4114F44116 /* FFTProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FFTProcessor.swift; sourceTree = ""; }; F3C698656C5709343F4AEEF0 /* FrequencyPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrequencyPickerSheet.swift; sourceTree = ""; }; + FA66E8CE63C02CC35A2BC010 /* PotaActivationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotaActivationStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -168,6 +193,7 @@ DE47CF6272CAE2614E6A2383 /* AdvancedSettings.swift */, 4A8FFDE0644205A9AE050BC0 /* BlockedCallsignsSettings.swift */, A88688AC6AFBEDC8AC3EF844 /* DecodeFilterSettings.swift */, + BB0565117E35A6C64BBF519D /* LoggingSettings.swift */, 89642B9763C7FA33CFF0EE57 /* RadioAudioSettings.swift */, 504C9AD4C6667267DFCABEF9 /* SettingsScreen.swift */, 4B08B1AA6951B99CEFAA521F /* TransmissionSettings.swift */, @@ -218,6 +244,7 @@ isa = PBXGroup; children = ( 0244EBDFC2ADDAE77418FF53 /* ActiveQsoPanel.swift */, + 4217D8419318D6A336873605 /* AudioRoutePickerButton.swift */, 5306EE5215AE14F95F3329F8 /* FilterChip.swift */, F3C698656C5709343F4AEEF0 /* FrequencyPickerSheet.swift */, AC8DAEDCAE1AD9DE02EB1480 /* QsoCelebration.swift */, @@ -258,8 +285,10 @@ BF21F5D95598018B8A46ED8D /* AppState.swift */, 076A186C88F9B5A2FFAAB073 /* Assets.xcassets */, 6F1E760AEB31EF3855951728 /* FT8AFApp.swift */, + BE0DCBA7E647113F08F32531 /* Info.plist */, 7DCF29540DF6FBE5F4ECD5FC /* Components */, B571E3EEC914286395700259 /* Engine */, + 990B4B022C7B0AE88C357BC4 /* Fonts */, 1F327B9757517C23BFA52C86 /* Navigation */, 7F57A25B1504D3F64E790FAC /* Screens */, C97F65A2DD30ABA1707C85D3 /* Theme */, @@ -268,6 +297,18 @@ path = FT8AF; sourceTree = ""; }; + 990B4B022C7B0AE88C357BC4 /* Fonts */ = { + isa = PBXGroup; + children = ( + 87C51D7CB8A8FA5C3F883285 /* geist_mono_bold.ttf */, + BF1653386B026C49C8A42365 /* geist_mono_medium.ttf */, + DB80BA772AE267AC9AE1FC43 /* geist_mono_regular.ttf */, + CFC0DD2F717A726A4887913B /* geist_mono_semibold.ttf */, + B42060FC68A03D3F64A7C2EF /* inter_variable.ttf */, + ); + path = Fonts; + sourceTree = ""; + }; 9B85E3FDCB60E378E9DF2E12 /* Logbook */ = { isa = PBXGroup; children = ( @@ -284,8 +325,12 @@ isa = PBXGroup; children = ( C42FB097015693FF14B27553 /* AudioCaptureService.swift */, + 3B94D7FBAC132C5854796F79 /* AudioRouteController.swift */, F287F167508A8E4114F44116 /* FFTProcessor.swift */, 4E09812700CD93E6187CC4BC /* LiveEngine.swift */, + D131B05E27A96EC4BEB28782 /* OnlineLogService.swift */, + FA66E8CE63C02CC35A2BC010 /* PotaActivationStore.swift */, + 7E27EE4248BF38A15E0F3B2D /* PotaService.swift */, 6B9E40312EA203F05764FA56 /* QsoLogStore.swift */, 29B5962B70F4510D76A5BC0A /* TxPlayerService.swift */, ); @@ -296,6 +341,7 @@ isa = PBXGroup; children = ( 0FD3B4836E62F9DC32FAB0DE /* Colors.swift */, + E0CA452882C4980BED953078 /* Typography.swift */, ); path = Theme; sourceTree = ""; @@ -373,6 +419,11 @@ buildActionMask = 2147483647; files = ( 80F7FFDDEE3AEE3E706C4A2B /* Assets.xcassets in Resources */, + 65095AFDD1557D1198D61B04 /* geist_mono_bold.ttf in Resources */, + AA448D315E29A08A6A925951 /* geist_mono_medium.ttf in Resources */, + B2C808F5B82B417F7837E115 /* geist_mono_regular.ttf in Resources */, + 4F742232FB7AE5A02DF90DE8 /* geist_mono_semibold.ttf in Resources */, + 9D3A2D37F171D6B4D7AA48A3 /* inter_variable.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -389,6 +440,8 @@ 49DFEC8ED0237764EA3EE3BF /* AppState.swift in Sources */, 5C76E98A4BD4854709C3D0E9 /* AppTabView.swift in Sources */, EA1C91B3E65DB77ED5703FD9 /* AudioCaptureService.swift in Sources */, + F56F35C07C7374F28A21ADE3 /* AudioRouteController.swift in Sources */, + 3BCBA78527E44AD070AA1E48 /* AudioRoutePickerButton.swift in Sources */, BCEB9305C3EA84C758FAFBA7 /* AzimuthalMapCanvas.swift in Sources */, 072D76FFDC706DE3E537A3C2 /* BlockedCallsignsSettings.swift in Sources */, 035DDD710715AAC0100E0871 /* Colors.swift in Sources */, @@ -408,8 +461,12 @@ 0FD449B6C15FD3407D8E3BC0 /* LogbookCharts.swift in Sources */, 33736A682F52CCBB13E8168A /* LogbookScreen.swift in Sources */, 65A688B883398E75D00BCD7E /* LogbookStats.swift in Sources */, + 6F7F961AB6678F3E6E60A97A /* LoggingSettings.swift in Sources */, 72300AB5D2B37428777C824D /* MapScreen.swift in Sources */, + 8EF984B5816F012A2DF93FDF /* OnlineLogService.swift in Sources */, + 6A8A11D49F8CA3F24D9DED29 /* PotaActivationStore.swift in Sources */, 595BBF17D95723947D5BFFC4 /* PotaScreen.swift in Sources */, + B4122A07A833C6746F51D5D6 /* PotaService.swift in Sources */, 4D87F21162B4DCEC806D8785 /* QsoCelebration.swift in Sources */, E375A4D50C25F5437C2C9F7F /* QsoLogStore.swift in Sources */, A037B00B861024FA2CA4919F /* QsoPathMap.swift in Sources */, @@ -424,6 +481,7 @@ 5E2B3105FFFE6C84D86ACD85 /* TransmitGlow.swift in Sources */, AC1D4BEDBCC1DBA945DB04D5 /* TxPlayerService.swift in Sources */, DA94D12D7FDE6D10D116E770 /* TxStrip.swift in Sources */, + CF1120C958F019240FB25A25 /* Typography.swift in Sources */, FEB99CFFD070BA6B893ADC83 /* WaterfallCanvas.swift in Sources */, 208767EE6D89D470D7C88E77 /* WaterfallScreen.swift in Sources */, B8FD5A2DF3B2A89740B3CB73 /* WorldMapCanvas.swift in Sources */, @@ -438,14 +496,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "FT8AF needs microphone access to capture radio audio for FT8 decoding"; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "FT8AF uses the local network to send WSJT-X UDP messages to companion apps like GridTracker and JTAlert"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_FILE = FT8AF/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -518,14 +569,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "FT8AF needs microphone access to capture radio audio for FT8 decoding"; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "FT8AF uses the local network to send WSJT-X UDP messages to companion apps like GridTracker and JTAlert"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_FILE = FT8AF/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/FT8AF/FT8AF/AppState.swift b/ios/FT8AF/FT8AF/AppState.swift index 3809c3418..ec543ed5a 100644 --- a/ios/FT8AF/FT8AF/AppState.swift +++ b/ios/FT8AF/FT8AF/AppState.swift @@ -53,6 +53,12 @@ struct DecodeMessage: Identifiable, Equatable { var grid: String var extra: String var slotIndex: Int + /// Wall-clock arrival of the decode. Defaulted to construction time so + /// every existing `DecodeMessage(...)` call site (LiveEngine creates + /// messages the moment a slot decodes) gets the correct arrival without + /// changes; the decode list derives the relative "now / 32s / 5m" age + /// from it. + var arrival: Date = Date() static let mock: [DecodeMessage] = [ DecodeMessage(utcTime: "12:30:00", callFrom: "W1AW", callTo: "CQ", snr: -5, freqHz: 1120, grid: "FN31", extra: "FN31", slotIndex: 0), @@ -75,7 +81,22 @@ struct DecodeMessage: Identifiable, Equatable { @Observable @MainActor final class WaterfallState { var rows: [[UInt8]] = [] + /// Optional UTC boundary label per row, kept in lockstep with `rows`: + /// non-nil on the first row of each 15 s FT8 period ("HH:mm:ss" of the + /// period start), nil elsewhere. `WaterfallCanvas` draws a divider + label + /// at these rows. + var rowTimestamps: [String?] = [] var spectrum: [Float] = [] + /// Top of the audio band the current `rows`/`spectrum` were built to, in + /// Hz. Maintained by the LiveEngine waterfall loop from the operator's + /// spectrum-width setting; on a live width change the loop clears the + /// old-width history and updates this. + var displayMaxHz: Float = 3000 + /// RX input level of the most recent metering window (linear, 0..~1), for + /// the level indicator in the waterfall info bar. Classified via + /// `AudioInputLevel.fromPeakRms` in the view. + var inputPeak: Float = 0 + var inputRms: Float = 0 var txFreqHz: Float = 1500 var isLive: Bool = false var noiseReduction: Bool = false @@ -192,13 +213,35 @@ final class SettingsState { var txWatchdogMin: Int = 0 // 0 = off var stopAfterAttempts: Int = 0 // 0 = off // Radio - var spectrumWidthHz: Int = 3000 + // Default 3500 matches the pre-configurable fixed span (desktop WF_MAX_HZ) + // so updating doesn't silently narrow the displayed band. + var spectrumWidthHz: Int = 3500 var enabledBands: [String] = ["160M","80M","40M","30M","20M","17M","15M","12M","10M","6M"] // WSJT-X UDP interface (GridTracker / JTAlert / N1MM / Log4OM interop). var udpEnabled: Bool = false var udpHost: String = "127.0.0.1" var udpPort: Int = 2237 var udpAcceptRequests: Bool = false + // Online logging (Cloudlog/Wavelog family + QRZ.com logbook + PSK Reporter) + var cloudlogEnabled: Bool = false + var cloudlogUrl: String = "" + var cloudlogApiKey: String = "" + var cloudlogStationId: String = "" + var qrzLogbookEnabled: Bool = false + var qrzLogbookApiKey: String = "" + var pskReporterEnabled: Bool = false + // Decode highlights & filters + var highlightNewDxcc: Bool = true + var highlightNewGrid: Bool = true + var highlightNewBand: Bool = true + var highlightWorked: Bool = true + var continentFilter: String = "All" // All / NA / SA / EU / AF / AS / OC / AN + var distanceInMiles: Bool = false + // Tune + var tuneTimeoutSec: Int = 30 + // Preferred audio input port name ("" = system default); matched by name + // so the choice survives replug/relaunch. + var preferredInputPort: String = "" } // MARK: - Rig @@ -242,18 +285,37 @@ final class TxState { var conversationLog: [QsoLogEntry] = [] /// Set by the engine when our CQ is answered, to auto-open QsoSheet. var autoOpenMessage: DecodeMessage? + /// The QSO engine's raw sequencer stage, mirrored by LiveEngine for the + /// TX message-selector chips (`TxStageSelector`). + var qsoStage: TxStage = .idle + /// Last SNR heard from the current target (for the Active QSO header). + var targetSnr: Int? + /// Callsigns waiting their turn (mirror of LiveEngine's `CallerQueue`). + var queuedCallers: [String] = [] + /// TUNE carrier state: latched steady tone at the TX audio frequency. + var isTuning: Bool = false + var tuneRemainingSec: Int = 0 } // MARK: - POTA @Observable @MainActor final class PotaState { + /// Start-form inputs (park reference + optional notes). var parkInput: String = "" - var parkRefs: [String] = [] var notes: String = "" - var isActivating: Bool = false - var activationQsoCount: Int = 0 - var activationStartTime: Date? + /// All activation sessions (past + at most one active). Loaded from + /// `PotaActivationStore` the first time the POTA screen appears and + /// persisted on every change; an unfinished activation therefore survives + /// app restarts. + var activations: [PotaActivationRecord] = [] + /// Whether `activations` has been loaded from disk yet (the POTA screen + /// loads lazily on first appearance). + var activationsLoaded: Bool = false + + /// The in-progress activation, if any (endedAtMs == nil). + var current: PotaActivationRecord? { activations.first { $0.isActive } } + var isActivating: Bool { current != nil } } // MARK: - Settings Persistence @@ -289,6 +351,21 @@ enum SettingsPersistence { d.set(s.udpHost, forKey: key("udpHost")) d.set(s.udpPort, forKey: key("udpPort")) d.set(s.udpAcceptRequests, forKey: key("udpAcceptRequests")) + d.set(s.cloudlogEnabled, forKey: key("cloudlogEnabled")) + d.set(s.cloudlogUrl, forKey: key("cloudlogUrl")) + d.set(s.cloudlogApiKey, forKey: key("cloudlogApiKey")) + d.set(s.cloudlogStationId, forKey: key("cloudlogStationId")) + d.set(s.qrzLogbookEnabled, forKey: key("qrzLogbookEnabled")) + d.set(s.qrzLogbookApiKey, forKey: key("qrzLogbookApiKey")) + d.set(s.pskReporterEnabled, forKey: key("pskReporterEnabled")) + d.set(s.highlightNewDxcc, forKey: key("highlightNewDxcc")) + d.set(s.highlightNewGrid, forKey: key("highlightNewGrid")) + d.set(s.highlightNewBand, forKey: key("highlightNewBand")) + d.set(s.highlightWorked, forKey: key("highlightWorked")) + d.set(s.continentFilter, forKey: key("continentFilter")) + d.set(s.distanceInMiles, forKey: key("distanceInMiles")) + d.set(s.tuneTimeoutSec, forKey: key("tuneTimeoutSec")) + d.set(s.preferredInputPort, forKey: key("preferredInputPort")) } @MainActor static func load(into s: SettingsState) { @@ -362,6 +439,43 @@ enum SettingsPersistence { if d.object(forKey: key("udpAcceptRequests")) != nil { s.udpAcceptRequests = d.bool(forKey: key("udpAcceptRequests")) } + if d.object(forKey: key("cloudlogEnabled")) != nil { + s.cloudlogEnabled = d.bool(forKey: key("cloudlogEnabled")) + } + if let v = d.string(forKey: key("cloudlogUrl")) { s.cloudlogUrl = v } + if let v = d.string(forKey: key("cloudlogApiKey")) { s.cloudlogApiKey = v } + if let v = d.string(forKey: key("cloudlogStationId")) { s.cloudlogStationId = v } + if d.object(forKey: key("qrzLogbookEnabled")) != nil { + s.qrzLogbookEnabled = d.bool(forKey: key("qrzLogbookEnabled")) + } + if let v = d.string(forKey: key("qrzLogbookApiKey")) { s.qrzLogbookApiKey = v } + if d.object(forKey: key("pskReporterEnabled")) != nil { + s.pskReporterEnabled = d.bool(forKey: key("pskReporterEnabled")) + } + if d.object(forKey: key("highlightNewDxcc")) != nil { + s.highlightNewDxcc = d.bool(forKey: key("highlightNewDxcc")) + } + if d.object(forKey: key("highlightNewGrid")) != nil { + s.highlightNewGrid = d.bool(forKey: key("highlightNewGrid")) + } + if d.object(forKey: key("highlightNewBand")) != nil { + s.highlightNewBand = d.bool(forKey: key("highlightNewBand")) + } + if d.object(forKey: key("highlightWorked")) != nil { + s.highlightWorked = d.bool(forKey: key("highlightWorked")) + } + if let v = d.string(forKey: key("continentFilter")), !v.isEmpty { + s.continentFilter = v + } + if d.object(forKey: key("distanceInMiles")) != nil { + s.distanceInMiles = d.bool(forKey: key("distanceInMiles")) + } + if d.object(forKey: key("tuneTimeoutSec")) != nil { + s.tuneTimeoutSec = d.integer(forKey: key("tuneTimeoutSec")) + } + if let v = d.string(forKey: key("preferredInputPort")) { + s.preferredInputPort = v + } } private static func key(_ name: String) -> String { prefix + name } diff --git a/ios/FT8AF/FT8AF/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/FT8AF/FT8AF/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 000000000..ea053cd70 Binary files /dev/null and b/ios/FT8AF/FT8AF/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios/FT8AF/FT8AF/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/FT8AF/FT8AF/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3ee..cefcc878e 100644 --- a/ios/FT8AF/FT8AF/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/FT8AF/FT8AF/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/ios/FT8AF/FT8AF/Components/ActiveQsoPanel.swift b/ios/FT8AF/FT8AF/Components/ActiveQsoPanel.swift index 42a4dbc70..92a102e34 100644 --- a/ios/FT8AF/FT8AF/Components/ActiveQsoPanel.swift +++ b/ios/FT8AF/FT8AF/Components/ActiveQsoPanel.swift @@ -1,6 +1,9 @@ +import FT8Engine import SwiftUI -/// Floating panel showing active QSO progress above the TxStrip. +/// Floating panel showing active QSO progress above the TxStrip (Android +/// parity: header states + SNR, LOG / ✕ actions, conversation log, TX +/// message-selector chips, caller queue, stage dots). struct ActiveQsoPanel: View { @Environment(AppState.self) private var appState @State private var isExpanded = true @@ -15,29 +18,74 @@ struct ActiveQsoPanel: View { } } + private var headerState: QsoPanelHeader.State { + QsoPanelHeader.state( + targetCall: appState.tx.targetCall, + isActivated: appState.tx.isActivated, + huntEnabled: appState.tx.huntEnabled, + huntCallsCQ: appState.settings.huntCallsCQ, + isTransmitting: appState.tx.isTransmitting + ) + } + + private var headerLabel: String { + switch headerState { + case .hunting: return "Hunting…" + case .callingCq: return "Calling CQ" + case .qsoing(let dx): return "QSOing with \(dx)" + case .waiting(let dx): return "Waiting for \(dx)" + case .idle: return "Idle" + } + } + + private var hasTarget: Bool { !appState.tx.targetCall.isEmpty } + private func expandedView(tx: TxState) -> some View { VStack(spacing: 8) { - // Header row with target call and collapse button - HStack { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 12, weight: .semibold)) + // Header row: QSO label + state + SNR · LOG · ✕ · collapse + HStack(spacing: 8) { + Text("QSO") + .font(.ft8afMono(size: 10, weight: .semibold)) + .foregroundStyle(textFaint) + + Text(headerLabel) + .font(.ft8afMono(size: 13, weight: .bold)) .foregroundStyle(accent) + .lineLimit(1) - Text(tx.targetCall.isEmpty ? "CQ" : tx.targetCall) - .font(.system(size: 16, weight: .bold, design: .monospaced)) - .foregroundStyle(textPrimary) + if hasTarget, let snr = tx.targetSnr { + Text(snr >= 0 ? "+\(snr)dB" : "\(snr)dB") + .font(.ft8afMono(size: 11, weight: .medium)) + .foregroundStyle(signal) + } - Spacer() + Spacer(minLength: 4) - Text(stageLabel(tx.stage)) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(accent) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(accent.opacity(0.14)) - ) + if hasTarget { + // LOG: force-log the current QSO and move on. + Button { + appState.engine?.forceLogAndMoveOn() + } label: { + Text("LOG") + .font(.ft8afMono(size: 10, weight: .bold)) + .foregroundStyle(signal) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 6).fill(signalSoft)) + } + .buttonStyle(.plain) + + // ✕: abandon the target (back to CQ / idle, nothing logged). + Button { + appState.engine?.clearActiveQso() + } label: { + Image(systemName: "xmark") + .font(.ft8afUI(size: 11, weight: .semibold)) + .foregroundStyle(textMuted) + .frame(width: 22, height: 22) + } + .buttonStyle(.plain) + } Button { withAnimation(.easeInOut(duration: 0.2)) { @@ -45,8 +93,9 @@ struct ActiveQsoPanel: View { } } label: { Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .semibold)) + .font(.ft8afUI(size: 10, weight: .semibold)) .foregroundStyle(textMuted) + .frame(width: 22, height: 22) } .buttonStyle(.plain) } @@ -74,7 +123,7 @@ struct ActiveQsoPanel: View { } else if !tx.txMessage.isEmpty { // Fallback: show current TX message if no log entries yet Text(tx.txMessage) - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: .medium)) .foregroundStyle(textMuted) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) @@ -85,6 +134,18 @@ struct ActiveQsoPanel: View { ) } + // TX message selector chips (CQ / GRID / RPT / R-RPT / RR73 / 73) + TxSelectorRow(currentStage: tx.qsoStage) { stage in + appState.engine?.selectTxStage(stage) + } + + // Caller queue — tap a callsign to log the current QSO and work them + if !tx.queuedCallers.isEmpty { + CallerQueueBar(callers: tx.queuedCallers) { callsign in + appState.engine?.forceLogAndMoveOn(nextCallsign: callsign) + } + } + // Stage progress dots QsoStageDots(currentStage: tx.stage) } @@ -113,18 +174,20 @@ struct ActiveQsoPanel: View { .fill(accent) .frame(width: 6, height: 6) - Text("QSO: \(tx.targetCall.isEmpty ? "CQ" : tx.targetCall)") - .font(.system(size: 12, weight: .semibold, design: .monospaced)) + Text(headerLabel) + .font(.ft8afMono(size: 12, weight: .semibold)) .foregroundStyle(textPrimary) - Text("— \(stageLabel(tx.stage))") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(textMuted) + if !tx.queuedCallers.isEmpty { + Text("+\(tx.queuedCallers.count)") + .font(.ft8afMono(size: 10, weight: .bold)) + .foregroundStyle(signal) + } Spacer() Image(systemName: "chevron.up") - .font(.system(size: 10, weight: .semibold)) + .font(.ft8afUI(size: 10, weight: .semibold)) .foregroundStyle(textMuted) } .padding(.horizontal, 14) @@ -142,14 +205,118 @@ struct ActiveQsoPanel: View { } .buttonStyle(.plain) } +} + +// MARK: - TX message selector + +/// Chip row wired to `QsoEngine.setStage` via the engine; the active chip is +/// amber, chips for stages already passed are cyan (Android `TxSelector`). +private struct TxSelectorRow: View { + let currentStage: TxStage + let onSelect: (TxStage) -> Void - private func stageLabel(_ stage: TxUIStage) -> String { - switch stage { - case .idle: return "IDLE" - case .cqSent: return "CQ" - case .reportSent: return "REPORT" - case .rrSent: return "RR73" - case .complete: return "DONE" + var body: some View { + HStack(spacing: 4) { + Text("TX:") + .font(.ft8afMono(size: 10, weight: .semibold)) + .foregroundStyle(textFaint) + + ForEach(TxStageSelector.chips, id: \.rawValue) { chip in + let isActive = TxStageSelector.isActive(chip, current: currentStage) + let isCompleted = TxStageSelector.isCompleted(chip, current: currentStage) + + Button { + onSelect(chip) + } label: { + Text(TxStageSelector.label(for: chip)) + .font(.ft8afMono(size: 10, weight: isActive ? .bold : .medium)) + .foregroundStyle(isActive ? accent : (isCompleted ? signal : textMuted)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isActive ? accentSoft : (isCompleted ? signalSoft : bgSurface3)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(isActive ? accent.opacity(0.5) : .clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + Spacer(minLength: 0) + } + } +} + +// MARK: - Caller queue + +/// Wrapping row of stations waiting their turn; tapping one logs the current +/// QSO (when it progressed far enough) and works that caller next. +private struct CallerQueueBar: View { + let callers: [String] + let onTap: (String) -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("QUEUE") + .font(.ft8afMono(size: 9, weight: .semibold)) + .foregroundStyle(textFaint) + + WrappingHStack(spacing: 4, lineSpacing: 3) { + ForEach(callers, id: \.self) { callsign in + Button { + onTap(callsign) + } label: { + Text(callsign) + .font(.ft8afMono(size: 10, weight: .medium)) + .foregroundStyle(signal) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(signalSoft)) + } + .buttonStyle(.plain) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// Minimal flow layout so queued callsigns wrap to new lines (Android FlowRow). +private struct WrappingHStack: Layout { + var spacing: CGFloat = 4 + var lineSpacing: CGFloat = 3 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0, y: CGFloat = 0, lineHeight: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x > 0, x + size.width > maxWidth { + x = 0 + y += lineHeight + lineSpacing + lineHeight = 0 + } + x += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + return CGSize(width: maxWidth == .infinity ? x : maxWidth, height: y + lineHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX, y = bounds.minY, lineHeight: CGFloat = 0 + for view in subviews { + let size = view.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width > bounds.maxX { + x = bounds.minX + y += lineHeight + lineSpacing + lineHeight = 0 + } + view.place(at: CGPoint(x: x, y: y), proposal: .unspecified) + x += size.width + spacing + lineHeight = max(lineHeight, size.height) } } } @@ -163,7 +330,7 @@ private struct ConversationRow: View { HStack(spacing: 6) { // Direction badge Text(entry.direction.rawValue) - .font(.system(size: 8, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 8, weight: .bold)) .foregroundStyle(directionColor) .frame(width: 28) .padding(.vertical, 2) @@ -174,7 +341,7 @@ private struct ConversationRow: View { // Message Text(entry.message) - .font(.system(size: 11, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .medium)) .foregroundStyle(textPrimary) .lineLimit(1) @@ -183,13 +350,13 @@ private struct ConversationRow: View { // SNR (for RX) if let snr = entry.snr { Text(snr >= 0 ? "+\(snr)" : "\(snr)") - .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .semibold)) .foregroundStyle(textFaint) } // Time Text(String(entry.utcTime.prefix(5))) - .font(.system(size: 9, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .medium)) .foregroundStyle(textDim) } .padding(.horizontal, 8) @@ -234,7 +401,7 @@ struct QsoStageDots: View { .fill(isReached(idx) ? accent : textDim.opacity(0.3)) .frame(width: isCurrent(idx) ? 10 : 7, height: isCurrent(idx) ? 10 : 7) Text(item.label) - .font(.system(size: 8, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 8, weight: .semibold)) .foregroundStyle(isReached(idx) ? accent : textFaint) } } diff --git a/ios/FT8AF/FT8AF/Components/AudioRoutePickerButton.swift b/ios/FT8AF/FT8AF/Components/AudioRoutePickerButton.swift new file mode 100644 index 000000000..f21e772f8 --- /dev/null +++ b/ios/FT8AF/FT8AF/Components/AudioRoutePickerButton.swift @@ -0,0 +1,17 @@ +import AVKit +import SwiftUI + +/// The system output-route picker (AirPlay/USB/Bluetooth/speaker). iOS does +/// not allow apps to set the output route directly — this button opens the +/// OS sheet, mirroring the Android "Audio output device" picker's role. +struct AudioRoutePickerButton: UIViewRepresentable { + func makeUIView(context: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.activeTintColor = UIColor(accent) + view.tintColor = UIColor(textMuted) + view.prioritizesVideoDevices = false + return view + } + + func updateUIView(_ uiView: AVRoutePickerView, context: Context) {} +} diff --git a/ios/FT8AF/FT8AF/Components/FilterChip.swift b/ios/FT8AF/FT8AF/Components/FilterChip.swift index 7227bcaf1..7e02f000c 100644 --- a/ios/FT8AF/FT8AF/Components/FilterChip.swift +++ b/ios/FT8AF/FT8AF/Components/FilterChip.swift @@ -10,7 +10,7 @@ struct FilterChip: View { var body: some View { Button(action: action) { Text(label) - .font(.system(size: 12, weight: isSelected ? .semibold : .medium, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: isSelected ? .semibold : .medium)) .foregroundStyle(isSelected ? bgApp : textMuted) .padding(.horizontal, 12) .padding(.vertical, 6) diff --git a/ios/FT8AF/FT8AF/Components/FrequencyPickerSheet.swift b/ios/FT8AF/FT8AF/Components/FrequencyPickerSheet.swift index 22d37b4de..815710f85 100644 --- a/ios/FT8AF/FT8AF/Components/FrequencyPickerSheet.swift +++ b/ios/FT8AF/FT8AF/Components/FrequencyPickerSheet.swift @@ -36,14 +36,14 @@ struct FrequencyPickerSheet: View { .frame(width: 8, height: 8) Text(item.band) - .font(.system(size: 16, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 16, weight: .bold)) .foregroundStyle(textPrimary) Text("—") .foregroundStyle(textDim) Text(item.freq) - .font(.system(size: 14, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 14, weight: .medium)) .foregroundStyle(textMuted) Spacer() diff --git a/ios/FT8AF/FT8AF/Components/QsoPathMap.swift b/ios/FT8AF/FT8AF/Components/QsoPathMap.swift index f9f7c5ccd..f807cbf64 100644 --- a/ios/FT8AF/FT8AF/Components/QsoPathMap.swift +++ b/ios/FT8AF/FT8AF/Components/QsoPathMap.swift @@ -39,8 +39,8 @@ struct QsoPathMap: View { ctx.stroke(Path(ellipseIn: theirRect), with: .color(signal.opacity(0.5)), lineWidth: 2) // Labels - let myLabel = Text(myGrid).font(.system(size: 8, weight: .bold, design: .monospaced)).foregroundStyle(accent) - let theirLabel = Text(theirGrid).font(.system(size: 8, weight: .bold, design: .monospaced)).foregroundStyle(signal) + let myLabel = Text(myGrid).font(.ft8afMono(size: 8, weight: .bold)).foregroundStyle(accent) + let theirLabel = Text(theirGrid).font(.ft8afMono(size: 8, weight: .bold)).foregroundStyle(signal) let myLabelPt = CGPoint(x: max(20, min(w - 20, myPt.x)), y: max(10, myPt.y - 12)) let theirLabelPt = CGPoint(x: max(20, min(w - 20, theirPt.x)), y: min(h - 10, theirPt.y + 12)) diff --git a/ios/FT8AF/FT8AF/Components/ToastOverlay.swift b/ios/FT8AF/FT8AF/Components/ToastOverlay.swift index ca056ac88..a18238051 100644 --- a/ios/FT8AF/FT8AF/Components/ToastOverlay.swift +++ b/ios/FT8AF/FT8AF/Components/ToastOverlay.swift @@ -13,11 +13,11 @@ struct ToastOverlay: View { HStack(spacing: 8) { if let icon { Image(systemName: icon) - .font(.system(size: 13, weight: .semibold)) + .font(.ft8afUI(size: 13, weight: .semibold)) .foregroundStyle(accent) } Text(message) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 13, weight: .semibold)) .foregroundStyle(textPrimary) } .padding(.horizontal, 16) diff --git a/ios/FT8AF/FT8AF/Components/TxStrip.swift b/ios/FT8AF/FT8AF/Components/TxStrip.swift index cd30af503..b0b2ae6d9 100644 --- a/ios/FT8AF/FT8AF/Components/TxStrip.swift +++ b/ios/FT8AF/FT8AF/Components/TxStrip.swift @@ -1,3 +1,4 @@ +import FT8Engine import SwiftUI import UIKit @@ -9,6 +10,7 @@ struct TxStrip: View { var onCallCQ: () -> Void = {} var onStop: () -> Void = {} var onToggleSlot: () -> Void = {} + var onToggleTune: () -> Void = {} var onOpenFrequencyPicker: () -> Void = {} var onVolumeChange: (Int) -> Void = { _ in } @@ -23,7 +25,7 @@ struct TxStrip: View { HStack(spacing: 6) { PulseDot(color: tx.isTransmitting ? accent : signal) Text(tx.isTransmitting ? "TX" : "RX") - .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: .semibold)) .foregroundStyle(textPrimary) } @@ -32,8 +34,10 @@ struct TxStrip: View { // CAT status chip CatChip(status: appState.rig.connectionStatus) - // Right: mode + frequency chips + // Right: mode + frequency + tune chips HStack(spacing: 6) { + // Mode pill — static "FT8" (FT4/FT2 deferred; plain, + // non-interactive). TxChip(label: "FT8", color: accent) // Tappable frequency/band chip @@ -41,6 +45,17 @@ struct TxStrip: View { TxChip(label: settings.band, color: textPrimary) } .buttonStyle(.plain) + + // TUNE pill: latches a steady carrier at the TX audio + // frequency; red with a countdown while active. Locked off + // while the sequencer is armed/transmitting — tune and FT8 + // TX are mutually exclusive. + TunePill( + isTuning: tx.isTuning, + remainingSec: tx.tuneRemainingSec, + locked: tx.isActivated || tx.isTransmitting, + action: onToggleTune + ) } // Expand/collapse chevron @@ -50,7 +65,7 @@ struct TxStrip: View { } } label: { Image(systemName: tx.expanded ? "chevron.down" : "chevron.up") - .font(.system(size: 11, weight: .semibold)) + .font(.ft8afUI(size: 11, weight: .semibold)) .foregroundStyle(textMuted) .frame(width: 28, height: 28) .background( @@ -66,7 +81,7 @@ struct TxStrip: View { VStack(spacing: 8) { HStack(spacing: 8) { Text("VOL") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(textFaint) // Step down @@ -75,7 +90,7 @@ struct TxStrip: View { onVolumeChange(newVol) } label: { Image(systemName: "minus") - .font(.system(size: 10, weight: .bold)) + .font(.ft8afUI(size: 10, weight: .bold)) .foregroundStyle(textMuted) .frame(width: 24, height: 24) .background(Circle().fill(bgSurface3)) @@ -98,7 +113,7 @@ struct TxStrip: View { onVolumeChange(newVol) } label: { Image(systemName: "plus") - .font(.system(size: 10, weight: .bold)) + .font(.ft8afUI(size: 10, weight: .bold)) .foregroundStyle(textMuted) .frame(width: 24, height: 24) .background(Circle().fill(bgSurface3)) @@ -106,7 +121,7 @@ struct TxStrip: View { .buttonStyle(.plain) Text("\(settings.txVolume)%") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .semibold)) .foregroundStyle(textMuted) .frame(width: 36, alignment: .trailing) } @@ -163,6 +178,32 @@ struct TxStrip: View { // MARK: - Subviews +/// TUNE toggle pill: idle = plain chip; active = statusBad red with the +/// remaining-seconds countdown (label via the kit's `TuneTone.chipLabel`); +/// dimmed/disabled while the sequencer owns the transmitter. +private struct TunePill: View { + let isTuning: Bool + let remainingSec: Int + let locked: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(TuneTone.chipLabel(isTuning: isTuning, remainingSec: remainingSec)) + .font(.ft8afMono(size: 11, weight: isTuning ? .bold : .semibold)) + .foregroundStyle(isTuning ? .white : (locked ? textMuted.opacity(0.4) : textMuted)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isTuning ? statusBad : bgSurface3.opacity(locked ? 0.4 : 1.0)) + ) + } + .buttonStyle(.plain) + .disabled(locked && !isTuning) + } +} + private struct CatChip: View { let status: ConnectionStatus @@ -172,7 +213,7 @@ private struct CatChip: View { .fill(dotColor) .frame(width: 6, height: 6) Text("CAT") - .font(.system(size: 9, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .bold)) .foregroundStyle(textMuted) } .padding(.horizontal, 6) @@ -222,7 +263,7 @@ private struct TxChip: View { var body: some View { Text(label) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .semibold)) .foregroundStyle(color) .padding(.horizontal, 8) .padding(.vertical, 4) @@ -255,16 +296,16 @@ private struct ActionButton: View { case .primary: HStack(spacing: 8) { Image(systemName: icon) - .font(.system(size: 14, weight: .semibold)) + .font(.ft8afUI(size: 14, weight: .semibold)) Text(label) - .font(.system(size: 15, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 15, weight: .bold)) } case .secondary: VStack(spacing: 3) { Image(systemName: icon) - .font(.system(size: 14, weight: .semibold)) + .font(.ft8afUI(size: 14, weight: .semibold)) Text(label) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .semibold)) } } } diff --git a/ios/FT8AF/FT8AF/Engine/AudioCaptureService.swift b/ios/FT8AF/FT8AF/Engine/AudioCaptureService.swift index 1373dd262..d901b9bd4 100644 --- a/ios/FT8AF/FT8AF/Engine/AudioCaptureService.swift +++ b/ios/FT8AF/FT8AF/Engine/AudioCaptureService.swift @@ -44,8 +44,36 @@ final class AudioCaptureService: @unchecked Sendable { try session.setCategory(.playAndRecord, options: [.defaultToSpeaker, .allowBluetooth]) try session.setActive(true) + try installTapAndStart() + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleInterruption), + name: AVAudioSession.interruptionNotification, + object: nil + ) + // The engine stops itself when the audio hardware configuration + // changes (output/input route change, Simulator host-device switch) + // and the input format may change with it. Without rebuilding the + // tap and restarting, RX goes silently deaf while the UI still + // says RX. + NotificationCenter.default.addObserver( + self, + selector: #selector(handleConfigChange), + name: .AVAudioEngineConfigurationChange, + object: engine + ) + } + + /// (Re)build the converter for the current hardware input format, install + /// the tap, and start the engine. Used at startup and again after every + /// engine configuration change. + private func installTapAndStart() throws { let inputNode = engine.inputNode let hwFormat = inputNode.outputFormat(forBus: 0) + guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else { + throw AudioCaptureError.formatCreationFailed + } // Target: 12 kHz, mono, Float32 — the FT8 codec sample rate. guard let targetFormat = AVAudioFormat( @@ -76,13 +104,6 @@ final class AudioCaptureService: @unchecked Sendable { lock.lock() _isRunning = true lock.unlock() - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleInterruption), - name: AVAudioSession.interruptionNotification, - object: nil - ) } /// Stop the audio engine and remove the tap. @@ -100,6 +121,11 @@ final class AudioCaptureService: @unchecked Sendable { name: AVAudioSession.interruptionNotification, object: nil ) + NotificationCenter.default.removeObserver( + self, + name: .AVAudioEngineConfigurationChange, + object: engine + ) } // MARK: - Private @@ -150,6 +176,22 @@ final class AudioCaptureService: @unchecked Sendable { )) } + @objc private func handleConfigChange(_ notification: Notification) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.lock.lock() + let wasRunning = self._isRunning + self.lock.unlock() + guard wasRunning else { return } + + self.engine.inputNode.removeTap(onBus: 0) + // The tap is gone, so the audio thread can't touch the reusable + // buffer; drop it in case the chunk geometry changes. + self.reusableOutBuffer = nil + try? self.installTapAndStart() + } + } + @objc private func handleInterruption(_ notification: Notification) { guard let info = notification.userInfo, let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, diff --git a/ios/FT8AF/FT8AF/Engine/AudioRouteController.swift b/ios/FT8AF/FT8AF/Engine/AudioRouteController.swift new file mode 100644 index 000000000..4810de030 --- /dev/null +++ b/ios/FT8AF/FT8AF/Engine/AudioRouteController.swift @@ -0,0 +1,95 @@ +import AVFoundation +import FT8Audio +import Foundation +import Observation + +/// One selectable audio input, identified by the session port UID (stable +/// while the device stays attached; the persisted preference matches on +/// port *name* instead, which survives replug/relaunch). +struct AudioInputOption: Identifiable, Equatable { + let id: String // AVAudioSessionPortDescription.uid + let name: String // raw port name, used for the persisted preference + let label: String // display label ("Digirig (USB)", "Built-in Microphone") +} + +/// Watches the shared AVAudioSession for the Radio & Audio settings screen: +/// enumerates selectable inputs, tracks the current output route, and applies +/// the operator's preferred input. Output routing is not directly settable on +/// iOS — the screen pairs the current-route readout with the system +/// `AVRoutePickerView`. +@Observable @MainActor +final class AudioRouteController { + var inputs: [AudioInputOption] = [] + var currentInputUid: String = "" + var currentOutputLabel: String = "—" + + @ObservationIgnored private var observer: NSObjectProtocol? + + func start(preferredPortName: String) { + refresh(preferredPortName: preferredPortName) + observer = NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.refresh(preferredPortName: preferredPortName) + } + } + } + + func stop() { + if let observer { NotificationCenter.default.removeObserver(observer) } + observer = nil + } + + func refresh(preferredPortName: String) { + let session = AVAudioSession.sharedInstance() + inputs = (session.availableInputs ?? []).map { port in + AudioInputOption(id: port.uid, name: port.portName, label: Self.label(for: port)) + } + // Re-apply the persisted preference when its port is present but not + // active (e.g. the USB interface was replugged). + if !preferredPortName.isEmpty, + let wanted = (session.availableInputs ?? []).first(where: { $0.portName == preferredPortName }), + session.currentRoute.inputs.first?.uid != wanted.uid { + try? session.setPreferredInput(wanted) + } + currentInputUid = session.currentRoute.inputs.first?.uid + ?? inputs.first?.id ?? "" + currentOutputLabel = session.currentRoute.outputs.first.map(Self.label(for:)) ?? "—" + } + + /// Selects an input for the shared session. Returns the chosen option's + /// port name for persistence, or nil if the selection failed. + func selectInput(uid: String) -> String? { + let session = AVAudioSession.sharedInstance() + guard let port = (session.availableInputs ?? []).first(where: { $0.uid == uid }) else { + return nil + } + do { + try session.setPreferredInput(port) + currentInputUid = port.uid + return port.portName + } catch { + return nil + } + } + + private static func label(for port: AVAudioSessionPortDescription) -> String { + AudioPortLabel.label(kind: kind(of: port.portType), portName: port.portName) + } + + private static func kind(of type: AVAudioSession.Port) -> AudioPortKind { + switch type { + case .builtInMic: return .builtInMic + case .builtInSpeaker: return .speaker + case .builtInReceiver: return .receiver + case .headsetMic, .headphones, .lineIn, .lineOut: return .wired + case .usbAudio: return .usb + case .bluetoothHFP, .bluetoothA2DP, .bluetoothLE: return .bluetooth + case .airPlay: return .airPlay + case .carAudio: return .carAudio + default: return .other + } + } +} diff --git a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift index 32e6a382a..d821e85e3 100644 --- a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift +++ b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift @@ -29,6 +29,15 @@ final class LiveEngine { // QSO engine — created once start() has settings available. private var qsoEngine: QsoEngine? + // TX safety nets (watchdog + stop-after-N) — pure counting in the kit type. + private var supervisor = TxSupervisor(nowMs: 0) + // Stations that called us while we were busy — pure policy in the kit type. + private var callerQueue = CallerQueue() + // Pending delayed TX start (honors txDelayMs); cancelled by stopTx(). + private var txScheduleTask: Task? + // TUNE carrier countdown ticker. + private var tuneTask: Task? + // MARK: - Public control methods (called from UI) /// Start audio capture and kick off decode + waterfall loops. @@ -56,8 +65,14 @@ final class LiveEngine { let qso = QsoEngine(myCall: settings.myCall, myGrid: settings.myGrid) qso.band = settings.band qso.freqMhz = bandToFreqMhz(settings.band) + qso.autoReturnToCq = settings.autoCQAfterQSO qsoEngine = qso + // Fresh TX safety-net + caller-queue state for this session. + supervisor = TxSupervisor(nowMs: Int64(Date().timeIntervalSince1970 * 1000)) + callerQueue.removeAll() + appState.tx.queuedCallers = [] + // Bring up the WSJT-X UDP interface for this session. setupUdp(appState: appState) @@ -75,14 +90,33 @@ final class LiveEngine { appState.decode.messages.removeLast(appState.decode.messages.count - 200) } } - let applyWaterfall: @Sendable @MainActor ([UInt8], [Float]) -> Void = { row, spectrum in - appState.waterfall.rows.append(row) - // Keep at most 120 rows for the scrolling waterfall. - if appState.waterfall.rows.count > 120 { - appState.waterfall.rows.removeFirst(appState.waterfall.rows.count - 120) + // Live spectrum-width source for the waterfall loop: read on the main + // actor each tick so a settings change applies without restarting. + let readSpectrumWidthHz: @Sendable @MainActor () -> Int = { + appState.settings.spectrumWidthHz + } + let applyWaterfall: @Sendable @MainActor (WaterfallUpdate) -> Void = { update in + let wf = appState.waterfall + // Width changed live: the new rows span a different band, so drop + // the old-width history rather than draw it against the new axis. + if wf.displayMaxHz != update.displayMaxHz { + wf.displayMaxHz = update.displayMaxHz + wf.rows.removeAll() + wf.rowTimestamps.removeAll() + } + wf.rows.append(update.bins) + wf.rowTimestamps.append(update.timestamp) + // Keep at most 120 rows for the scrolling waterfall (timestamps + // stay in lockstep with the rows they annotate). + if wf.rows.count > 120 { + let overflow = wf.rows.count - 120 + wf.rows.removeFirst(overflow) + wf.rowTimestamps.removeFirst(min(overflow, wf.rowTimestamps.count)) } - appState.waterfall.spectrum = spectrum - appState.waterfall.updateCount += 1 + wf.spectrum = update.spectrum + wf.inputPeak = update.inputPeak + wf.inputRms = update.inputRms + wf.updateCount += 1 } engineTask = Task.detached { [weak self] in @@ -99,6 +133,7 @@ final class LiveEngine { group.addTask { await self?.runWaterfallLoop( accumulator: accumulator, + readSpectrumWidthHz: readSpectrumWidthHz, applyWaterfall: applyWaterfall ) } @@ -114,33 +149,51 @@ final class LiveEngine { udpStatusTask = nil udp.stop() lastUdpConfig = nil + txScheduleTask?.cancel() + txScheduleTask = nil + finishTune() txPlayer.stop() audio.stop() isRunning = false appState?.waterfall.isLive = false + // Flush any queued PSK Reporter spots before the session state goes + // away (mirrors Android PskReporterSender.stop()). + OnlineLogService.shared.stopPsk(appState: appState) appState = nil qsoEngine = nil } /// Begin calling CQ. func callCQ() { + stopTune() syncSettingsToQso() appState?.tx.conversationLog.removeAll() + appState?.tx.targetSnr = nil qsoEngine?.startCq() + armSupervisor() syncQsoStatusToUI() } /// Stop any active TX/QSO. func stopTx() { + txScheduleTask?.cancel() + txScheduleTask = nil qsoEngine?.stop() txPlayer.stop() + appState?.tx.isTransmitting = false + appState?.tx.targetSnr = nil + callerQueue.removeAll() syncQsoStatusToUI() } /// Answer a station from a decoded message (user tapped "Call" in QsoSheet). func answerStation(_ msg: DecodeMessage) { + stopTune() syncSettingsToQso() appState?.tx.conversationLog.removeAll() + appState?.tx.targetSnr = msg.snr + callerQueue.remove(callsign: msg.callFrom) + armSupervisor() let decoded = FT8DSP.DecodedMessage( callTo: msg.callTo, callFrom: msg.callFrom, @@ -171,6 +224,124 @@ final class LiveEngine { appState.tx.slotParity = appState.tx.slotParity == 0 ? 1 : 0 } + /// Operator picked a standard message chip (CQ/GRID/RPT/R-RPT/RR73/73) + /// in the Active QSO panel. + func selectTxStage(_ stage: TxStage) { + guard let qso = qsoEngine else { return } + syncSettingsToQso() + qso.setStage(stage) + supervisor.resetAttempts() + syncQsoStatusToUI() + } + + /// Force-log the current QSO (LOG action) and move on: to `nextCallsign` + /// when the operator tapped a queued caller, otherwise to the head of the + /// caller queue, otherwise back to CQ / stop per the auto-CQ setting. + /// Mirrors Android `forceLogAndMoveOn`. + func forceLogAndMoveOn(nextCallsign: String? = nil) { + guard let qso = qsoEngine, let appState else { return } + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + + if case .completed(let record)? = qso.forceLog() { + logCompletedQso(record) + } + appState.tx.targetSnr = nil + appState.tx.conversationLog.removeAll() + supervisor.resetAttempts() + if appState.settings.autoCQAfterQSO { + supervisor.resetWatchdog(nowMs: nowMs) + } + + // Work the next caller: the tapped one, or the head of the queue. + let next: QueuedCaller? = nextCallsign != nil + ? callerQueue.dequeue(callsign: nextCallsign!) + : callerQueue.dequeueNext() + if let caller = next { + startQsoWithCaller(caller, nowMs: nowMs) + } + syncQsoStatusToUI() + } + + /// Abandon the current target without logging (✕ action): back to the CQ + /// baseline or idle per the auto-CQ setting. Mirrors Android `userResetToCQ`. + func clearActiveQso() { + guard let qso = qsoEngine, let appState else { return } + qso.abandonToCq() + appState.tx.targetSnr = nil + appState.tx.conversationLog.removeAll() + supervisor.resetAttempts() + supervisor.resetWatchdog(nowMs: Int64(Date().timeIntervalSince1970 * 1000)) + syncQsoStatusToUI() + } + + // MARK: - TUNE carrier + + /// Latch/unlatch a steady carrier at the current TX audio frequency for + /// antenna/amplifier tuning. Auto-stops at the settings timeout; the + /// buffer itself is capped at the timeout, so playback ending enforces the + /// hard max-on time even if the countdown ticker dies. + func toggleTune() { + guard let appState else { return } + if appState.tx.isTuning { + stopTune() + appState.toast.show("Tune stopped", icon: "dot.radiowaves.right") + } else { + startTune() + } + } + + private func startTune() { + guard let appState else { return } + guard isRunning else { + appState.toast.show("Start RX before tuning", icon: "exclamationmark.triangle") + return + } + // Tune and FT8 TX are mutually exclusive (the TUNE pill is also + // disabled in the UI while the sequencer is armed). + guard !appState.tx.isActivated, !appState.tx.isTransmitting else { return } + + let timeout = max(1, appState.settings.tuneTimeoutSec) + let samples = TuneTone.samples( + freqHz: appState.waterfall.txFreqHz, + sampleRate: Int(FT8.sampleRate), + durationSec: timeout + ) + guard !samples.isEmpty else { return } + + appState.tx.isTuning = true + appState.tx.tuneRemainingSec = timeout + appState.toast.show("Tuning at \(Int(appState.waterfall.txFreqHz)) Hz", icon: "dot.radiowaves.right") + txPlayer.play(samples) { [weak self] in + Task { @MainActor in self?.finishTune() } + } + tuneTask?.cancel() + tuneTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, let self, let appState = self.appState, + appState.tx.isTuning else { return } + appState.tx.tuneRemainingSec = max(0, appState.tx.tuneRemainingSec - 1) + } + } + } + + /// Stop the tune carrier if one is active (tap, timeout, or TX taking over). + func stopTune() { + guard let appState, appState.tx.isTuning else { return } + txPlayer.stop() + finishTune() + } + + /// Idempotent tune teardown (called from stop-tap, playback completion, + /// and engine stop). + private func finishTune() { + tuneTask?.cancel() + tuneTask = nil + guard let appState, appState.tx.isTuning else { return } + appState.tx.isTuning = false + appState.tx.tuneRemainingSec = 0 + } + // MARK: - Decode loop /// Polls for slot boundaries and runs the FT8 decoder at each transition. @@ -251,11 +422,19 @@ final class LiveEngine { // Broadcast this slot's decodes over the WSJT-X UDP interface. broadcastUdpDecodes(decoded) + // Hand this slot's decodes to the PSK Reporter queue (policy-filtered, + // rate-limited per callsign/band, flushed every ~5 min when enabled). + let dialHz = Int64((Double(bandToFreqMhz(appState.settings.band)) ?? 14.074) * 1_000_000) + OnlineLogService.shared.enqueuePskDecodes(decoded, dialFreqHz: dialHz, appState: appState) + + let settings = appState.settings + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + // Log incoming RX messages addressed to us for the conversation panel. - let myCall = appState.settings.myCall.uppercased() + let myCall = settings.myCall.uppercased() if !myCall.isEmpty { for msg in decoded where msg.callTo.uppercased() == myCall { - let utcTime = Self.utcTimeString(from: Int64(Date().timeIntervalSince1970 * 1000)) + let utcTime = Self.utcTimeString(from: nowMs) appState.tx.conversationLog.append( QsoLogEntry(direction: .rx, message: msg.rawText.isEmpty ? "\(msg.callTo) \(msg.callFrom) \(msg.extra)" : msg.rawText, snr: msg.snr, utcTime: utcTime) @@ -263,9 +442,31 @@ final class LiveEngine { } } + // Track the last SNR heard from the current target for the QSO header. + if let dx = qso.status().target?.uppercased(), + let m = decoded.last(where: { $0.callFrom.uppercased() == dx }) { + appState.tx.targetSnr = m.snr + } + + // Hunt / auto-call-follow: lock a station while we have no target. + autoAnswerIfHunting(decoded, slotID: slotID, nowMs: nowMs) + + // Caller queue: stations calling me that aren't the current target + // wait their turn while we're busy (in a QSO or running CQ). + enqueueCallers(decoded, nowMs: nowMs) + // Capture pre-RX target to detect new QSO answers. let previousTarget = qso.status().target + // A reply from the target (or any answer to our CQ) resets the + // stop-after-N no-reply streak. + if !myCall.isEmpty, decoded.contains(where: { m in + m.callTo.uppercased() == myCall + && (previousTarget == nil || m.callFrom.uppercased() == previousTarget!.uppercased()) + }) { + supervisor.noteReply() + } + // Feed decodes to the QSO sequencer. let outcome = qso.processRx(decoded) @@ -287,17 +488,31 @@ final class LiveEngine { } } + // The engine locked a caller on its own — their queue slot is obsolete, + // and a fresh target restarts the safety-net clocks. + if let dx = qso.status().target, + previousTarget?.uppercased() != dx.uppercased() { + callerQueue.remove(callsign: dx) + armSupervisor() + } + // Handle QSO completion. - if case .completed(var record) = outcome { - // Assign a unique ID based on the current record count. - let nextId = Int64((appState.logbook.records.map { $0.id ?? 0 }.max() ?? 0) + 1) - record.id = nextId - appState.logbook.records.insert(record, at: 0) - QsoLogStore.save(appState.logbook.records) - // Broadcast the logged QSO over the WSJT-X UDP interface. - broadcastUdpQso(record) - // Trigger QSO celebration animation. - appState.tx.qsoCompletedAt = Date() + if case .completed(let record) = outcome { + logCompletedQso(record) + // Auto-CQ keeps the run going, so refresh the watchdog on each + // completed QSO (mirrors Android). + if settings.autoCQAfterQSO { + supervisor.resetWatchdog(nowMs: nowMs) + } + } + + // Previous QSO fully wrapped up → work the next queued caller. + serviceCallerQueue(nowMs: nowMs) + + // A hunt that is idle-listening (no target locked, not CQing) is + // behaving correctly — keep the watchdog fresh (mirrors Android). + if appState.tx.huntEnabled, qso.status().target == nil, !settings.huntCallsCQ { + supervisor.resetWatchdog(nowMs: nowMs) } // Sync QSO status back to UI. @@ -307,26 +522,242 @@ final class LiveEngine { // operator's selected TX parity, transmit. The audio keys up immediately, // so it plays in `slotID` itself — gate on that slot's parity (mirrors the // desktop engine's `maybe_transmit`), not the next slot's. + // The TX safety nets run first: watchdog (N minutes of continuous TX + // cycles) and stop-after-N (same message repeatedly unanswered). if let txMsg = qso.txMessage { - if SlotClock.shouldTransmit(slotID: slotID, desiredParity: appState.tx.slotParity) { + if let reason = supervisor.stopReason( + nowMs: nowMs, + watchdogMin: settings.txWatchdogMin, + stopAfterAttempts: settings.stopAfterAttempts + ) { + // A supervisor stop must also disarm hunt: with hunt left + // armed, the hunt branch would restart CQ next slot with a + // fresh supervisor — runaway TX forever. Only supervisor + // stops disarm; a manual STOP tap keeps hunt as-is. + let huntWasArmed = appState.tx.huntEnabled + appState.tx.huntEnabled = false + stopTx() + let huntSuffix = huntWasArmed ? " (hunt disarmed)" : "" + switch reason { + case .watchdog: + appState.toast.show( + "TX watchdog: stopped after \(settings.txWatchdogMin) min\(huntSuffix)", + icon: "exclamationmark.triangle") + case .noReply: + appState.toast.show( + "No reply after \(settings.stopAfterAttempts) attempts — TX stopped\(huntSuffix)", + icon: "stop.circle") + } + } else if SlotClock.shouldTransmit(slotID: slotID, desiredParity: appState.tx.slotParity) { + // The attempt is counted in beginTxPlayback, once playback is + // actually committed — TUNE, encode failure, and late-skip + // slots must not feed the stop-after-N / watchdog bookkeeping. scheduleTx(message: txMsg, freqHz: appState.waterfall.txFreqHz) } } } - /// Generate FT8 audio and play it through the speaker. + /// Auto-answer while no target is locked: HUNT answers other stations' + /// CQs (skipping blocked and already-worked calls); the auto-call-follow + /// setting answers stations calling us directly while we're idle; the + /// hunt-calls-CQ setting turns a caller-less hunt into a CQ run. + private func autoAnswerIfHunting(_ decoded: [DecodedMessage], slotID: Int64, nowMs: Int64) { + guard let qso = qsoEngine, let appState else { return } + let settings = appState.settings + let myCall = settings.myCall.uppercased() + guard myCall.count >= 3 else { return } + guard qso.status().target == nil else { return } + + let blocked = settings.blockedCallsigns.map { $0.uppercased() } + let worked = Set(appState.logbook.records.map { $0.call.uppercased() }) + func eligible(_ m: DecodedMessage) -> Bool { + let from = m.callFrom.uppercased() + return !from.isEmpty && from != myCall + && !from.contains("<") && !blocked.contains(from) + } + + var candidate: DecodedMessage? + if appState.tx.huntEnabled { + // Strongest un-worked station calling CQ. + candidate = decoded + .filter { $0.callTo.uppercased().hasPrefix("CQ") && eligible($0) + && !worked.contains($0.callFrom.uppercased()) } + .max { $0.snr < $1.snr } + } + if candidate == nil, settings.autoCallFollow, !qso.active { + // Someone calling us directly while we're idle. + candidate = decoded + .filter { $0.callTo.uppercased() == myCall && eligible($0) } + .max { $0.snr < $1.snr } + } + + if let msg = candidate { + appState.tx.conversationLog.removeAll() + qso.answer(msg) + // They transmitted in the slot that just ended, so reply in slots + // of the opposite parity — i.e. the parity of the slot now starting. + appState.tx.slotParity = Int(SlotClock.parity(slotID: slotID)) + appState.tx.targetSnr = msg.snr + armSupervisor() + appState.toast.show("Answering \(msg.callFrom.uppercased())", icon: "target") + } else if appState.tx.huntEnabled, settings.huntCallsCQ, !qso.active { + // Hunting with no callers → call CQ ourselves. + qso.startCq() + armSupervisor() + } + } + + /// Queue stations calling me that aren't the current target (dedup by + /// call, stale entries pruned) and mirror the queue into TxState. + private func enqueueCallers(_ decoded: [DecodedMessage], nowMs: Int64) { + guard let qso = qsoEngine, let appState else { return } + defer { appState.tx.queuedCallers = callerQueue.callers.map(\.callsign) } + callerQueue.removeStale(nowMs: nowMs) + guard qso.active else { return } + let myCall = appState.settings.myCall.uppercased() + guard !myCall.isEmpty else { return } + + let target = qso.status().target + for m in decoded where m.callTo.uppercased() == myCall { + guard CallerQueue.shouldEnqueue( + callsign: m.callFrom, + myCall: myCall, + currentTarget: target, + blocked: appState.settings.blockedCallsigns + ) else { continue } + callerQueue.enqueue(QueuedCaller( + callsign: m.callFrom.uppercased(), + snr: m.snr, + freqHz: m.freqHz, + grid: m.grid, + queuedAtMs: nowMs + )) + } + } + + /// When the previous QSO has fully wrapped up (no target locked), start + /// working the next queued caller. Runs after each slot's RX processing, + /// so it also covers the courtesy-73 case where completion lands a slot + /// before the target clears. + private func serviceCallerQueue(nowMs: Int64) { + guard let qso = qsoEngine, let appState else { return } + guard qso.status().target == nil else { return } + callerQueue.removeStale(nowMs: nowMs) + guard let next = callerQueue.dequeueNext() else { + appState.tx.queuedCallers = callerQueue.callers.map(\.callsign) + return + } + startQsoWithCaller(next, nowMs: nowMs) + } + + /// Begin a QSO with a dequeued caller: they already called us, so if they + /// sent their grid we answer with a report (Tx2), otherwise with our grid. + private func startQsoWithCaller(_ caller: QueuedCaller, nowMs: Int64) { + guard let qso = qsoEngine, let appState else { return } + appState.tx.conversationLog.removeAll() + let msg = FT8DSP.DecodedMessage( + callTo: appState.settings.myCall.uppercased(), + callFrom: caller.callsign, + extra: "", + grid: caller.grid, + rawText: "", + snr: caller.snr, + freqHz: caller.freqHz, + timeSec: 0, + score: 0, + i3: 0, + n3: 0, + a91: [UInt8](repeating: 0, count: 12) + ) + qso.answer(msg) + if !caller.grid.isEmpty { + // They already sent their grid — skip ours and send the report. + qso.setStage(.report) + } + appState.tx.targetSnr = caller.snr + armSupervisor() + appState.tx.queuedCallers = callerQueue.callers.map(\.callsign) + appState.toast.show("Working \(caller.callsign)", icon: "person.wave.2") + } + + /// Persist + broadcast a completed QSO record (shared by the auto-sequence + /// completion path and the force-log action). + private func logCompletedQso(_ record: QsoRecord) { + guard let appState else { return } + var record = record + // Assign a unique ID based on the current record count. + let nextId = Int64((appState.logbook.records.map { $0.id ?? 0 }.max() ?? 0) + 1) + record.id = nextId + appState.logbook.records.insert(record, at: 0) + QsoLogStore.save(appState.logbook.records) + // Broadcast the logged QSO over the WSJT-X UDP interface. + broadcastUdpQso(record) + // Upload to Cloudlog/QRZ when enabled (fire-and-forget with retry; + // marks the record's synced flags and re-persists on success). + OnlineLogService.shared.handleQsoLogged(record, appState: appState) + // Trigger QSO celebration animation. + appState.tx.qsoCompletedAt = Date() + } + + /// Restart both TX safety nets (fresh CQ run / new target). + private func armSupervisor() { + supervisor.resetWatchdog(nowMs: Int64(Date().timeIntervalSince1970 * 1000)) + supervisor.resetAttempts() + } + + /// Generate FT8 audio and play it, honoring the operator's TX delay and + /// the late-start clip rule. private func scheduleTx(message: String, freqHz: Float) { guard let appState else { return } + guard !appState.tx.isTuning else { return } // tune and FT8 TX are exclusive guard let samples = FT8Encoder.generateFT8(message, baseFreqHz: freqHz) else { return } + let txDelayMs = max(0, appState.settings.txDelayMs) + txScheduleTask?.cancel() + txScheduleTask = Task { @MainActor [weak self] in + if txDelayMs > 0 { + try? await Task.sleep(for: .milliseconds(txDelayMs)) + } + guard !Task.isCancelled else { return } + self?.beginTxPlayback(message: message, samples: samples) + } + } + + /// Start playback now: clip the leading `max(0, msIntoCycle - slack)` ms + /// when we're late into the 15 s cycle so the TX still ends on the cycle + /// boundary. The slack is the *physical* `TxTiming.defaultSlackMs` + /// constant (15000 − 12640 = 2360 ms) — never the user setting: a smaller + /// slack clips the leading Costas sync of ON-TIME transmissions and makes + /// them audible but undecodable (see CLAUDE.md, TX pipeline gotcha #2). + /// The late-start-tolerance setting is a SKIP threshold instead: when more + /// than that much leading audio would be clipped, the slot is skipped. + private func beginTxPlayback(message: String, samples: [Float]) { + guard let appState else { return } + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let clipMs = TxTiming.lateStartClipMs( + msIntoCycle: SlotClock.msIntoCycle(atUtcMs: nowMs) + ) + guard !TxTiming.shouldSkip( + clipMs: clipMs, + toleranceMs: Int64(appState.settings.lateStartToleranceMs) + ) else { return } // too late for the operator's tolerance — skip this cycle + let skip = TxTiming.clipSampleCount(clipMs: clipMs, sampleRate: Int(FT8.sampleRate)) + guard skip < samples.count else { return } // hopelessly late — skip this cycle + let playSamples = skip > 0 ? Array(samples[skip...]) : samples + + // Playback is definitely happening now — count the attempt for the + // TX safety nets (stop-after-N / watchdog bookkeeping). Skipped or + // failed slots above never reach this point. + supervisor.noteTransmission(message) + // Log the TX message to conversation panel. - let utcTime = Self.utcTimeString(from: Int64(Date().timeIntervalSince1970 * 1000)) appState.tx.conversationLog.append( - QsoLogEntry(direction: .tx, message: message, snr: nil, utcTime: utcTime) + QsoLogEntry(direction: .tx, message: message, snr: nil, + utcTime: Self.utcTimeString(from: nowMs)) ) appState.tx.isTransmitting = true - txPlayer.play(samples) { [weak self] in + txPlayer.play(playSamples) { [weak self] in Task { @MainActor in self?.appState?.tx.isTransmitting = false } @@ -336,23 +767,35 @@ final class LiveEngine { // MARK: - Waterfall loop /// Runs ~4x per second, building FFT rows and updating the waterfall + - /// spectrum state. + /// spectrum state. Reads the operator's spectrum-width setting each tick so + /// a change applies live (display-only — the FT8 decoder's fixed range is + /// untouched), stamps a UTC label on the first row of each 15 s period, and + /// meters the RX input level for the info-bar indicator. private nonisolated func runWaterfallLoop( accumulator: SlotAccumulator, - applyWaterfall: @escaping @Sendable @MainActor ([UInt8], [Float]) -> Void + readSpectrumWidthHz: @escaping @Sendable @MainActor () -> Int, + applyWaterfall: @escaping @Sendable @MainActor (WaterfallUpdate) -> Void ) async { let sampleRate = Int(FT8.sampleRate) let needed = WaterfallRowBuilder.samplesNeeded() - let columns = WaterfallRowBuilder.columns(sampleRate: sampleRate) var builder = WaterfallRowBuilder() + var timestampGate = WaterfallTimestampGate() while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(250)) if Task.isCancelled { break } + let displayMaxHz = Float(await MainActor.run { readSpectrumWidthHz() }) + let columns = WaterfallRowBuilder.columns(sampleRate: sampleRate, maxHz: displayMaxHz) + let samples = accumulator.peekRecent(needed) guard samples.count >= needed else { continue } + // RX input level from the most recent ~250 ms (the window Android + // meters), classified later by the pure AudioInputLevel. + let levelWindow = min(samples.count, sampleRate / 4) + let level = AudioInputLevel.measure(Array(samples.suffix(levelWindow))) + // Compute Welch-averaged power spectrum. let power = FFTProcessor.welchPower( samples: samples, @@ -367,7 +810,25 @@ final class LiveEngine { let scale = maxPower > 0 ? 1.0 / maxPower : 1.0 let spectrum = power.map { min($0 * scale, 1.0) } - await MainActor.run { applyWaterfall(row.bins, spectrum) } + // Stamp the UTC label once on the first row of each FT8 period + // (label shows the period start, matching Android). + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + var timestamp: String? + if timestampGate.shouldDraw(utcMs: nowMs) { + timestamp = WaterfallTimestampGate.utcLabel( + forUtcMs: WaterfallTimestampGate.slotStartMs(utcMs: nowMs) + ) + } + + let update = WaterfallUpdate( + bins: row.bins, + spectrum: spectrum, + displayMaxHz: displayMaxHz, + timestamp: timestamp, + inputPeak: level.peak, + inputRms: level.rms + ) + await MainActor.run { applyWaterfall(update) } } } @@ -381,6 +842,7 @@ final class LiveEngine { qso.myGrid = s.myGrid.uppercased() qso.band = s.band qso.freqMhz = bandToFreqMhz(s.band) + qso.autoReturnToCq = s.autoCQAfterQSO } /// Reflect QSO engine status into the UI TxState. @@ -389,8 +851,13 @@ final class LiveEngine { let status = qso.status() appState.tx.isActivated = status.active appState.tx.stage = mapStage(status.stage) + appState.tx.qsoStage = status.stage appState.tx.targetCall = status.target ?? "" appState.tx.txMessage = status.txMessage ?? "" + appState.tx.queuedCallers = callerQueue.callers.map(\.callsign) + if status.target == nil { + appState.tx.targetSnr = nil + } } private func mapStage(_ stage: TxStage) -> TxUIStage { @@ -562,6 +1029,19 @@ final class LiveEngine { } } +/// One tick of the waterfall pipeline, handed from the background loop to the +/// MainActor state application: the new brightness row, the normalized +/// spectrum, the display width the row was built to, an optional UTC label for +/// a 15 s period boundary, and the RX input level of the metering window. +struct WaterfallUpdate: Sendable { + let bins: [UInt8] + let spectrum: [Float] + let displayMaxHz: Float + let timestamp: String? + let inputPeak: Float + let inputRms: Float +} + /// Initializer extension on DecodedMessage for constructing from individual /// fields (used when mapping UI DecodeMessage back to the DSP type). extension FT8DSP.DecodedMessage { diff --git a/ios/FT8AF/FT8AF/Engine/OnlineLogService.swift b/ios/FT8AF/FT8AF/Engine/OnlineLogService.swift new file mode 100644 index 000000000..c7f9a57c4 --- /dev/null +++ b/ios/FT8AF/FT8AF/Engine/OnlineLogService.swift @@ -0,0 +1,294 @@ +import FT8DSP +import FT8Engine +import Foundation +import Network + +/// Thin app-side transport for the online-logging integrations. All request +/// building and response parsing lives in FT8Engine (CloudlogClient, +/// QrzLogbookClient, PskReporterEncoder — pure and unit-tested); this class +/// just moves the bytes: URLSession for the HTTP APIs and an NWConnection UDP +/// socket for PSK Reporter. +/// +/// Mirrors Android's split between `ThirdPartyService` (Cloudlog/QRZ upload + +/// catch-up sync with per-record synced flags) and `PskReporterSender` +/// (batched spots flushed every ~5 minutes with a per-call/band dedup window). +@Observable @MainActor +final class OnlineLogService { + static let shared = OnlineLogService() + + // Catch-up sync progress (drives the Logbook toolbar UI). + private(set) var isSyncing = false + private(set) var syncDone = 0 + private(set) var syncTotal = 0 + + // PSK Reporter batching state. + private let pskEncoder = PskReporterEncoder() + private let pskDedup = PskDedup() + private var pskQueue: [PskSpot] = [] + private var pskFlushTask: Task? + + // MARK: - Upload on QSO logged + + /// Fire-and-forget upload of a freshly-logged QSO to every enabled + /// service (each attempt retried once), then mark the record's synced + /// flags and persist. Safe to call unconditionally — no-op when nothing + /// is enabled/configured. + func handleQsoLogged(_ record: QsoRecord, appState: AppState) { + let s = appState.settings + let cloudlog = s.cloudlogEnabled && !s.cloudlogUrl.isEmpty && !s.cloudlogApiKey.isEmpty + let qrz = s.qrzLogbookEnabled && !s.qrzLogbookApiKey.isEmpty + guard cloudlog || qrz else { return } + + let adif = Adif.singleRecord(record) + let cloudlogReq = cloudlog + ? CloudlogClient.qsoUploadRequest( + serverAddress: s.cloudlogUrl, apiKey: s.cloudlogApiKey, + stationId: s.cloudlogStationId, adif: adif) + : nil + let qrzBody = qrz ? QrzLogbookClient.insertBody(apiKey: s.qrzLogbookApiKey, adif: adif) : nil + + Task { [weak appState] in + var clOk = false + var qrzOk = false + if let cloudlogReq { clOk = await Self.postCloudlogWithRetry(cloudlogReq) } + if let qrzBody { qrzOk = await Self.postQrzWithRetry(qrzBody) } + guard clOk || qrzOk, let appState else { return } + Self.markSynced(id: record.id, cloudlog: clOk, qrz: qrzOk, appState: appState) + QsoLogStore.save(appState.logbook.records) + } + } + + private static func markSynced(id: Int64?, cloudlog: Bool, qrz: Bool, appState: AppState) { + guard let id, + let idx = appState.logbook.records.firstIndex(where: { $0.id == id }) + else { return } + if cloudlog { appState.logbook.records[idx].syncedCloudlog = true } + if qrz { appState.logbook.records[idx].syncedQrz = true } + } + + // MARK: - Catch-up sync ("sync all unsynced") + + /// Re-upload every record still awaiting an enabled service (services + /// dedupe by call+date+time+mode, so repeats are safe). Updates + /// `syncDone`/`syncTotal` as it goes and persists the flags at the end. + /// Returns (attempted, cloudlogOk, qrzOk). + func syncAll(appState: AppState) async -> (attempted: Int, cloudlogOk: Int, qrzOk: Int) { + guard !isSyncing else { return (0, 0, 0) } + let s = appState.settings + let cl = s.cloudlogEnabled && !s.cloudlogUrl.isEmpty && !s.cloudlogApiKey.isEmpty + let qrz = s.qrzLogbookEnabled && !s.qrzLogbookApiKey.isEmpty + let pending = OnlineLogSync.unsynced( + appState.logbook.records, cloudlogEnabled: cl, qrzEnabled: qrz) + guard !pending.isEmpty else { return (0, 0, 0) } + + isSyncing = true + syncTotal = pending.count + syncDone = 0 + var clOk = 0 + var qrzOk = 0 + for record in pending { + let adif = Adif.singleRecord(record) + if cl, !record.syncedCloudlog, + let req = CloudlogClient.qsoUploadRequest( + serverAddress: s.cloudlogUrl, apiKey: s.cloudlogApiKey, + stationId: s.cloudlogStationId, adif: adif), + await Self.postCloudlog(req) { + clOk += 1 + Self.markSynced(id: record.id, cloudlog: true, qrz: false, appState: appState) + } + if qrz, !record.syncedQrz { + let body = QrzLogbookClient.insertBody(apiKey: s.qrzLogbookApiKey, adif: adif) + if await Self.postQrz(body) { + qrzOk += 1 + Self.markSynced(id: record.id, cloudlog: false, qrz: true, appState: appState) + } + } + syncDone += 1 + } + QsoLogStore.save(appState.logbook.records) + isSyncing = false + return (pending.count, clOk, qrzOk) + } + + // MARK: - Test connections + + /// Cloudlog/Wavelog `api/auth/` check. The URL carries the key — + /// never log it. + nonisolated static func testCloudlog(serverAddress: String, apiKey: String) async -> Bool { + guard let urlStr = CloudlogClient.authUrl(serverAddress: serverAddress, apiKey: apiKey), + let url = URL(string: urlStr) + else { return false } + var req = URLRequest(url: url) + req.timeoutInterval = 15 + do { + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else { return false } + return CloudlogClient.isAuthValid(String(data: data, encoding: .utf8)) + } catch { + return false + } + } + + /// QRZ logbook `ACTION=STATUS` check (POST keeps the key out of the URL). + nonisolated static func testQrz(apiKey: String) async -> Bool { + guard !apiKey.isEmpty else { return false } + let response = await postForm(QrzLogbookClient.statusBody(apiKey: apiKey)) + return QrzLogbookClient.isStatusOk(response) + } + + // MARK: - PSK Reporter + + /// Hand this slot's decodes to the reporter queue. Applies the Android + /// sender's policy (skip self/free-text/unresolved hashes; one report per + /// callsign per band per 5 minutes) and schedules a batched flush. + func enqueuePskDecodes(_ decoded: [DecodedMessage], dialFreqHz: Int64, appState: AppState) { + let s = appState.settings + guard s.pskReporterEnabled, !s.myCall.isEmpty, !decoded.isEmpty else { return } + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + for m in decoded { + guard let spot = PskReporter.makeSpot( + callFrom: m.callFrom, i3: m.i3, n3: m.n3, grid: m.grid, snr: m.snr, + audioFreqHz: m.freqHz, dialFreqHz: dialFreqHz, + myCall: s.myCall, utcSeconds: nowMs / 1000) + else { continue } + guard pskDedup.markIfFresh( + call: spot.senderCallsign, dialFreqHz: dialFreqHz, nowMs: nowMs) + else { continue } + pskQueue.append(spot) + } + scheduleFlushIfNeeded(appState: appState) + } + + private func scheduleFlushIfNeeded(appState: AppState) { + guard pskFlushTask == nil, !pskQueue.isEmpty else { return } + pskFlushTask = Task { [weak self, weak appState] in + try? await Task.sleep(for: .milliseconds(Int(PskReporter.sendIntervalMs))) + guard let self, !Task.isCancelled else { return } + self.pskFlushTask = nil + self.flushPsk(appState: appState) + } + } + + /// Flush-on-stop (Android `PskReporterSender.stop()`): send anything + /// queued immediately, then cancel the pending flush task. Called from + /// `LiveEngine.stop()`. + func stopPsk(appState: AppState?) { + pskFlushTask?.cancel() + pskFlushTask = nil + flushPsk(appState: appState) + } + + /// Build the IPFIX datagrams for the queued spots and send them. + /// Re-checks the PSK Reporter toggle at flush time: once the operator + /// opts out, queued spots are dropped and the pending flush task is + /// cancelled — nothing may be sent after opt-out. + func flushPsk(appState: AppState?) { + guard let appState, appState.settings.pskReporterEnabled else { + pskQueue.removeAll() + pskFlushTask?.cancel() + pskFlushTask = nil + return + } + guard !pskQueue.isEmpty else { return } + let myCall = appState.settings.myCall + // Spots are only enqueued while a callsign is set; if it was cleared + // since, the queue can never be sent — drop it rather than strand it. + guard !myCall.isEmpty else { + pskQueue.removeAll() + return + } + let spots = pskQueue + pskQueue.removeAll() + let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "1.0" + let packets = pskEncoder.buildPackets( + rxCall: myCall, + rxGrid: appState.settings.myGrid, + software: PskReporter.buildSoftwareString(version: version, rigName: nil), + spots: spots, + nowMs: Int64(Date().timeIntervalSince1970 * 1000)) + Self.sendUdp(packets.map { Data($0) }) + } + + private nonisolated static func sendUdp(_ packets: [Data]) { + guard !packets.isEmpty, + let port = NWEndpoint.Port(rawValue: PskReporter.port) + else { return } + let conn = NWConnection( + host: NWEndpoint.Host(PskReporter.host), port: port, using: .udp) + conn.stateUpdateHandler = { state in + switch state { + case .ready: + // Sends complete in order on the connection queue; tear the + // connection down once the last one is processed. + for (i, packet) in packets.enumerated() { + conn.send(content: packet, completion: .contentProcessed { _ in + if i == packets.count - 1 { conn.cancel() } + }) + } + case .failed: + conn.cancel() // fire-and-forget: drop the batch + default: + break + } + } + conn.start(queue: .global(qos: .utility)) + } + + // MARK: - HTTP plumbing + + private static func postCloudlogWithRetry(_ req: CloudlogClient.Request) async -> Bool { + if await postCloudlog(req) { return true } + try? await Task.sleep(for: .seconds(2)) + return await postCloudlog(req) + } + + private static func postQrzWithRetry(_ body: String) async -> Bool { + if await postQrz(body) { return true } + try? await Task.sleep(for: .seconds(2)) + return await postQrz(body) + } + + /// POST the JSON upload. URLSession transparently follows the 307/308 + /// redirects Wavelog/Nextlog emit for rewritten paths, preserving the + /// POST body (unlike Android's HttpURLConnection, which needs the manual + /// redirect walk). + private nonisolated static func postCloudlog(_ req: CloudlogClient.Request) async -> Bool { + guard let url = URL(string: req.url) else { return false } + var r = URLRequest(url: url) + r.httpMethod = "POST" + r.setValue("application/json", forHTTPHeaderField: "Content-Type") + r.httpBody = Data(req.body.utf8) + r.timeoutInterval = 20 + do { + let (_, resp) = try await URLSession.shared.data(for: r) + guard let http = resp as? HTTPURLResponse else { return false } + return CloudlogClient.isSuccessStatus(http.statusCode) + } catch { + return false + } + } + + private nonisolated static func postQrz(_ body: String) async -> Bool { + QrzLogbookClient.isInsertSuccess(await postForm(body)) + } + + /// POST a form body to the QRZ endpoint; returns the response text on + /// HTTP 200/201, nil otherwise. + private nonisolated static func postForm(_ body: String) async -> String? { + guard let url = URL(string: QrzLogbookClient.endpoint) else { return nil } + var r = URLRequest(url: url) + r.httpMethod = "POST" + r.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + r.httpBody = Data(body.utf8) + r.timeoutInterval = 20 + do { + let (data, resp) = try await URLSession.shared.data(for: r) + guard let http = resp as? HTTPURLResponse, + http.statusCode == 200 || http.statusCode == 201 + else { return nil } + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } +} diff --git a/ios/FT8AF/FT8AF/Engine/PotaActivationStore.swift b/ios/FT8AF/FT8AF/Engine/PotaActivationStore.swift new file mode 100644 index 000000000..e6b3d00d7 --- /dev/null +++ b/ios/FT8AF/FT8AF/Engine/PotaActivationStore.swift @@ -0,0 +1,35 @@ +import FT8Engine +import Foundation + +/// File-based persistence for POTA activation sessions, exactly like +/// `QsoLogStore`: a JSON array in the app's documents directory, loaded when +/// the POTA screen first appears and saved after every change. This is what +/// makes the History tab (and a still-open activation) survive restarts. +enum PotaActivationStore { + private static let fileName = "pota_activations.json" + + static func fileURL() -> URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return docs.appendingPathComponent(fileName) + } + + static func load() -> [PotaActivationRecord] { + let url = fileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + do { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([PotaActivationRecord].self, from: data) + } catch { + return [] + } + } + + static func save(_ activations: [PotaActivationRecord]) { + do { + let data = try JSONEncoder().encode(activations) + try data.write(to: fileURL(), options: .atomic) + } catch { + // Best-effort: activation-history persistence failure is non-fatal. + } + } +} diff --git a/ios/FT8AF/FT8AF/Engine/PotaService.swift b/ios/FT8AF/FT8AF/Engine/PotaService.swift new file mode 100644 index 000000000..a2ea2d684 --- /dev/null +++ b/ios/FT8AF/FT8AF/Engine/PotaService.swift @@ -0,0 +1,66 @@ +import FT8Engine +import Foundation + +/// Live POTA activator-spot feed for the Hunt tab. Fetch + refresh state only; +/// all response decoding, filtering, and formatting is pure kit logic in +/// `FT8Engine.PotaSpots` (tested there with canned fixtures). +/// +/// Mirrors Android's `PotaSpotsRepository`: same endpoint, 60 s refresh cadence +/// (driven by the screen's `.task` while the Hunt tab is visible), same +/// FT8-mode constraint for hunting — except the filter is applied client-side +/// behind a toggle so it flips instantly without a refetch. +@Observable @MainActor +final class PotaService { + static let shared = PotaService() + + /// All decoded spots from the last successful fetch (every mode). + var spots: [PotaSpot] = [] + var isLoading = false + /// Human-readable failure from the last fetch, nil after a success. + var lastError: String? + var lastUpdatedAt: Date? + /// Hunt-tab constraint: Android hunts FT8-relevant spots only; the toggle + /// lets the operator peek at the full feed. + var ft8Only = true + + /// Spots as the Hunt list shows them (mode filter + frequency sort). + var displaySpots: [PotaSpot] { + PotaSpots.forDisplay(spots, ft8Only: ft8Only) + } + + private init() {} + + /// One fetch of the public activator-spot feed. Safe to call concurrently + /// (re-entrant calls are dropped while a fetch is in flight). + func refresh() async { + if isLoading { return } + guard let url = URL(string: PotaSpots.activatorSpotsURLString) else { return } + isLoading = true + defer { isLoading = false } + + var req = URLRequest(url: url) + req.timeoutInterval = 10 + req.setValue("ft8af-1.0", forHTTPHeaderField: "User-Agent") + req.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else { + let code = (resp as? HTTPURLResponse)?.statusCode ?? -1 + lastError = "POTA feed error (HTTP \(code))" + return + } + guard let decoded = PotaSpots.decode(data) else { + lastError = "POTA feed returned unexpected data" + return + } + spots = decoded + lastError = nil + lastUpdatedAt = Date() + } catch is CancellationError { + // Tab switched away mid-fetch — not an error. + } catch { + lastError = "POTA feed unreachable" + } + } +} diff --git a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift index d1076595f..b778dfc82 100644 --- a/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift +++ b/ios/FT8AF/FT8AF/Engine/TxPlayerService.swift @@ -61,6 +61,20 @@ final class TxPlayerService: @unchecked Sendable { /// Schedule and play `samples` (mono Float32 at 12 kHz). Interrupts any /// previously playing buffer so a new TX overrides the old one. func play(_ samples: [Float], completion: (() -> Void)? = nil) { + // The shared engine can be stopped at play time (route/config change, + // interruption, Simulator startup) — AVAudioPlayerNode.play() on a + // stopped engine logs "Cannot play yet!" and the TX is silent, and a + // stopped engine can also report an invalid mixer format, which would + // make the deferred attach below bail. Restart it first. + if let engine, !engine.isRunning { + do { + try engine.start() + } catch { + completion?() + return + } + } + guard ensureAttached(), let converter = outputConverter else { completion?() return diff --git a/ios/FT8AF/FT8AF/Fonts/geist_mono_bold.ttf b/ios/FT8AF/FT8AF/Fonts/geist_mono_bold.ttf new file mode 100644 index 000000000..201f8e7ba Binary files /dev/null and b/ios/FT8AF/FT8AF/Fonts/geist_mono_bold.ttf differ diff --git a/ios/FT8AF/FT8AF/Fonts/geist_mono_medium.ttf b/ios/FT8AF/FT8AF/Fonts/geist_mono_medium.ttf new file mode 100644 index 000000000..09702960d Binary files /dev/null and b/ios/FT8AF/FT8AF/Fonts/geist_mono_medium.ttf differ diff --git a/ios/FT8AF/FT8AF/Fonts/geist_mono_regular.ttf b/ios/FT8AF/FT8AF/Fonts/geist_mono_regular.ttf new file mode 100644 index 000000000..643e6ba07 Binary files /dev/null and b/ios/FT8AF/FT8AF/Fonts/geist_mono_regular.ttf differ diff --git a/ios/FT8AF/FT8AF/Fonts/geist_mono_semibold.ttf b/ios/FT8AF/FT8AF/Fonts/geist_mono_semibold.ttf new file mode 100644 index 000000000..ad0c82201 Binary files /dev/null and b/ios/FT8AF/FT8AF/Fonts/geist_mono_semibold.ttf differ diff --git a/ios/FT8AF/FT8AF/Fonts/inter_variable.ttf b/ios/FT8AF/FT8AF/Fonts/inter_variable.ttf new file mode 100644 index 000000000..047c92f6e Binary files /dev/null and b/ios/FT8AF/FT8AF/Fonts/inter_variable.ttf differ diff --git a/ios/FT8AF/FT8AF/Info.plist b/ios/FT8AF/FT8AF/Info.plist new file mode 100644 index 000000000..e49a1158c --- /dev/null +++ b/ios/FT8AF/FT8AF/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSLocalNetworkUsageDescription + FT8AF uses the local network to send WSJT-X UDP messages to companion apps like GridTracker and JTAlert + NSMicrophoneUsageDescription + FT8AF needs microphone access to capture radio audio for FT8 decoding + UIAppFonts + + inter_variable.ttf + geist_mono_regular.ttf + geist_mono_medium.ttf + geist_mono_semibold.ttf + geist_mono_bold.ttf + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/FT8AF/FT8AF/Navigation/AppTabView.swift b/ios/FT8AF/FT8AF/Navigation/AppTabView.swift index 9c14623ce..8aada9f57 100644 --- a/ios/FT8AF/FT8AF/Navigation/AppTabView.swift +++ b/ios/FT8AF/FT8AF/Navigation/AppTabView.swift @@ -28,7 +28,9 @@ struct AppTabView: View { // Bottom chrome: ActiveQsoPanel + TxStrip + SlotTimer + TabBar VStack(spacing: 0) { - if showsTxStrip && appState.tx.isActivated { + // Panel also shows while HUNT is armed (silent listening) so + // the "Hunting…" header state has somewhere to live. + if showsTxStrip && (appState.tx.isActivated || appState.tx.huntEnabled) { ActiveQsoPanel() } @@ -38,6 +40,7 @@ struct AppTabView: View { onCallCQ: { appState.engine?.callCQ() }, onStop: { appState.engine?.stopTx() }, onToggleSlot: { appState.engine?.toggleSlotParity() }, + onToggleTune: { appState.engine?.toggleTune() }, onOpenFrequencyPicker: { showFrequencyPicker = true }, onVolumeChange: { newVol in appState.settings.txVolume = newVol @@ -132,13 +135,13 @@ private struct TabBarButton: View { .frame(width: 40, height: 28) } Image(systemName: tab.icon) - .font(.system(size: 16, weight: isSelected ? .semibold : .regular)) + .font(.ft8afUI(size: 16, weight: isSelected ? .semibold : .regular)) .foregroundStyle(isSelected ? accent : textFaint) } .frame(height: 28) Text(tab.label) - .font(.system(size: 10.5, weight: isSelected ? .semibold : .medium)) + .font(.ft8afUI(size: 10.5, weight: isSelected ? .semibold : .medium)) .foregroundStyle(isSelected ? accent : textFaint) } .frame(maxWidth: .infinity) diff --git a/ios/FT8AF/FT8AF/Screens/Decode/DecodeRow.swift b/ios/FT8AF/FT8AF/Screens/Decode/DecodeRow.swift index a039bed6b..ae0108354 100644 --- a/ios/FT8AF/FT8AF/Screens/Decode/DecodeRow.swift +++ b/ios/FT8AF/FT8AF/Screens/Decode/DecodeRow.swift @@ -1,129 +1,176 @@ +import FT8Engine import SwiftUI +/// A single decoded FT8 message row, mirroring the Android `DecodeRow` design: +/// +/// - Left accent bar — pink for the current call target, amber for CQ +/// - Context label chips (CALLING / CQ / TO YOU) before the callsign +/// - Callsign (bold monospaced), grid locator, status pill on the right +/// - Full decoded message text line +/// - Meta row: segmented signal bar, SNR, audio frequency, distance, +/// relative age ("now" / "32s" / "5m" / "2h") +/// - DXCC entity location line with globe icon (when known) +/// +/// Background tinting: cyan glow for messages directed at the operator, pink +/// soft for the call target, surface card for CQ, transparent otherwise. +/// All classification logic is pure and lives in FT8Engine +/// (`classifyDecode`, `relativeAge`, `gridDistanceText`, `DxccPrefix`). struct DecodeRow: View { let message: DecodeMessage var myCall: String = "" - var workedCalls: Set = [] + var myGrid: String = "" + var highlight: DecodeHighlight? + var isTarget: Bool = false var compact: Bool = false + var now: Date = Date() + var distanceInMiles: Bool = false - var body: some View { - HStack(spacing: 0) { - // UTC time - Text(shortTime) - .font(.system(size: compact ? 10 : 11, weight: .medium, design: .monospaced)) - .foregroundStyle(textFaint) - .frame(width: compact ? 34 : 38, alignment: .leading) - - // SNR badge - Text(snrText) - .font(.system(size: compact ? 10 : 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(snrColor) - .frame(width: compact ? 28 : 32, alignment: .trailing) - .padding(.trailing, compact ? 6 : 8) - - // Frequency - Text(freqText) - .font(.system(size: compact ? 10 : 11, weight: .medium, design: .monospaced)) - .foregroundStyle(textFaint) - .frame(width: compact ? 36 : 40, alignment: .trailing) - .padding(.trailing, compact ? 8 : 10) - - // Callsigns + extra - VStack(alignment: .leading, spacing: compact ? 1 : 2) { - HStack(spacing: 4) { - // CQ / callTo pill - if message.callTo == "CQ" || message.callTo.hasPrefix("CQ ") { - Text("CQ") - .font(.system(size: compact ? 9 : 10, weight: .bold, design: .monospaced)) - .foregroundStyle(bgApp) - .padding(.horizontal, compact ? 4 : 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3) - .fill(accent) - ) - } else { - Text(message.callTo) - .font(.system(size: compact ? 11 : 12, weight: .medium, design: .monospaced)) - .foregroundStyle(signal) - } - - Image(systemName: "arrow.left") - .font(.system(size: compact ? 7 : 8)) - .foregroundStyle(textDim) + private var isCQ: Bool { isCQMessage(callTo: message.callTo) } + private var isToMe: Bool { isDirectedToMe(callTo: message.callTo, myCall: myCall) } + /// Mid-QSO with a third party — de-emphasized but readable (Android: 0.8). + private var isInQsoWithOther: Bool { !isCQ && !isToMe && !isTarget } - Text(message.callFrom) - .font(.system(size: compact ? 12 : 13, weight: .bold, design: .monospaced)) - .foregroundStyle(textPrimary) - } + var body: some View { + HStack(alignment: .top, spacing: 0) { + // Left accent bar — target (pink) wins over CQ (amber) because it + // is the more actionable signal for the operator. + if let accentColor = accentBarColor { + RoundedRectangle(cornerRadius: 99) + .fill(accentColor) + .frame(width: 3, height: compact ? 32 : 52) + Spacer().frame(width: 10) + } else { + Spacer().frame(width: 13) + } - if !compact { + VStack(alignment: .leading, spacing: 0) { + topRow + Spacer().frame(height: compact ? 2 : 4) + if !compact && !fullMessageText.isEmpty { Text(fullMessageText) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(textFaint) + .font(.ft8afMono(size: 12)) + .foregroundStyle(textMuted) .lineLimit(1) + Spacer().frame(height: 4) + } + metaRow + if !compact, let location = locationText { + Spacer().frame(height: 4) + HStack(spacing: 4) { + Image(systemName: "globe") + .font(.ft8afUI(size: 10)) + .foregroundStyle(textFaint) + Text(location) + .font(.ft8afUI(size: 10.5, weight: .medium)) + .foregroundStyle(textFaint) + } } } + .padding(.trailing, 12) + } + .padding(.vertical, compact ? 6 : 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(rowBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(rowBorder, lineWidth: 1) + ) + .contentShape(Rectangle()) + .opacity(isInQsoWithOther ? 0.8 : 1.0) + .padding(.horizontal, 12) + .padding(.vertical, 3) + } + + // MARK: - Sub-rows + + private var topRow: some View { + HStack(spacing: 8) { + // Context labels can stack — a target station calling CQ shows + // both CALLING and CQ, like Android. + if isTarget { + MessageLabel(text: "CALLING", color: target, bgColor: targetSoft) + } + if isCQ { + MessageLabel(text: "CQ", color: accent, bgColor: accentSoft) + } else if isToMe { + MessageLabel(text: "TO YOU", color: signal, bgColor: signalSoft) + } - Spacer() + Text(message.callFrom) + .font(.ft8afMono(size: 15, weight: .bold)) + .foregroundStyle(callsignColor) - // Chevron - Image(systemName: "chevron.right") - .font(.system(size: compact ? 9 : 10, weight: .semibold)) - .foregroundStyle(textDim) - } - .padding(.horizontal, 16) - .padding(.vertical, compact ? 5 : 8) - .background(rowBackground) - .overlay(alignment: .bottom) { - Rectangle() - .fill(borderSubtle) - .frame(height: 1) + if !message.grid.isEmpty { + Text(message.grid) + .font(.ft8afMono(size: 11)) + .foregroundStyle(textFaint) + } + + Spacer(minLength: 4) + + if let highlight { + StatusPillView(highlight: highlight, compact: true) + } } } - private var rowBackground: Color { - if isForMe { return target.opacity(0.10) } - if isCQ { return accent.opacity(0.06) } - if isWorked { return signal.opacity(0.06) } - return bgApp - } + private var metaRow: some View { + HStack(spacing: 10) { + SignalBarView( + snr: message.snr, + width: compact ? 22 : 28, + height: compact ? 10 : 12 + ) - private var isCQ: Bool { - message.callTo == "CQ" || message.callTo.hasPrefix("CQ ") - } + MetaText("\(message.snr) dB") + MetaText("\(Int(message.freqHz)) Hz") - private var isForMe: Bool { - !myCall.isEmpty && message.callTo.uppercased() == myCall.uppercased() - } + let distance = gridDistanceText(from: myGrid, to: message.grid, inMiles: distanceInMiles) + if !distance.isEmpty { + MetaText(distance) + } + + Spacer(minLength: 4) - private var isWorked: Bool { - workedCalls.contains(message.callFrom.uppercased()) + MetaText(relativeAge(secondsAgo: Int(now.timeIntervalSince(message.arrival)))) + } } - private var shortTime: String { - String(message.utcTime.prefix(5)) + // MARK: - Derived styling + + private var accentBarColor: Color? { + if isTarget { return target } + if isCQ { return accent } + return nil } - private var snrText: String { - message.snr >= 0 ? "+\(message.snr)" : "\(message.snr)" + private var rowBackground: Color { + if isToMe { return signal.opacity(0.08) } // cyan glow + if isTarget { return targetSoft } + if isCQ { return bgSurface } + return .clear } - private var snrColor: Color { - if message.snr >= 0 { return statusConfirmed } - if message.snr >= -10 { return signal } - if message.snr >= -18 { return textMuted } - return statusBad + private var rowBorder: Color { + if isToMe { return signal.opacity(0.22) } + if isTarget { return targetBorder } + if isCQ { return borderSubtle } + return .clear } - private var freqText: String { - String(format: "%.0f", message.freqHz) + private var callsignColor: Color { + if isToMe { return signal } + if isTarget { return target } + return textPrimary } private var fullMessageText: String { var parts: [String] = [] - parts.append(message.callTo) - parts.append(message.callFrom) + if !message.callTo.isEmpty { parts.append(message.callTo) } + if !message.callFrom.isEmpty { parts.append(message.callFrom) } if !message.extra.isEmpty { parts.append(message.extra) } else if !message.grid.isEmpty { @@ -131,4 +178,145 @@ struct DecodeRow: View { } return parts.joined(separator: " ") } + + /// DXCC entity name for the location line ("United States" → "USA" + /// shorthand, matching the Android abbreviation). + private var locationText: String? { + guard let entity = DxccPrefix.entity(for: message.callFrom) else { return nil } + switch entity.name { + case "United States": return "USA" + case "United Kingdom": return "UK" + default: return entity.name + } + } +} + +// MARK: - Status pill + +/// Android-parity status chip: 4 pt corner, glowing dot, hue at 0.12 bg / +/// 0.28 border alpha. Category → hue mapping mirrors `StatusPill.kt` with the +/// pending/needed chips on the amber accent. +private struct StatusPillView: View { + let highlight: DecodeHighlight + var compact: Bool = false + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(hue) + .frame(width: 6, height: 6) + Text(label) + .font(.ft8afUI(size: compact ? 9.5 : 11, weight: .semibold)) + .foregroundStyle(hue) + } + .padding(.horizontal, compact ? 6 : 8) + .frame(height: compact ? 18 : 22) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(hue.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(hue.opacity(0.28), lineWidth: 1) + ) + } + + private var label: String { + switch highlight { + case .pending: return "PENDING" + case .pota: return "POTA" + case .newDxcc: return "NEW DXCC" + case .newGrid: return "NEW GRID" + case .newBand: return "NEW BAND" + case .worked: return "WORKED" + case .cq: return "CQ" + } + } + + private var hue: Color { + switch highlight { + case .pending: return accent // amber — needs the operator's action + case .pota: return statusConfirmed // green + case .newDxcc: return statusNew // purple + case .newGrid: return statusWarn // yellow + case .newBand: return statusWorked // cyan + case .worked: return statusWorked // cyan + case .cq: return statusCq // amber + } + } +} + +// MARK: - Signal bar + +/// Small segmented signal-strength bar (5 ascending bars), port of the +/// Android `SignalBar`: fill fraction = (snr + 25) / 30, color by SNR band. +private struct SignalBarView: View { + let snr: Int + var width: CGFloat = 28 + var height: CGFloat = 12 + + private static let bars = 5 + + var body: some View { + let filled = Self.filledBars(snr: snr) + let barWidth = (width - CGFloat(Self.bars - 1) * 2) / CGFloat(Self.bars) + HStack(alignment: .bottom, spacing: 2) { + ForEach(0.. Int { + let norm = min(1, max(0, Float(snr + 25) / 30)) + return min(bars, Int(norm * Float(bars))) + } + + private var barColor: Color { + if snr >= -5 { return statusConfirmed } // green + if snr >= -12 { return signal } // cyan + if snr >= -18 { return accent } // amber + return statusBad // red + } + + private var emptyColor: Color { + Color(red: 148/255, green: 163/255, blue: 184/255).opacity(0.15) + } +} + +// MARK: - Small pieces + +/// Small colored label chip (e.g. "CQ", "TO YOU", "CALLING"). +private struct MessageLabel: View { + let text: String + let color: Color + let bgColor: Color + + var body: some View { + Text(text) + .font(.ft8afUI(size: 9.5, weight: .bold)) + .kerning(0.6) + .foregroundStyle(color) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(RoundedRectangle(cornerRadius: 4).fill(bgColor)) + } +} + +/// Small monospaced metadata text used in the bottom info row. +private struct MetaText: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .font(.ft8afMono(size: 10.5)) + .foregroundStyle(textFaint) + } } diff --git a/ios/FT8AF/FT8AF/Screens/Decode/DecodeScreen.swift b/ios/FT8AF/FT8AF/Screens/Decode/DecodeScreen.swift index 743339f41..a2ecca7b2 100644 --- a/ios/FT8AF/FT8AF/Screens/Decode/DecodeScreen.swift +++ b/ios/FT8AF/FT8AF/Screens/Decode/DecodeScreen.swift @@ -1,3 +1,4 @@ +import FT8Engine import SwiftUI struct DecodeScreen: View { @@ -11,7 +12,7 @@ struct DecodeScreen: View { // Top bar HStack { Text("Decode") - .font(.system(size: 18, weight: .bold)) + .font(.ft8afUI(size: 18, weight: .bold)) .foregroundStyle(textPrimary) Spacer() @@ -21,7 +22,7 @@ struct DecodeScreen: View { appState.decode.compactMode.toggle() } label: { Image(systemName: decode.compactMode ? "list.bullet" : "list.bullet.indent") - .font(.system(size: 14, weight: .medium)) + .font(.ft8afUI(size: 14, weight: .medium)) .foregroundStyle(decode.compactMode ? accent : textMuted) } .buttonStyle(.plain) @@ -32,7 +33,7 @@ struct DecodeScreen: View { appState.decode.autoClear.toggle() } label: { Image(systemName: decode.autoClear ? "arrow.clockwise.circle.fill" : "arrow.clockwise.circle") - .font(.system(size: 14, weight: .medium)) + .font(.ft8afUI(size: 14, weight: .medium)) .foregroundStyle(decode.autoClear ? accent : textMuted) } .buttonStyle(.plain) @@ -43,14 +44,14 @@ struct DecodeScreen: View { appState.decode.messages.removeAll() } label: { Image(systemName: "trash") - .font(.system(size: 13, weight: .medium)) + .font(.ft8afUI(size: 13, weight: .medium)) .foregroundStyle(textMuted) } .buttonStyle(.plain) .padding(.trailing, 8) Text("\(decode.messages.count) msgs") - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: .medium)) .foregroundStyle(textMuted) } .padding(.horizontal, 16) @@ -77,40 +78,7 @@ struct DecodeScreen: View { if filteredMessages.isEmpty { emptyState } else { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(groupedBySlot.enumerated()), id: \.offset) { groupIdx, group in - // Time divider between slot groups - if groupIdx > 0 { - SlotDivider(time: group.first?.utcTime ?? "") - } - - ForEach(group) { message in - DecodeRow( - message: message, - myCall: appState.settings.myCall, - workedCalls: workedCallSet, - compact: decode.compactMode - ) - .id(message.id) - .onTapGesture { - appState.decode.selectedMessage = message - showQsoSheet = true - } - } - } - } - .padding(.bottom, 160) - } - .onChange(of: decode.messages.first?.id) { _, newId in - if let id = newId { - withAnimation(.easeOut(duration: 0.3)) { - proxy.scrollTo(id, anchor: .top) - } - } - } - } + messageList } Spacer(minLength: 0) @@ -143,39 +111,141 @@ struct DecodeScreen: View { } } - private var filteredMessages: [DecodeMessage] { - let blocked = Set(appState.settings.blockedCallsigns) - let msgs = blocked.isEmpty ? appState.decode.messages : + // MARK: - List + + private var messageList: some View { + let decode = appState.decode + let settings = appState.settings + let index = logbookIndex + let toggles = HighlightToggles( + newDxcc: settings.highlightNewDxcc, + newGrid: settings.highlightNewGrid, + newBand: settings.highlightNewBand, + worked: settings.highlightWorked + ) + let targetCall = appState.tx.targetCall.uppercased() + + return ScrollViewReader { proxy in + ScrollView { + // The 1 s timeline drives the relative-age labels + // ("now" → "32s" → "5m") without any per-row timers. + TimelineView(.periodic(from: .now, by: 1)) { timeline in + LazyVStack(spacing: 0) { + ForEach(Array(groupedBySlot.enumerated()), id: \.offset) { groupIdx, group in + // Time divider between slot groups + if groupIdx > 0 { + SlotDivider(time: group.first?.utcTime ?? "") + } + + ForEach(group) { message in + DecodeRow( + message: message, + myCall: settings.myCall, + myGrid: settings.myGrid, + highlight: classifyDecode( + callFrom: message.callFrom, + callTo: message.callTo, + grid: message.grid, + extra: message.extra, + myCall: settings.myCall, + band: settings.band, + index: index, + toggles: toggles + ), + isTarget: !targetCall.isEmpty && + message.callFrom.uppercased() == targetCall, + compact: decode.compactMode, + now: timeline.date, + distanceInMiles: settings.distanceInMiles + ) + .id(message.id) + .onTapGesture { + appState.decode.selectedMessage = message + showQsoSheet = true + } + } + } + } + .padding(.bottom, 160) + } + } + .onChange(of: decode.messages.first?.id) { _, newId in + if let id = newId { + withAnimation(.easeOut(duration: 0.3)) { + proxy.scrollTo(id, anchor: .top) + } + } + } + } + } + + // MARK: - Filtering + + /// Pre-computed logbook lookups shared by highlight classification and the + /// New DXCC / Needed chips. + private var logbookIndex: LogbookIndex { + LogbookIndex(qsos: appState.logbook.records.map { + LoggedQso(call: $0.call, grid: $0.gridsquare, band: $0.band) + }) + } + + /// Base stage (always-on): blocklist + the persisted settings filters + /// (Show Only CQ, DX Only, continent). These AND with whatever chip is + /// selected, mirroring Android `filterMessages`. + private var baseMessages: [DecodeMessage] { + let settings = appState.settings + let blocked = Set(settings.blockedCallsigns.map { $0.uppercased() }) + var msgs = blocked.isEmpty ? appState.decode.messages : appState.decode.messages.filter { !blocked.contains($0.callFrom.uppercased()) } + if settings.showOnlyCQ { + msgs = msgs.filter { isCQMessage(callTo: $0.callTo) } + } + if settings.dxOnly { + // DX = station's continent known and different from mine (derived + // from my call prefix). Unknown continents are conservatively kept + // out, matching the Android predicate. + let myContinent = DxccPrefix.continent(for: settings.myCall) + msgs = msgs.filter { + guard let mine = myContinent, + let theirs = DxccPrefix.continent(for: $0.callFrom) else { return false } + return theirs != mine + } + } + if settings.continentFilter != "All" { + msgs = msgs.filter { + DxccPrefix.continent(for: $0.callFrom) == settings.continentFilter + } + } + return msgs + } + + private var filteredMessages: [DecodeMessage] { + let msgs = baseMessages + switch appState.decode.activeFilter { case .all: return msgs - case .cq: return msgs.filter { $0.callTo == "CQ" || $0.callTo.hasPrefix("CQ ") } - case .cqPota: return msgs.filter { - $0.extra.contains("K-") || $0.extra.contains("VE-") || $0.extra.contains("DL-") || - $0.callTo == "CQ POTA" || $0.callTo.hasPrefix("CQ POTA") - } + case .cq: return msgs.filter { isCQMessage(callTo: $0.callTo) } + case .cqPota: return msgs.filter { isPotaCq(callTo: $0.callTo, extra: $0.extra) } case .newDxcc: - // Show only callsign prefixes not yet in logbook - let workedPrefixes = Set(appState.logbook.records.map { dxccPrefix($0.call) }) - return msgs.filter { !workedPrefixes.contains(dxccPrefix($0.callFrom)) } + // Stations whose DXCC entity isn't in the logbook yet. + let worked = logbookIndex.workedEntities + return msgs.filter { + guard let entity = DxccPrefix.entity(for: $0.callFrom) else { return true } + return !worked.contains(entity.name) + } case .needed: - // Show CQ stations not yet worked + // CQ stations not yet worked. + let workedCalls = logbookIndex.workedCalls return msgs.filter { - ($0.callTo == "CQ" || $0.callTo.hasPrefix("CQ ")) && - !workedCallSet.contains($0.callFrom.uppercased()) + isCQMessage(callTo: $0.callTo) && + !workedCalls.contains($0.callFrom.uppercased()) + } + case .forMe: + return msgs.filter { + isDirectedToMe(callTo: $0.callTo, myCall: appState.settings.myCall) } - case .forMe: return msgs.filter { $0.callTo.uppercased() == appState.settings.myCall.uppercased() } - } - } - - private func dxccPrefix(_ call: String) -> String { - var prefix = "" - for ch in call.uppercased() { - if ch.isNumber { prefix.append(ch); break } - prefix.append(ch) } - return prefix } /// Group filtered messages by slotIndex for time dividers. @@ -193,21 +263,17 @@ struct DecodeScreen: View { return groups } - private var workedCallSet: Set { - Set(appState.logbook.records.map { $0.call.uppercased() }) - } - private var emptyState: some View { VStack(spacing: 12) { Spacer() Image(systemName: "waveform.slash") - .font(.system(size: 40)) + .font(.ft8afUI(size: 40)) .foregroundStyle(textFaint) Text("No messages") - .font(.system(size: 16, weight: .medium)) + .font(.ft8afUI(size: 16, weight: .medium)) .foregroundStyle(textMuted) Text("Decoded FT8 messages will appear here") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textFaint) Spacer() } @@ -224,7 +290,7 @@ private struct SlotDivider: View { .fill(textDim.opacity(0.4)) .frame(height: 1) Text(String(time.prefix(5))) - .font(.system(size: 9, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .medium)) .foregroundStyle(textFaint) Rectangle() .fill(textDim.opacity(0.4)) diff --git a/ios/FT8AF/FT8AF/Screens/Decode/QsoSheet.swift b/ios/FT8AF/FT8AF/Screens/Decode/QsoSheet.swift index d23b60b38..2f68b7560 100644 --- a/ios/FT8AF/FT8AF/Screens/Decode/QsoSheet.swift +++ b/ios/FT8AF/FT8AF/Screens/Decode/QsoSheet.swift @@ -14,13 +14,13 @@ struct QsoSheet: View { // Callsign header VStack(spacing: 4) { Text(message.callFrom) - .font(.system(size: 28, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 28, weight: .bold)) .foregroundStyle(textPrimary) HStack(spacing: 8) { if !message.grid.isEmpty { Text(message.grid) - .font(.system(size: 14, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 14, weight: .medium)) .foregroundStyle(textMuted) } @@ -30,7 +30,7 @@ struct QsoSheet: View { } } label: { Text("QRZ") - .font(.system(size: 11, weight: .bold)) + .font(.ft8afUI(size: 11, weight: .bold)) .foregroundStyle(signal) .padding(.horizontal, 8) .padding(.vertical, 3) @@ -64,7 +64,7 @@ struct QsoSheet: View { if appState.tx.isActivated { VStack(spacing: 6) { Text("QSO PROGRESS") - .font(.system(size: 9, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .bold)) .foregroundStyle(textFaint) QsoStageDots(currentStage: appState.tx.stage) .padding(.horizontal, 24) @@ -83,11 +83,11 @@ struct QsoSheet: View { if !message.extra.isEmpty && message.extra != message.grid { HStack { Text("Report") - .font(.system(size: 12, weight: .medium)) + .font(.ft8afUI(size: 12, weight: .medium)) .foregroundStyle(textFaint) Spacer() Text(message.extra) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 14, weight: .semibold)) .foregroundStyle(textPrimary) } .padding(.horizontal, 24) @@ -98,7 +98,7 @@ struct QsoSheet: View { if appState.tx.isActivated && !appState.tx.txMessage.isEmpty { HStack(spacing: 8) { Text(appState.tx.isTransmitting ? "TX NOW" : "TX NEXT") - .font(.system(size: 9, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .bold)) .foregroundStyle(appState.tx.isTransmitting ? bgApp : accent) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -108,7 +108,7 @@ struct QsoSheet: View { ) Text(appState.tx.txMessage) - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: .medium)) .foregroundStyle(textPrimary) .lineLimit(1) @@ -133,9 +133,9 @@ struct QsoSheet: View { } label: { HStack(spacing: 8) { Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 14, weight: .semibold)) + .font(.ft8afUI(size: 14, weight: .semibold)) Text("Call \(message.callFrom)") - .font(.system(size: 16, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 16, weight: .bold)) } .foregroundStyle(bgApp) .frame(maxWidth: .infinity) @@ -172,11 +172,11 @@ private struct StatBadge: View { var body: some View { VStack(spacing: 4) { Text(label) - .font(.system(size: 10, weight: .medium)) + .font(.ft8afUI(size: 10, weight: .medium)) .foregroundStyle(textFaint) .textCase(.uppercase) Text(value) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 13, weight: .semibold)) .foregroundStyle(textPrimary) .lineLimit(1) .minimumScaleFactor(0.7) diff --git a/ios/FT8AF/FT8AF/Screens/Logbook/ExportLogSheet.swift b/ios/FT8AF/FT8AF/Screens/Logbook/ExportLogSheet.swift index 0ad99429a..2351da045 100644 --- a/ios/FT8AF/FT8AF/Screens/Logbook/ExportLogSheet.swift +++ b/ios/FT8AF/FT8AF/Screens/Logbook/ExportLogSheet.swift @@ -63,16 +63,16 @@ struct ExportLogSheet: View { } label: { HStack(spacing: 12) { Image(systemName: format.icon) - .font(.system(size: 14, weight: .semibold)) + .font(.ft8afUI(size: 14, weight: .semibold)) .foregroundStyle(selectedFormat == format ? accent : textFaint) .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text(format.rawValue) - .font(.system(size: 14, weight: .semibold)) + .font(.ft8afUI(size: 14, weight: .semibold)) .foregroundStyle(textPrimary) Text(format.description) - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } @@ -117,9 +117,9 @@ struct ExportLogSheet: View { HStack { Spacer() Image(systemName: "square.and.arrow.up") - .font(.system(size: 14, weight: .semibold)) + .font(.ft8afUI(size: 14, weight: .semibold)) Text("Export \(selectedFormat.rawValue)") - .font(.system(size: 15, weight: .bold)) + .font(.ft8afUI(size: 15, weight: .bold)) Spacer() } .foregroundStyle(bgApp) diff --git a/ios/FT8AF/FT8AF/Screens/Logbook/LogbookAwards.swift b/ios/FT8AF/FT8AF/Screens/Logbook/LogbookAwards.swift index de7253b35..111f57e2d 100644 --- a/ios/FT8AF/FT8AF/Screens/Logbook/LogbookAwards.swift +++ b/ios/FT8AF/FT8AF/Screens/Logbook/LogbookAwards.swift @@ -134,13 +134,13 @@ private struct AwardStatCard: View { .rotationEffect(.degrees(-90)) Text("\(value)") - .font(.system(size: 18, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 18, weight: .bold)) .foregroundStyle(textPrimary) } .frame(width: 54, height: 54) Text(title) - .font(.system(size: 10, weight: .semibold)) + .font(.ft8afUI(size: 10, weight: .semibold)) .foregroundStyle(textFaint) .textCase(.uppercase) } @@ -179,7 +179,7 @@ private struct AwardCard: View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: icon) - .font(.system(size: 14, weight: .semibold)) + .font(.ft8afUI(size: 14, weight: .semibold)) .foregroundStyle(color) .frame(width: 28, height: 28) .background( @@ -189,10 +189,10 @@ private struct AwardCard: View { VStack(alignment: .leading, spacing: 1) { Text(name) - .font(.system(size: 13, weight: .bold)) + .font(.ft8afUI(size: 13, weight: .bold)) .foregroundStyle(textPrimary) Text(description) - .font(.system(size: 10)) + .font(.ft8afUI(size: 10)) .foregroundStyle(textFaint) .lineLimit(1) } @@ -200,7 +200,7 @@ private struct AwardCard: View { Spacer() Text("\(current)/\(nextMilestone)") - .font(.system(size: 12, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: .bold)) .foregroundStyle(progress >= 1.0 ? statusConfirmed : textMuted) } @@ -227,7 +227,7 @@ private struct AwardCard: View { HStack(spacing: 0) { ForEach(milestones, id: \.self) { ms in Text("\(ms)") - .font(.system(size: 8, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 8, weight: .semibold)) .foregroundStyle(current >= ms ? color : textDim) if ms != milestones.last { Spacer() diff --git a/ios/FT8AF/FT8AF/Screens/Logbook/LogbookCharts.swift b/ios/FT8AF/FT8AF/Screens/Logbook/LogbookCharts.swift index 7a80533c4..12572217c 100644 --- a/ios/FT8AF/FT8AF/Screens/Logbook/LogbookCharts.swift +++ b/ios/FT8AF/FT8AF/Screens/Logbook/LogbookCharts.swift @@ -84,10 +84,10 @@ private struct BigStatCard: View { var body: some View { VStack(spacing: 4) { Text("\(value)") - .font(.system(size: 22, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 22, weight: .bold)) .foregroundStyle(textPrimary) Text(title) - .font(.system(size: 10, weight: .semibold)) + .font(.ft8afUI(size: 10, weight: .semibold)) .foregroundStyle(textFaint) .textCase(.uppercase) } @@ -112,7 +112,7 @@ private struct BandDonutChart: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text("BAND DISTRIBUTION") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(textFaint) HStack(spacing: 16) { @@ -128,10 +128,10 @@ private struct BandDonutChart: View { // Center label VStack(spacing: 1) { Text("\(total)") - .font(.system(size: 16, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 16, weight: .bold)) .foregroundStyle(textPrimary) Text("QSOs") - .font(.system(size: 8, weight: .medium)) + .font(.ft8afUI(size: 8, weight: .medium)) .foregroundStyle(textFaint) } } @@ -145,11 +145,11 @@ private struct BandDonutChart: View { .fill(bandColor(for: item.band)) .frame(width: 6, height: 6) Text(item.band) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .semibold)) .foregroundStyle(textMuted) Spacer() Text("\(item.count)") - .font(.system(size: 11, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .bold)) .foregroundStyle(textPrimary) } } @@ -218,11 +218,11 @@ private struct SnrSparkline: View { VStack(alignment: .leading, spacing: 8) { HStack { Text("SIGNAL TREND") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(textFaint) Spacer() Text("Last \(snrValues.count) QSOs") - .font(.system(size: 9, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .medium)) .foregroundStyle(textDim) } @@ -295,11 +295,11 @@ private struct GridHeatmap: View { VStack(alignment: .leading, spacing: 8) { HStack { Text("GRID COVERAGE") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(textFaint) Spacer() Text("\(workedFields.count)/324 fields") - .font(.system(size: 9, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .medium)) .foregroundStyle(textDim) } @@ -357,7 +357,7 @@ private struct AwardProgressSection: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text("AWARDS PROGRESS") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(textFaint) AwardBar(name: "DXCC Mixed", current: dxccCount, goal: 100, color: signal) @@ -386,11 +386,11 @@ private struct AwardBar: View { VStack(alignment: .leading, spacing: 4) { HStack { Text(name) - .font(.system(size: 11, weight: .semibold)) + .font(.ft8afUI(size: 11, weight: .semibold)) .foregroundStyle(textMuted) Spacer() Text("\(current)/\(goal)") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(textFaint) } GeometryReader { geo in diff --git a/ios/FT8AF/FT8AF/Screens/Logbook/LogbookScreen.swift b/ios/FT8AF/FT8AF/Screens/Logbook/LogbookScreen.swift index 72a1b2f8b..9b220c0cf 100644 --- a/ios/FT8AF/FT8AF/Screens/Logbook/LogbookScreen.swift +++ b/ios/FT8AF/FT8AF/Screens/Logbook/LogbookScreen.swift @@ -23,16 +23,42 @@ struct LogbookScreen: View { // Top bar HStack { Text("Logbook") - .font(.system(size: 18, weight: .bold)) + .font(.ft8afUI(size: 18, weight: .bold)) .foregroundStyle(textPrimary) Spacer() + // Catch-up sync to Cloudlog/QRZ (shown when a service is enabled) + if appState.settings.cloudlogEnabled || appState.settings.qrzLogbookEnabled { + let service = OnlineLogService.shared + Button { + runCatchUpSync() + } label: { + if service.isSyncing { + HStack(spacing: 5) { + ProgressView() + .controlSize(.mini) + .tint(accent) + Text("\(service.syncDone)/\(service.syncTotal)") + .font(.ft8afMono(size: 11, weight: .medium)) + .foregroundStyle(textMuted) + } + } else { + Image(systemName: "icloud.and.arrow.up") + .font(.ft8afUI(size: 14, weight: .medium)) + .foregroundStyle(textMuted) + } + } + .buttonStyle(.plain) + .disabled(service.isSyncing) + .padding(.trailing, 4) + } + // Import button Button { showImportPicker = true } label: { Image(systemName: "square.and.arrow.down") - .font(.system(size: 14, weight: .medium)) + .font(.ft8afUI(size: 14, weight: .medium)) .foregroundStyle(textMuted) } .buttonStyle(.plain) @@ -43,14 +69,14 @@ struct LogbookScreen: View { showExportSheet = true } label: { Image(systemName: "square.and.arrow.up") - .font(.system(size: 14, weight: .medium)) + .font(.ft8afUI(size: 14, weight: .medium)) .foregroundStyle(textMuted) } .buttonStyle(.plain) .padding(.trailing, 8) Text("\(logbook.totalCount) QSOs") - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: .medium)) .foregroundStyle(textMuted) } .padding(.horizontal, 16) @@ -109,10 +135,10 @@ struct LogbookScreen: View { // Search bar HStack(spacing: 8) { Image(systemName: "magnifyingglass") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textFaint) TextField("Search callsign, band, date...", text: $searchText) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textPrimary) .textInputAutocapitalization(.characters) .autocorrectionDisabled() @@ -121,7 +147,7 @@ struct LogbookScreen: View { searchText = "" } label: { Image(systemName: "xmark.circle.fill") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textFaint) } .buttonStyle(.plain) @@ -152,7 +178,11 @@ struct LogbookScreen: View { } else { LazyVStack(spacing: 0) { ForEach(filteredRecords, id: \.id) { record in - LogbookRow(record: record) + LogbookRow( + record: record, + showCloudlogChip: appState.settings.cloudlogEnabled, + showQrzChip: appState.settings.qrzLogbookEnabled + ) .onTapGesture { editingRecord = record } @@ -187,19 +217,37 @@ struct LogbookScreen: View { private var emptyState: some View { VStack(spacing: 12) { Image(systemName: "book.closed") - .font(.system(size: 40)) + .font(.ft8afUI(size: 40)) .foregroundStyle(textFaint) Text("No QSOs logged") - .font(.system(size: 16, weight: .medium)) + .font(.ft8afUI(size: 16, weight: .medium)) .foregroundStyle(textMuted) Text("Completed contacts will appear here") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textFaint) } .frame(maxWidth: .infinity) .padding(.vertical, 60) } + /// Upload every unsynced QSO to the enabled services, then toast the result. + private func runCatchUpSync() { + Task { + let s = appState.settings + let result = await OnlineLogService.shared.syncAll(appState: appState) + if result.attempted == 0 { + appState.toast.show("All QSOs already synced", icon: "checkmark.icloud") + return + } + var parts: [String] = [] + if s.cloudlogEnabled { parts.append("CL \(result.cloudlogOk)") } + if s.qrzLogbookEnabled { parts.append("QRZ \(result.qrzOk)") } + appState.toast.show( + "Synced \(parts.joined(separator: ", ")) of \(result.attempted)", + icon: "icloud.and.arrow.up") + } + } + private func importAdif(result: Result<[URL], Error>) { switch result { case .success(let urls): @@ -238,13 +286,13 @@ struct LogbookScreen: View { private var noResultsState: some View { VStack(spacing: 12) { Image(systemName: "magnifyingglass") - .font(.system(size: 40)) + .font(.ft8afUI(size: 40)) .foregroundStyle(textFaint) Text("No matches") - .font(.system(size: 16, weight: .medium)) + .font(.ft8afUI(size: 16, weight: .medium)) .foregroundStyle(textMuted) Text("No QSOs match \"\(searchText)\"") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textFaint) } .frame(maxWidth: .infinity) @@ -256,6 +304,8 @@ struct LogbookScreen: View { private struct LogbookRow: View { let record: QsoRecord + var showCloudlogChip = false + var showQrzChip = false var body: some View { HStack(spacing: 0) { @@ -265,15 +315,23 @@ private struct LogbookRow: View { .frame(width: 3, height: 36) .padding(.trailing, 10) - // Call + grid + // Call + grid + sync chips VStack(alignment: .leading, spacing: 2) { Text(record.call) - .font(.system(size: 14, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 14, weight: .bold)) .foregroundStyle(textPrimary) - if !record.gridsquare.isEmpty { - Text(record.gridsquare) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(textFaint) + HStack(spacing: 4) { + if !record.gridsquare.isEmpty { + Text(record.gridsquare) + .font(.ft8afMono(size: 10, weight: .medium)) + .foregroundStyle(textFaint) + } + if showCloudlogChip { + syncChip("CL", synced: record.syncedCloudlog, color: signal) + } + if showQrzChip { + syncChip("QRZ", synced: record.syncedQrz, color: statusConfirmed) + } } } @@ -282,7 +340,7 @@ private struct LogbookRow: View { // Band pill + freq VStack(spacing: 2) { Text(record.band) - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(bandColor(for: record.band)) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -292,7 +350,7 @@ private struct LogbookRow: View { ) if !record.freq.isEmpty { Text(record.freq) - .font(.system(size: 8, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 8, weight: .medium)) .foregroundStyle(textDim) } } @@ -300,7 +358,7 @@ private struct LogbookRow: View { // SNR Text(record.rstRcvd) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .semibold)) .foregroundStyle(snrColor) .frame(width: 30, alignment: .trailing) .padding(.trailing, 10) @@ -308,10 +366,10 @@ private struct LogbookRow: View { // Date/time VStack(alignment: .trailing, spacing: 1) { Text(formatDate(record.qsoDate)) - .font(.system(size: 10, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .medium)) .foregroundStyle(textFaint) Text(formatTime(record.timeOn)) - .font(.system(size: 10, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .medium)) .foregroundStyle(textFaint) } } @@ -325,6 +383,19 @@ private struct LogbookRow: View { } } + /// Tiny "uploaded to " badge — colored once the record is synced. + private func syncChip(_ label: String, synced: Bool, color: Color) -> some View { + Text(label) + .font(.ft8afMono(size: 8, weight: .bold)) + .foregroundStyle(synced ? color : textDim) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(synced ? color.opacity(0.14) : bgSurface2) + ) + } + private var snrColor: Color { guard let val = Int(record.rstRcvd.trimmingCharacters(in: CharacterSet(charactersIn: "+-").inverted)) ?? Int(record.rstRcvd) else { return textMuted } @@ -388,7 +459,7 @@ private struct QsoEditSheet: View { .foregroundStyle(textPrimary) Spacer() Text(record.band) - .font(.system(.body, design: .monospaced)) + .font(.ft8afMono(size: 17)) .foregroundStyle(textMuted) } HStack { @@ -396,7 +467,7 @@ private struct QsoEditSheet: View { .foregroundStyle(textPrimary) Spacer() Text(record.freq.isEmpty ? "—" : "\(record.freq) MHz") - .font(.system(.body, design: .monospaced)) + .font(.ft8afMono(size: 17)) .foregroundStyle(textMuted) } } header: { @@ -439,7 +510,7 @@ private struct QsoEditSheet: View { .foregroundStyle(textPrimary) Spacer() TextField("", text: text) - .font(.system(.body, design: .monospaced)) + .font(.ft8afMono(size: 17)) .multilineTextAlignment(.trailing) .foregroundStyle(textPrimary) .textInputAutocapitalization(.characters) diff --git a/ios/FT8AF/FT8AF/Screens/Logbook/LogbookStats.swift b/ios/FT8AF/FT8AF/Screens/Logbook/LogbookStats.swift index a2b35bb76..86bd35816 100644 --- a/ios/FT8AF/FT8AF/Screens/Logbook/LogbookStats.swift +++ b/ios/FT8AF/FT8AF/Screens/Logbook/LogbookStats.swift @@ -11,16 +11,16 @@ struct LogbookStats: View { HStack { VStack(alignment: .leading, spacing: 4) { Text("Total QSOs") - .font(.system(size: 11, weight: .medium)) + .font(.ft8afUI(size: 11, weight: .medium)) .foregroundStyle(textFaint) .textCase(.uppercase) Text("\(totalQsos)") - .font(.system(size: 28, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 28, weight: .bold)) .foregroundStyle(textPrimary) } Spacer() Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 24)) + .font(.ft8afUI(size: 24)) .foregroundStyle(accent.opacity(0.5)) } .padding(14) @@ -65,10 +65,10 @@ private struct BandStatCard: View { var body: some View { VStack(spacing: 4) { Text(band) - .font(.system(size: 11, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .bold)) .foregroundStyle(bandColor(for: band)) Text("\(count)") - .font(.system(size: 16, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 16, weight: .semibold)) .foregroundStyle(textPrimary) } .frame(maxWidth: .infinity) diff --git a/ios/FT8AF/FT8AF/Screens/Map/AzimuthalMapCanvas.swift b/ios/FT8AF/FT8AF/Screens/Map/AzimuthalMapCanvas.swift index cd0918015..f18b3b21d 100644 --- a/ios/FT8AF/FT8AF/Screens/Map/AzimuthalMapCanvas.swift +++ b/ios/FT8AF/FT8AF/Screens/Map/AzimuthalMapCanvas.swift @@ -53,7 +53,7 @@ struct AzimuthalMapCanvas: View { // Distance label on right side of ring let labelText = dist >= 1000 ? "\(Int(dist / 1000))k" : "\(Int(dist))" let text = context.resolve(Text(labelText) - .font(.system(size: 8, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 8, weight: .medium)) .foregroundStyle(textFaint)) let labelX = center.x + ringRadius + 2 let labelY = center.y - 5 @@ -82,7 +82,7 @@ struct AzimuthalMapCanvas: View { let lx = center.x + labelDist * CGFloat(sin(rad)) let ly = center.y - labelDist * CGFloat(cos(rad)) let compassText = context.resolve(Text(compassLabels[i]) - .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .semibold)) .foregroundStyle(i == 0 ? textMuted : textFaint)) context.draw(compassText, at: CGPoint(x: lx, y: ly), anchor: .center) } diff --git a/ios/FT8AF/FT8AF/Screens/Map/MapScreen.swift b/ios/FT8AF/FT8AF/Screens/Map/MapScreen.swift index c14f800f2..0f2198ac7 100644 --- a/ios/FT8AF/FT8AF/Screens/Map/MapScreen.swift +++ b/ios/FT8AF/FT8AF/Screens/Map/MapScreen.swift @@ -20,7 +20,7 @@ struct MapScreen: View { // Top bar HStack { Text("Map") - .font(.system(size: 18, weight: .bold)) + .font(.ft8afUI(size: 18, weight: .bold)) .foregroundStyle(textPrimary) Spacer() @@ -34,9 +34,9 @@ struct MapScreen: View { } label: { HStack(spacing: 4) { Image(systemName: projection == .equirectangular ? "map" : "globe") - .font(.system(size: 11, weight: .medium)) + .font(.ft8afUI(size: 11, weight: .medium)) Text(projection.rawValue) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .semibold)) } .foregroundStyle(textMuted) .padding(.horizontal, 8) @@ -50,7 +50,7 @@ struct MapScreen: View { .padding(.trailing, 8) Text("\(markers.count) stations") - .font(.system(size: 12, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: .medium)) .foregroundStyle(textMuted) } .padding(.horizontal, 16) @@ -145,26 +145,26 @@ struct MapScreen: View { private func markerPopup(_ marker: MapMarker) -> some View { VStack(spacing: 4) { Text(marker.callsign) - .font(.system(size: 14, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 14, weight: .bold)) .foregroundStyle(textPrimary) Text(marker.grid) - .font(.system(size: 11, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .medium)) .foregroundStyle(textMuted) HStack(spacing: 8) { if let snr = marker.snr { Text("SNR: \(snr >= 0 ? "+\(snr)" : "\(snr)")") - .font(.system(size: 10, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .medium)) .foregroundStyle(textFaint) } if let dist = markerDistance(marker) { Text(dist) - .font(.system(size: 10, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .medium)) .foregroundStyle(textFaint) } if let brg = markerBearing(marker) { Text(brg) - .font(.system(size: 10, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .medium)) .foregroundStyle(textFaint) } } @@ -238,7 +238,7 @@ private struct ZoomButton: View { var body: some View { Button(action: action) { Image(systemName: icon) - .font(.system(size: 13, weight: .semibold)) + .font(.ft8afUI(size: 13, weight: .semibold)) .foregroundStyle(textPrimary) .frame(width: 32, height: 32) .background( diff --git a/ios/FT8AF/FT8AF/Screens/POTA/PotaScreen.swift b/ios/FT8AF/FT8AF/Screens/POTA/PotaScreen.swift index 295c9a067..9f809dd0a 100644 --- a/ios/FT8AF/FT8AF/Screens/POTA/PotaScreen.swift +++ b/ios/FT8AF/FT8AF/Screens/POTA/PotaScreen.swift @@ -1,4 +1,6 @@ +import FT8Engine import SwiftUI +import UIKit enum PotaTab: String, CaseIterable, Identifiable { case activate = "Activate" @@ -11,13 +13,17 @@ enum PotaTab: String, CaseIterable, Identifiable { struct PotaScreen: View { @Environment(AppState.self) private var appState @State private var selectedTab: PotaTab = .hunt + @State private var shareURLs: [URL] = [] + @State private var showShare = false + + private var spotService: PotaService { PotaService.shared } var body: some View { VStack(spacing: 0) { // Top bar HStack { Text("POTA") - .font(.system(size: 18, weight: .bold)) + .font(.ft8afUI(size: 18, weight: .bold)) .foregroundStyle(textPrimary) Spacer() @@ -28,7 +34,7 @@ struct PotaScreen: View { .fill(statusConfirmed) .frame(width: 6, height: 6) Text("ACTIVE") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(statusConfirmed) } } @@ -58,16 +64,40 @@ struct PotaScreen: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .background(bgApp) + .onAppear(perform: loadActivationsIfNeeded) + .task(id: selectedTab) { + // Auto-refresh the live spot feed every ~60 s while the Hunt tab is + // visible (Android's PotaSpotsRepository cadence). The task is + // cancelled automatically when the tab changes or the screen leaves. + guard selectedTab == .hunt else { return } + while !Task.isCancelled { + await spotService.refresh() + try? await Task.sleep(nanoseconds: 60_000_000_000) + } + } + .sheet(isPresented: $showShare) { + PotaShareSheet(items: shareURLs) + } + } + + // MARK: - Activation persistence + + private func loadActivationsIfNeeded() { + guard !appState.pota.activationsLoaded else { return } + appState.pota.activations = PotaActivationStore.load() + appState.pota.activationsLoaded = true + } + + private func persistActivations() { + PotaActivationStore.save(appState.pota.activations) } // MARK: - Activate Tab @ViewBuilder private var activateTab: some View { - let pota = appState.pota - - if pota.isActivating { - activeSessionView(pota: pota) + if let current = appState.pota.current { + activeSessionView(current: current) } else { startActivationView } @@ -77,13 +107,13 @@ struct PotaScreen: View { VStack(spacing: 16) { Spacer() Image(systemName: "tree.fill") - .font(.system(size: 40)) + .font(.ft8afUI(size: 40)) .foregroundStyle(statusConfirmed.opacity(0.5)) Text("Park Activation") - .font(.system(size: 16, weight: .semibold)) + .font(.ft8afUI(size: 16, weight: .semibold)) .foregroundStyle(textPrimary) - Text("Enter a POTA park reference (e.g. K-1234)\nto start an activation session") - .font(.system(size: 13)) + Text("Enter a POTA park reference (e.g. US-1234)\nto start an activation session") + .font(.ft8afUI(size: 13)) .foregroundStyle(textMuted) .multilineTextAlignment(.center) @@ -91,10 +121,10 @@ struct PotaScreen: View { VStack(spacing: 8) { HStack { Text("Park Ref:") - .font(.system(size: 13, weight: .medium)) + .font(.ft8afUI(size: 13, weight: .medium)) .foregroundStyle(textMuted) - TextField("K-0000", text: Bindable(appState.pota).parkInput) - .font(.system(size: 14, weight: .medium, design: .monospaced)) + TextField("US-0000", text: Bindable(appState.pota).parkInput) + .font(.ft8afMono(size: 14, weight: .medium)) .foregroundStyle(textPrimary) .padding(.horizontal, 10) .padding(.vertical, 8) @@ -110,10 +140,10 @@ struct PotaScreen: View { // Notes field HStack { Text("Notes:") - .font(.system(size: 13, weight: .medium)) + .font(.ft8afUI(size: 13, weight: .medium)) .foregroundStyle(textMuted) TextField("Optional notes", text: Bindable(appState.pota).notes) - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textPrimary) .padding(.horizontal, 10) .padding(.vertical, 8) @@ -129,61 +159,65 @@ struct PotaScreen: View { startActivation() } label: { Text("Start Activation") - .font(.system(size: 14, weight: .bold)) + .font(.ft8afUI(size: 14, weight: .bold)) .foregroundStyle(bgApp) .frame(width: 200, height: 44) .background( RoundedRectangle(cornerRadius: 10) - .fill(appState.pota.parkInput.isEmpty ? textFaint : statusConfirmed) + .fill(trimmedParkInput.isEmpty ? textFaint : statusConfirmed) ) } .buttonStyle(.plain) - .disabled(appState.pota.parkInput.isEmpty) + .disabled(trimmedParkInput.isEmpty) Spacer() } } - private func activeSessionView(pota: PotaState) -> some View { - VStack(spacing: 16) { + private func activeSessionView(current: PotaActivationRecord) -> some View { + let qsoCount = activationQsoCount(current) + + return VStack(spacing: 16) { // Activation card VStack(spacing: 12) { HStack { Image(systemName: "tree.fill") - .font(.system(size: 16)) + .font(.ft8afUI(size: 16)) .foregroundStyle(statusConfirmed) - Text(pota.parkInput) - .font(.system(size: 18, weight: .bold, design: .monospaced)) + Text(current.parkRefsDisplay) + .font(.ft8afMono(size: 18, weight: .bold)) .foregroundStyle(statusConfirmed) Spacer() } - if !pota.notes.isEmpty { - Text(pota.notes) - .font(.system(size: 12)) + if !current.notes.isEmpty { + Text(current.notes) + .font(.ft8afUI(size: 12)) .foregroundStyle(textMuted) .frame(maxWidth: .infinity, alignment: .leading) } Divider().overlay(borderSubtle) - // Stats row - HStack(spacing: 0) { - activationStat( - label: "QSOs", - value: "\(pota.activationQsoCount)", - color: pota.activationQsoCount >= 10 ? statusConfirmed : accent - ) - activationStat( - label: "Needed", - value: "\(max(0, 10 - pota.activationQsoCount))", - color: pota.activationQsoCount >= 10 ? statusConfirmed : statusWarn - ) - activationStat( - label: "Elapsed", - value: elapsedString(from: pota.activationStartTime), - color: textMuted - ) + // Stats row (elapsed ticks once a second) + TimelineView(.periodic(from: .now, by: 1)) { context in + HStack(spacing: 0) { + activationStat( + label: "QSOs", + value: "\(qsoCount)", + color: qsoCount >= 10 ? statusConfirmed : accent + ) + activationStat( + label: "Needed", + value: "\(max(0, 10 - qsoCount))", + color: qsoCount >= 10 ? statusConfirmed : statusWarn + ) + activationStat( + label: "Elapsed", + value: elapsedString(startedAtMs: current.startedAtMs, now: context.date), + color: textMuted + ) + } } // Progress bar to 10 QSOs @@ -193,15 +227,15 @@ struct PotaScreen: View { RoundedRectangle(cornerRadius: 3) .fill(bgSurface3) RoundedRectangle(cornerRadius: 3) - .fill(pota.activationQsoCount >= 10 ? statusConfirmed : accent) - .frame(width: geo.size.width * min(CGFloat(pota.activationQsoCount) / 10.0, 1.0)) + .fill(qsoCount >= 10 ? statusConfirmed : accent) + .frame(width: geo.size.width * min(CGFloat(qsoCount) / 10.0, 1.0)) } } .frame(height: 6) - Text(pota.activationQsoCount >= 10 ? "Activation complete!" : "\(pota.activationQsoCount)/10 for valid activation") - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(pota.activationQsoCount >= 10 ? statusConfirmed : textFaint) + Text(qsoCount >= 10 ? "Activation complete!" : "\(qsoCount)/10 for valid activation") + .font(.ft8afMono(size: 10, weight: .medium)) + .foregroundStyle(qsoCount >= 10 ? statusConfirmed : textFaint) } } .padding(16) @@ -215,12 +249,38 @@ struct PotaScreen: View { ) .padding(.horizontal, 16) + // Export ADIF (share sheet) — one file per park, MY_SIG_INFO pinned. + Button { + exportAdif(for: current) + } label: { + HStack(spacing: 6) { + Image(systemName: "square.and.arrow.up") + .font(.ft8afUI(size: 13, weight: .semibold)) + Text("Export ADIF") + .font(.ft8afUI(size: 14, weight: .bold)) + } + .foregroundStyle(qsoCount > 0 ? signal : textFaint) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(signal.opacity(qsoCount > 0 ? 0.14 : 0.05)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(signal.opacity(qsoCount > 0 ? 0.3 : 0.1), lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + .disabled(qsoCount == 0) + .padding(.horizontal, 16) + // End activation button Button { endActivation() } label: { Text("End Activation") - .font(.system(size: 14, weight: .bold)) + .font(.ft8afUI(size: 14, weight: .bold)) .foregroundStyle(statusBad) .frame(maxWidth: .infinity) .frame(height: 44) @@ -244,32 +304,58 @@ struct PotaScreen: View { private func activationStat(label: String, value: String, color: Color) -> some View { VStack(spacing: 4) { Text(value) - .font(.system(size: 22, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 22, weight: .bold)) .foregroundStyle(color) Text(label) - .font(.system(size: 10, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .medium)) .foregroundStyle(textFaint) } .frame(maxWidth: .infinity) } + private var trimmedParkInput: String { + appState.pota.parkInput.trimmingCharacters(in: .whitespaces) + } + + /// Live QSO count for an activation, derived from the logbook records that + /// fall inside its time window (the same `PotaQsoWindow` scoping the ADIF + /// export uses). Reading `logbook.records` here means Observation re-renders + /// this screen whenever a QSO is logged — no manual increments to miss. + private func activationQsoCount(_ activation: PotaActivationRecord) -> Int { + PotaQsoWindow.qsoCount( + records: appState.logbook.records, + startedAtMs: activation.startedAtMs, + endedAtMs: activation.endedAtMs + ) + } + private func startActivation() { - appState.pota.isActivating = true - appState.pota.activationQsoCount = 0 - appState.pota.activationStartTime = Date() - if !appState.pota.parkRefs.contains(appState.pota.parkInput) { - appState.pota.parkRefs.append(appState.pota.parkInput) - } + let parkRef = trimmedParkInput.uppercased() + guard !parkRef.isEmpty, appState.pota.current == nil else { return } + let record = PotaActivationRecord( + parkRef: parkRef, + notes: appState.pota.notes.trimmingCharacters(in: .whitespaces), + startedAtMs: Int64(Date().timeIntervalSince1970 * 1000) + ) + appState.pota.activations.append(record) + persistActivations() + appState.pota.parkInput = "" + appState.pota.notes = "" } private func endActivation() { - appState.pota.isActivating = false - appState.pota.activationStartTime = nil + guard let current = appState.pota.current, + let idx = appState.pota.activations.firstIndex(where: { $0.id == current.id }) + else { return } + appState.pota.activations[idx].endedAtMs = Int64(Date().timeIntervalSince1970 * 1000) + // Freeze the derived count into the record for the History list. + appState.pota.activations[idx].qsoCount = activationQsoCount(appState.pota.activations[idx]) + persistActivations() } - private func elapsedString(from date: Date?) -> String { - guard let date else { return "0:00" } - let elapsed = Int(Date().timeIntervalSince(date)) + private func elapsedString(startedAtMs: Int64, now: Date) -> String { + let start = Date(timeIntervalSince1970: Double(startedAtMs) / 1000.0) + let elapsed = max(0, Int(now.timeIntervalSince(start))) let hrs = elapsed / 3600 let mins = (elapsed % 3600) / 60 if hrs > 0 { @@ -278,64 +364,185 @@ struct PotaScreen: View { return "\(mins)m" } + // MARK: - ADIF export + + /// Build the per-park ADIF documents for an activation and hand them to the + /// system share sheet. Multi-park ("two-fer") activations share one file per + /// park, each with MY_SIG_INFO pinned to that single park. + private func exportAdif(for activation: PotaActivationRecord) { + let docs = Adif.potaActivationExport( + records: appState.logbook.records, activation: activation) + guard !docs.isEmpty else { + appState.toast.show("No QSOs in this activation yet", icon: "tree") + return + } + var urls: [URL] = [] + for doc in docs { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(doc.filename) + do { + try Data(doc.content.utf8).write(to: url, options: .atomic) + urls.append(url) + } catch { + appState.toast.show("Export failed", icon: "exclamationmark.triangle") + return + } + } + shareURLs = urls + showShare = true + } + // MARK: - Hunt Tab private var huntTab: some View { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(PotaSpotMock.spots) { spot in - PotaSpotRow(spot: spot) + VStack(spacing: 0) { + // Header: spot count + FT8-only toggle + HStack { + Text(huntHeaderText) + .font(.ft8afUI(size: 12)) + .foregroundStyle(textMuted) + Spacer() + Button { + spotService.ft8Only.toggle() + } label: { + Text("FT8 ONLY") + .font(.ft8afMono(size: 10, weight: .bold)) + .foregroundStyle(spotService.ft8Only ? accent : textFaint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(spotService.ft8Only ? accent.opacity(0.14) : bgSurface2) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder( + spotService.ft8Only ? accent.opacity(0.4) : borderSubtle, + lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + + if let error = spotService.lastError { + HStack(spacing: 6) { + Image(systemName: "wifi.exclamationmark") + .font(.ft8afUI(size: 11)) + Text(error) + .font(.ft8afUI(size: 11, weight: .medium)) + Spacer() } + .foregroundStyle(statusWarn) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(statusWarn.opacity(0.08)) } - .padding(.bottom, 100) + + if spotService.displaySpots.isEmpty { + VStack(spacing: 12) { + Spacer() + if spotService.isLoading && spotService.lastUpdatedAt == nil { + ProgressView() + .tint(accent) + Text("Loading live spots…") + .font(.ft8afUI(size: 13)) + .foregroundStyle(textMuted) + } else { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.ft8afUI(size: 40)) + .foregroundStyle(textFaint) + Text("No active spots") + .font(.ft8afUI(size: 16, weight: .medium)) + .foregroundStyle(textMuted) + Text("Live POTA activator spots will appear here") + .font(.ft8afUI(size: 13)) + .foregroundStyle(textFaint) + } + Spacer() + } + } else { + // List (not ScrollView) so pull-to-refresh works natively. + TimelineView(.periodic(from: .now, by: 30)) { context in + List { + ForEach(spotService.displaySpots) { spot in + PotaSpotRow(spot: spot, now: context.date) { + targetSpot(spot) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(bgApp) + .listRowSeparator(.hidden) + } + // Bottom spacer so the TX strip doesn't cover the last row. + Color.clear + .frame(height: 100) + .listRowBackground(bgApp) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { + await spotService.refresh() + } + } + } + } + } + + private var huntHeaderText: String { + let n = spotService.displaySpots.count + if n == 0 { return spotService.lastUpdatedAt == nil ? "Live POTA spots" : "No active spots" } + return "\(n) active spot\(n == 1 ? "" : "s")" + } + + /// Tap-to-hunt: for an FT8 spot on a known band, switch the app's band the + /// same way the TX strip's band chip does (settings.band + persist — the + /// engine reads the dial from settings), then confirm with a toast. Non-FT8 + /// or out-of-band spots just get an informational toast (the operator tunes + /// manually, as on Android). + private func targetSpot(_ spot: PotaSpot) { + UISelectionFeedbackGenerator().selectionChanged() + let band = PotaSpots.band(forFrequencyKhz: spot.frequencyKhz) + if spot.isFT8, let band { + if appState.settings.band != band { + appState.settings.band = band + SettingsPersistence.save(appState.settings) + } + appState.toast.show( + "Targeting \(spot.activator) · \(band) (\(spot.reference))", + icon: "scope") + } else { + appState.toast.show( + "\(spot.activator) at \(spot.frequencyText) \(spot.mode) — tune manually", + icon: "dot.radiowaves.left.and.right") } } // MARK: - History Tab private var historyTab: some View { - Group { - if appState.pota.parkRefs.isEmpty { + let history = potaHistoryForDisplay(appState.pota.activations) + return Group { + if history.isEmpty { VStack(spacing: 12) { Spacer() Image(systemName: "clock.arrow.circlepath") - .font(.system(size: 40)) + .font(.ft8afUI(size: 40)) .foregroundStyle(textFaint) Text("No activations yet") - .font(.system(size: 16, weight: .medium)) + .font(.ft8afUI(size: 16, weight: .medium)) .foregroundStyle(textMuted) Text("Past POTA activations will appear here") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textFaint) Spacer() } } else { ScrollView { LazyVStack(spacing: 0) { - ForEach(appState.pota.parkRefs, id: \.self) { ref in - HStack { - Image(systemName: "tree.fill") - .font(.system(size: 12)) - .foregroundStyle(statusConfirmed.opacity(0.6)) - - Text(ref) - .font(.system(size: 14, weight: .bold, design: .monospaced)) - .foregroundStyle(textPrimary) - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(textFaint) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(bgApp) - .overlay(alignment: .bottom) { - Rectangle() - .fill(borderSubtle) - .frame(height: 1) - } + ForEach(history) { activation in + historyRow(activation) } } .padding(.bottom, 100) @@ -343,66 +550,41 @@ struct PotaScreen: View { } } } -} - -// MARK: - Mock data - -struct PotaSpotMock: Identifiable { - let id = UUID() - let activator: String - let reference: String - let parkName: String - let freqKhz: Int - let mode: String - let spotTime: String - - static let spots: [PotaSpotMock] = [ - PotaSpotMock(activator: "W1AW", reference: "K-0001", parkName: "Acadia NP", freqKhz: 14074, mode: "FT8", spotTime: "12:28"), - PotaSpotMock(activator: "KD2OGR", reference: "K-1234", parkName: "Harriman SP", freqKhz: 7074, mode: "FT8", spotTime: "12:25"), - PotaSpotMock(activator: "N5TIM", reference: "K-0042", parkName: "Big Bend NP", freqKhz: 14074, mode: "FT8", spotTime: "12:22"), - PotaSpotMock(activator: "VE3XYZ", reference: "VE-0456", parkName: "Algonquin PP", freqKhz: 21074, mode: "FT8", spotTime: "12:18"), - PotaSpotMock(activator: "DL4RCK", reference: "DL-0123", parkName: "Black Forest NP", freqKhz: 14074, mode: "FT8", spotTime: "12:15"), - ] -} -private struct PotaSpotRow: View { - let spot: PotaSpotMock + private func historyRow(_ activation: PotaActivationRecord) -> some View { + HStack(spacing: 10) { + Image(systemName: "tree.fill") + .font(.ft8afUI(size: 12)) + .foregroundStyle(statusConfirmed.opacity(0.6)) - var body: some View { - HStack(spacing: 0) { - // Activator + park VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(spot.activator) - .font(.system(size: 13, weight: .bold, design: .monospaced)) - .foregroundStyle(textPrimary) - Text(spot.reference) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(statusConfirmed) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3) - .fill(statusConfirmed.opacity(0.14)) - ) - } - Text(spot.parkName) - .font(.system(size: 11)) - .foregroundStyle(textMuted) - .lineLimit(1) + Text(activation.parkRefsDisplay) + .font(.ft8afMono(size: 14, weight: .bold)) + .foregroundStyle(textPrimary) + Text(historyDateText(activation)) + .font(.ft8afUI(size: 11)) + .foregroundStyle(textFaint) } Spacer() - // Frequency + time - VStack(alignment: .trailing, spacing: 2) { - Text("\(spot.freqKhz) kHz") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(textMuted) - Text(spot.spotTime) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(textFaint) + Text("\(activation.qsoCount) QSO\(activation.qsoCount == 1 ? "" : "s")") + .font(.ft8afMono(size: 11, weight: .semibold)) + .foregroundStyle(activation.qsoCount >= 10 ? statusConfirmed : textMuted) + + Button { + exportAdif(for: activation) + } label: { + Image(systemName: "square.and.arrow.up") + .font(.ft8afUI(size: 14, weight: .semibold)) + .foregroundStyle(signal) + .frame(width: 34, height: 34) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(signal.opacity(0.1)) + ) } + .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 10) @@ -413,4 +595,119 @@ private struct PotaSpotRow: View { .frame(height: 1) } } + + private func historyDateText(_ activation: PotaActivationRecord) -> String { + let start = Date(timeIntervalSince1970: Double(activation.startedAtMs) / 1000.0) + return start.formatted(date: .abbreviated, time: .shortened) + } +} + +// MARK: - Spot row + +private struct PotaSpotRow: View { + let spot: PotaSpot + let now: Date + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(alignment: .top, spacing: 0) { + // Activator + park + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(spot.activator) + .font(.ft8afMono(size: 13, weight: .bold)) + .foregroundStyle(textPrimary) + Text(spot.reference) + .font(.ft8afMono(size: 11, weight: .semibold)) + .foregroundStyle(statusConfirmed) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(statusConfirmed.opacity(0.14)) + ) + if !spot.isFT8 { + Text(spot.mode) + .font(.ft8afMono(size: 10, weight: .semibold)) + .foregroundStyle(textFaint) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(bgSurface3) + ) + } + } + if !parkLine.isEmpty { + Text(parkLine) + .font(.ft8afUI(size: 11)) + .foregroundStyle(textMuted) + .lineLimit(1) + } + if !spot.comments.isEmpty { + Text("\u{201C}\(spot.comments)\u{201D}") + .font(.ft8afUI(size: 11)) + .foregroundStyle(textFaint) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + // Frequency + age + spotter + VStack(alignment: .trailing, spacing: 2) { + Text(spot.frequencyText) + .font(.ft8afMono(size: 12, weight: .medium)) + .foregroundStyle(accent) + Text(ageText) + .font(.ft8afMono(size: 10, weight: .medium)) + .foregroundStyle(textFaint) + if !spot.spotter.isEmpty { + Text("de \(spot.spotter)") + .font(.ft8afMono(size: 9)) + .foregroundStyle(textFaint) + .lineLimit(1) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(bgApp) + .overlay(alignment: .bottom) { + Rectangle() + .fill(borderSubtle) + .frame(height: 1) + } + } + + private var parkLine: String { + var line = spot.parkName + if !spot.locationDesc.isEmpty { + line += line.isEmpty ? spot.locationDesc : " · \(spot.locationDesc)" + } + return line + } + + private var ageText: String { + guard let secs = PotaSpots.ageSeconds(spotTimeUtc: spot.spotTimeUtc, now: now) else { + return "" + } + return relativeAge(secondsAgo: secs) + } +} + +// MARK: - Share sheet + +private struct PotaShareSheet: UIViewControllerRepresentable { + let items: [URL] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } diff --git a/ios/FT8AF/FT8AF/Screens/Settings/AboutScreen.swift b/ios/FT8AF/FT8AF/Screens/Settings/AboutScreen.swift index 7fe9a0fc6..d527e5876 100644 --- a/ios/FT8AF/FT8AF/Screens/Settings/AboutScreen.swift +++ b/ios/FT8AF/FT8AF/Screens/Settings/AboutScreen.swift @@ -10,15 +10,15 @@ struct AboutScreen: View { Section { VStack(spacing: 12) { Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 40)) + .font(.ft8afUI(size: 40)) .foregroundStyle(accent) Text("FT8AF") - .font(.system(size: 22, weight: .bold)) + .font(.ft8afUI(size: 22, weight: .bold)) .foregroundStyle(textPrimary) Text("FT8 for Amateur Radio") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(textMuted) } .frame(maxWidth: .infinity) @@ -50,7 +50,7 @@ struct AboutScreen: View { .foregroundStyle(textPrimary) Spacer() Image(systemName: "chevron.right") - .font(.system(size: 12)) + .font(.ft8afUI(size: 12)) .foregroundStyle(textFaint) } } @@ -74,13 +74,13 @@ struct AboutScreen: View { Section { VStack(alignment: .leading, spacing: 8) { Text("Based on FT8CN by bg7yoz") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textMuted) Text("FT8 protocol by K9AN & K1JT") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textMuted) Text("Open source under GPL-3.0") - .font(.system(size: 13)) + .font(.ft8afUI(size: 13)) .foregroundStyle(textFaint) } .padding(.vertical, 4) @@ -105,7 +105,7 @@ struct AboutScreen: View { .foregroundStyle(textPrimary) Spacer() Text(value) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textMuted) } } @@ -143,7 +143,7 @@ private struct DebugLogView: View { NavigationStack { ScrollView { Text(content) - .font(.system(size: 11, design: .monospaced)) + .font(.ft8afMono(size: 11)) .foregroundStyle(textPrimary) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/ios/FT8AF/FT8AF/Screens/Settings/AdvancedSettings.swift b/ios/FT8AF/FT8AF/Screens/Settings/AdvancedSettings.swift index 87b0f79e2..d4bf38e8f 100644 --- a/ios/FT8AF/FT8AF/Screens/Settings/AdvancedSettings.swift +++ b/ios/FT8AF/FT8AF/Screens/Settings/AdvancedSettings.swift @@ -15,7 +15,7 @@ struct AdvancedSettings: View { Text("PTT Delay") .foregroundStyle(textPrimary) Text("Delay before audio starts after PTT key") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } Spacer() @@ -35,19 +35,19 @@ struct AdvancedSettings: View { Text("TX Delay") .foregroundStyle(textPrimary) Text("Extra delay before TX audio playback") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } Spacer() HStack(spacing: 4) { TextField("0", value: $settings.txDelayMs, format: .number) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textPrimary) .multilineTextAlignment(.trailing) .keyboardType(.numberPad) .frame(width: 60) Text("ms") - .font(.system(size: 12)) + .font(.ft8afUI(size: 12)) .foregroundStyle(textFaint) } } @@ -57,27 +57,27 @@ struct AdvancedSettings: View { VStack(alignment: .leading, spacing: 2) { Text("Late Start Tolerance") .foregroundStyle(textPrimary) - Text("Max ms into cycle before skipping TX") - .font(.system(size: 11)) + Text("Max ms clipped from a late TX before skipping it") + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } Spacer() HStack(spacing: 4) { TextField("2360", value: $settings.lateStartToleranceMs, format: .number) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textPrimary) .multilineTextAlignment(.trailing) .keyboardType(.numberPad) .frame(width: 60) Text("ms") - .font(.system(size: 12)) + .font(.ft8afUI(size: 12)) .foregroundStyle(textFaint) } } } header: { Text("Timing").foregroundStyle(textMuted) } footer: { - Text("PTT delay compensates for rig keying latency. Late start tolerance sets how far into a 15-second cycle TX can still start (default 2360 ms = FT8 message duration).") + Text("PTT delay compensates for rig keying latency. A TX starting more than 2.36 s into the 15-second cycle has its leading audio clipped so it still ends on time; late start tolerance is the most clipped audio allowed before the transmission is skipped for that cycle (0 = skip any clipped start).") .foregroundStyle(textFaint) } .listRowBackground(bgSurface) @@ -88,7 +88,7 @@ struct AdvancedSettings: View { VStack(alignment: .leading, spacing: 2) { Text("Enable UDP broadcast").foregroundStyle(textPrimary) Text("Send decodes, status & QSOs to companion apps") - .font(.system(size: 11)).foregroundStyle(textFaint) + .font(.ft8afUI(size: 11)).foregroundStyle(textFaint) } } .tint(accent) @@ -97,7 +97,7 @@ struct AdvancedSettings: View { Text("Server host").foregroundStyle(textPrimary) Spacer() TextField("127.0.0.1", text: $settings.udpHost) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textPrimary) .multilineTextAlignment(.trailing) .autocorrectionDisabled() @@ -110,7 +110,7 @@ struct AdvancedSettings: View { Text("Server port").foregroundStyle(textPrimary) Spacer() TextField("2237", value: $settings.udpPort, format: .number) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textPrimary) .multilineTextAlignment(.trailing) .keyboardType(.numberPad) @@ -122,7 +122,7 @@ struct AdvancedSettings: View { VStack(alignment: .leading, spacing: 2) { Text("Accept UDP requests").foregroundStyle(textPrimary) Text("Let companion apps reply / halt / free-text") - .font(.system(size: 11)).foregroundStyle(textFaint) + .font(.ft8afUI(size: 11)).foregroundStyle(textFaint) } } .tint(accent) diff --git a/ios/FT8AF/FT8AF/Screens/Settings/BlockedCallsignsSettings.swift b/ios/FT8AF/FT8AF/Screens/Settings/BlockedCallsignsSettings.swift index 2d5160093..5b34eeb5d 100644 --- a/ios/FT8AF/FT8AF/Screens/Settings/BlockedCallsignsSettings.swift +++ b/ios/FT8AF/FT8AF/Screens/Settings/BlockedCallsignsSettings.swift @@ -10,7 +10,7 @@ struct BlockedCallsignsSettings: View { Section { HStack(spacing: 8) { TextField("Enter callsign", text: $newCallsign) - .font(.system(size: 14, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 14, weight: .medium)) .foregroundStyle(textPrimary) .textInputAutocapitalization(.characters) .autocorrectionDisabled() @@ -19,7 +19,7 @@ struct BlockedCallsignsSettings: View { addCallsign() } label: { Image(systemName: "plus.circle.fill") - .font(.system(size: 20)) + .font(.ft8afUI(size: 20)) .foregroundStyle(newCallsign.isEmpty ? textFaint : accent) } .buttonStyle(.plain) @@ -39,14 +39,14 @@ struct BlockedCallsignsSettings: View { ForEach(appState.settings.blockedCallsigns, id: \.self) { call in HStack { Text(call) - .font(.system(size: 14, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 14, weight: .bold)) .foregroundStyle(textPrimary) Spacer() Button { removeCallsign(call) } label: { Image(systemName: "xmark.circle") - .font(.system(size: 16)) + .font(.ft8afUI(size: 16)) .foregroundStyle(statusBad) } .buttonStyle(.plain) diff --git a/ios/FT8AF/FT8AF/Screens/Settings/DecodeFilterSettings.swift b/ios/FT8AF/FT8AF/Screens/Settings/DecodeFilterSettings.swift index 88be760a2..2133b2337 100644 --- a/ios/FT8AF/FT8AF/Screens/Settings/DecodeFilterSettings.swift +++ b/ios/FT8AF/FT8AF/Screens/Settings/DecodeFilterSettings.swift @@ -3,6 +3,23 @@ import SwiftUI struct DecodeFilterSettings: View { @Environment(AppState.self) private var appState + /// Continent picker options: "All" plus the seven continent codes used by the + /// decode filter (matches Android's continent codes). + private static let continentOptions = ["All", "NA", "SA", "EU", "AF", "AS", "OC", "AN"] + + private func continentName(_ code: String) -> String { + switch code { + case "NA": return "North America" + case "SA": return "South America" + case "EU": return "Europe" + case "AF": return "Africa" + case "AS": return "Asia" + case "OC": return "Oceania" + case "AN": return "Antarctica" + default: return "All" + } + } + var body: some View { @Bindable var settings = appState.settings @@ -13,7 +30,7 @@ struct DecodeFilterSettings: View { Text("Show Only CQ") .foregroundStyle(textPrimary) Text("Hide QSO traffic, show only CQ calls") - .font(.system(size: 12)) + .font(.ft8afUI(size: 12)) .foregroundStyle(textFaint) } } @@ -23,12 +40,27 @@ struct DecodeFilterSettings: View { VStack(alignment: .leading, spacing: 2) { Text("DX Only") .foregroundStyle(textPrimary) - Text("Hide domestic stations, show only DX") - .font(.system(size: 12)) + Text("Hide stations on your own continent") + .font(.ft8afUI(size: 12)) .foregroundStyle(textFaint) } } .tint(accent) + + Picker(selection: $settings.continentFilter) { + ForEach(Self.continentOptions, id: \.self) { code in + Text(continentName(code)).tag(code) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Continent Filter") + .foregroundStyle(textPrimary) + Text("Show only stations from one continent") + .font(.ft8afUI(size: 12)) + .foregroundStyle(textFaint) + } + } + .tint(textMuted) } header: { Text("Filters") .foregroundStyle(textMuted) @@ -36,33 +68,57 @@ struct DecodeFilterSettings: View { .listRowBackground(bgSurface) Section { - highlightRow("New DXCC", description: "Purple highlight for unworked DXCC entities", color: statusNew) - highlightRow("New Grid", description: "Highlight for unworked grid squares", color: signal) - highlightRow("POTA Activators", description: "Green pill for spotted park activators", color: statusConfirmed) - highlightRow("Worked Stations", description: "Cyan indicator for previously worked calls", color: statusWorked) + highlightToggle( + "New DXCC", description: "Purple pill for unworked DXCC entities", + color: statusNew, isOn: $settings.highlightNewDxcc + ) + highlightToggle( + "New Grid", description: "Yellow pill for unworked grid squares", + color: statusWarn, isOn: $settings.highlightNewGrid + ) + highlightToggle( + "New Band", description: "Cyan pill for stations never worked on this band", + color: signal, isOn: $settings.highlightNewBand + ) + highlightToggle( + "Worked Stations", description: "Cyan pill for previously worked calls", + color: statusWorked, isOn: $settings.highlightWorked + ) } header: { Text("Highlights") .foregroundStyle(textMuted) } footer: { - Text("Highlight rules take effect in Phase 4 when the logbook database is integrated") + Text("Highlights compare each decode against your logbook. Disabled categories fall through to the next in priority.") .foregroundStyle(textFaint) } .listRowBackground(bgSurface) Section { - HStack { - Text("Continent Filter") - .foregroundStyle(textPrimary) - Spacer() - Text("All") - .foregroundStyle(textMuted) + Picker(selection: $settings.distanceInMiles) { + Text("Kilometers").tag(false) + Text("Miles").tag(true) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Distance Unit") + .foregroundStyle(textPrimary) + Text("Unit for QSO-path distances in the decode list") + .font(.ft8afUI(size: 12)) + .foregroundStyle(textFaint) + } } - HStack { - Text("Blocked Callsigns") - .foregroundStyle(textPrimary) - Spacer() - Text("0") - .foregroundStyle(textMuted) + .tint(textMuted) + + NavigationLink { + BlockedCallsignsSettings() + } label: { + HStack { + Text("Blocked Callsigns") + .foregroundStyle(textPrimary) + Spacer() + Text("\(appState.settings.blockedCallsigns.count)") + .font(.ft8afMono(size: 14, weight: .medium)) + .foregroundStyle(textMuted) + } } } header: { Text("Advanced") @@ -77,20 +133,31 @@ struct DecodeFilterSettings: View { .toolbarColorScheme(.dark, for: .navigationBar) .onChange(of: settings.showOnlyCQ) { _, _ in SettingsPersistence.save(appState.settings) } .onChange(of: settings.dxOnly) { _, _ in SettingsPersistence.save(appState.settings) } + .onChange(of: settings.continentFilter) { _, _ in SettingsPersistence.save(appState.settings) } + .onChange(of: settings.highlightNewDxcc) { _, _ in SettingsPersistence.save(appState.settings) } + .onChange(of: settings.highlightNewGrid) { _, _ in SettingsPersistence.save(appState.settings) } + .onChange(of: settings.highlightNewBand) { _, _ in SettingsPersistence.save(appState.settings) } + .onChange(of: settings.highlightWorked) { _, _ in SettingsPersistence.save(appState.settings) } + .onChange(of: settings.distanceInMiles) { _, _ in SettingsPersistence.save(appState.settings) } } - private func highlightRow(_ title: String, description: String, color: Color) -> some View { - HStack(spacing: 10) { - Circle() - .fill(color) - .frame(width: 10, height: 10) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .foregroundStyle(textPrimary) - Text(description) - .font(.system(size: 12)) - .foregroundStyle(textFaint) + private func highlightToggle( + _ title: String, description: String, color: Color, isOn: Binding + ) -> some View { + Toggle(isOn: isOn) { + HStack(spacing: 10) { + Circle() + .fill(color) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .foregroundStyle(textPrimary) + Text(description) + .font(.ft8afUI(size: 12)) + .foregroundStyle(textFaint) + } } } + .tint(accent) } } diff --git a/ios/FT8AF/FT8AF/Screens/Settings/LoggingSettings.swift b/ios/FT8AF/FT8AF/Screens/Settings/LoggingSettings.swift new file mode 100644 index 000000000..937a3561b --- /dev/null +++ b/ios/FT8AF/FT8AF/Screens/Settings/LoggingSettings.swift @@ -0,0 +1,187 @@ +import FT8Engine +import SwiftUI + +/// Online logging integrations: Cloudlog/Wavelog server upload, QRZ.com +/// logbook upload, and PSK Reporter reception reports. Mirrors the Android +/// LoggingSettings groups. +struct LoggingSettings: View { + @Environment(AppState.self) private var appState + + @State private var testingCloudlog = false + @State private var testingQrz = false + @State private var cloudlogResult: Bool? + @State private var qrzResult: Bool? + + var body: some View { + @Bindable var settings = appState.settings + + List { + // MARK: Cloudlog / Wavelog + Section { + Toggle(isOn: $settings.cloudlogEnabled) { + Text("Upload QSOs") + .foregroundStyle(textPrimary) + } + .tint(accent) + + credentialField("Server URL", text: $settings.cloudlogUrl, + placeholder: "https://log.example.com/", + keyboard: .URL) + credentialField("API Key", text: $settings.cloudlogApiKey, + placeholder: "cl64ab…", secure: true) + credentialField("Station ID", text: $settings.cloudlogStationId, + placeholder: "1", keyboard: .numberPad) + + testConnectionRow( + isTesting: testingCloudlog, + result: cloudlogResult, + disabled: settings.cloudlogUrl.isEmpty || settings.cloudlogApiKey.isEmpty + ) { + testingCloudlog = true + cloudlogResult = nil + let addr = settings.cloudlogUrl + let key = settings.cloudlogApiKey + Task { + let ok = await OnlineLogService.testCloudlog( + serverAddress: addr, apiKey: key) + cloudlogResult = ok + testingCloudlog = false + appState.toast.show(ok ? "Cloudlog: Pass" : "Cloudlog: Fail", + icon: ok ? "checkmark.circle" : "xmark.circle") + } + } + } header: { + Text("Cloudlog").foregroundStyle(textMuted) + } footer: { + Text("Upload each logged QSO to a Cloudlog, Wavelog or Nextlog server. The API key needs read/write rights.") + .foregroundStyle(textFaint) + } + .listRowBackground(bgSurface) + + // MARK: QRZ logbook + Section { + Toggle(isOn: $settings.qrzLogbookEnabled) { + Text("Upload QSOs") + .foregroundStyle(textPrimary) + } + .tint(accent) + + credentialField("API Key", text: $settings.qrzLogbookApiKey, + placeholder: "ABCD-1234-…", secure: true) + + testConnectionRow( + isTesting: testingQrz, + result: qrzResult, + disabled: settings.qrzLogbookApiKey.isEmpty + ) { + testingQrz = true + qrzResult = nil + let key = settings.qrzLogbookApiKey + Task { + let ok = await OnlineLogService.testQrz(apiKey: key) + qrzResult = ok + testingQrz = false + appState.toast.show(ok ? "QRZ: Pass" : "QRZ: Fail", + icon: ok ? "checkmark.circle" : "xmark.circle") + } + } + } header: { + Text("QRZ Logbook").foregroundStyle(textMuted) + } footer: { + Text("Upload each logged QSO to your QRZ.com logbook. Use the Logbook API key from QRZ (requires an XML subscription), not your account password.") + .foregroundStyle(textFaint) + } + .listRowBackground(bgSurface) + + // MARK: PSK Reporter + Section { + Toggle(isOn: $settings.pskReporterEnabled) { + Text("Send reception reports") + .foregroundStyle(textPrimary) + } + .tint(accent) + } header: { + Text("PSK Reporter").foregroundStyle(textMuted) + } footer: { + Text("Report decoded stations to pskreporter.info every ~5 minutes so others can see your receive coverage. Requires your callsign and grid to be set.") + .foregroundStyle(textFaint) + } + .listRowBackground(bgSurface) + } + .scrollContentBackground(.hidden) + .background(bgApp) + .navigationTitle("Online Logging") + .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) + .onChange(of: settings.cloudlogEnabled) { _, _ in persist() } + .onChange(of: settings.cloudlogUrl) { _, _ in cloudlogResult = nil; persist() } + .onChange(of: settings.cloudlogApiKey) { _, _ in cloudlogResult = nil; persist() } + .onChange(of: settings.cloudlogStationId) { _, _ in persist() } + .onChange(of: settings.qrzLogbookEnabled) { _, _ in persist() } + .onChange(of: settings.qrzLogbookApiKey) { _, _ in qrzResult = nil; persist() } + .onChange(of: settings.pskReporterEnabled) { _, newValue in + persist() + // Opting out drops any queued spots and cancels the pending + // flush task right away — nothing may be sent after opt-out. + if !newValue { + OnlineLogService.shared.flushPsk(appState: appState) + } + } + } + + private func persist() { + SettingsPersistence.save(appState.settings) + } + + // MARK: - Rows + + private func credentialField( + _ label: String, text: Binding, placeholder: String, + secure: Bool = false, keyboard: UIKeyboardType = .asciiCapable + ) -> some View { + HStack { + Text(label) + .foregroundStyle(textPrimary) + Spacer() + Group { + if secure { + SecureField(placeholder, text: text) + } else { + TextField(placeholder, text: text) + .keyboardType(keyboard) + } + } + .font(.ft8afMono(size: 17)) + .multilineTextAlignment(.trailing) + .foregroundStyle(textPrimary) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + + private func testConnectionRow( + isTesting: Bool, result: Bool?, disabled: Bool, action: @escaping () -> Void + ) -> some View { + HStack { + Button(action: action) { + Text("Test Connection") + .foregroundStyle(disabled || isTesting ? textFaint : signal) + .fontWeight(.semibold) + } + .buttonStyle(.plain) + .disabled(disabled || isTesting) + + Spacer() + + if isTesting { + ProgressView() + .controlSize(.small) + .tint(accent) + } else if let result { + Text(result ? "Pass" : "Fail") + .font(.ft8afMono(size: 14, weight: .semibold)) + .foregroundStyle(result ? statusConfirmed : statusBad) + } + } + } +} diff --git a/ios/FT8AF/FT8AF/Screens/Settings/RadioAudioSettings.swift b/ios/FT8AF/FT8AF/Screens/Settings/RadioAudioSettings.swift index 2605bb2ca..e26dde0e0 100644 --- a/ios/FT8AF/FT8AF/Screens/Settings/RadioAudioSettings.swift +++ b/ios/FT8AF/FT8AF/Screens/Settings/RadioAudioSettings.swift @@ -4,6 +4,22 @@ private let allBands = ["160M","80M","40M","30M","20M","17M","15M","12M","10M"," struct RadioAudioSettings: View { @Environment(AppState.self) private var appState + @State private var audioRoutes = AudioRouteController() + + /// Selection binding for the input-device picker: reflects the session's + /// current input, and on change applies + persists the preference. + private var inputSelection: Binding { + Binding( + get: { audioRoutes.currentInputUid }, + set: { uid in + guard uid != audioRoutes.currentInputUid else { return } + if let portName = audioRoutes.selectInput(uid: uid) { + appState.settings.preferredInputPort = portName + SettingsPersistence.save(appState.settings) + } + } + ) + } var body: some View { @Bindable var settings = appState.settings @@ -25,13 +41,25 @@ struct RadioAudioSettings: View { // Audio input Section { + Picker("Input Device", selection: inputSelection) { + ForEach(audioRoutes.inputs) { input in + Text(input.label).tag(input.id) + } + } + .foregroundStyle(textPrimary) + .tint(accent) + HStack { - Text("Input Device") - .foregroundStyle(textPrimary) + VStack(alignment: .leading, spacing: 2) { + Text("Output Device") + .foregroundStyle(textPrimary) + Text(audioRoutes.currentOutputLabel) + .font(.ft8afUI(size: 11)) + .foregroundStyle(textMuted) + } Spacer() - Text("Built-in Microphone") - .font(.system(size: 14)) - .foregroundStyle(textMuted) + AudioRoutePickerButton() + .frame(width: 44, height: 32) } // Spectrum width @@ -40,12 +68,12 @@ struct RadioAudioSettings: View { Text("Spectrum Width") .foregroundStyle(textPrimary) Text("Decode bandwidth") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } Spacer() Picker("", selection: $settings.spectrumWidthHz) { - ForEach([2500, 3000, 4000, 5000], id: \.self) { hz in + ForEach([2500, 3000, 3500, 4000, 5000], id: \.self) { hz in Text("\(hz) Hz").tag(hz) } } @@ -66,7 +94,7 @@ struct RadioAudioSettings: View { .foregroundStyle(textPrimary) Spacer() Text("Not connected") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(textFaint) } Button { } label: { @@ -94,7 +122,7 @@ struct RadioAudioSettings: View { .fill(statusColor) .frame(width: 8, height: 8) Text(appState.rig.connectionStatus.rawValue) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textMuted) } } @@ -103,7 +131,7 @@ struct RadioAudioSettings: View { .foregroundStyle(textPrimary) Spacer() Text(formatFreq(appState.rig.currentFreqHz)) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textMuted) } } header: { @@ -133,11 +161,11 @@ struct RadioAudioSettings: View { .fill(bandColor(for: band)) .frame(width: 3, height: 18) Text(band) - .font(.system(size: 14, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 14, weight: .medium)) .foregroundStyle(textPrimary) Spacer() Text(bandFrequency(band)) - .font(.system(size: 12, design: .monospaced)) + .font(.ft8afMono(size: 12)) .foregroundStyle(textFaint) } } @@ -159,6 +187,8 @@ struct RadioAudioSettings: View { .toolbarColorScheme(.dark, for: .navigationBar) .onChange(of: settings.rigModel) { _, _ in SettingsPersistence.save(appState.settings) } .onChange(of: settings.spectrumWidthHz) { _, _ in SettingsPersistence.save(appState.settings) } + .onAppear { audioRoutes.start(preferredPortName: appState.settings.preferredInputPort) } + .onDisappear { audioRoutes.stop() } } private var statusColor: Color { diff --git a/ios/FT8AF/FT8AF/Screens/Settings/SettingsScreen.swift b/ios/FT8AF/FT8AF/Screens/Settings/SettingsScreen.swift index b90f11210..886069e86 100644 --- a/ios/FT8AF/FT8AF/Screens/Settings/SettingsScreen.swift +++ b/ios/FT8AF/FT8AF/Screens/Settings/SettingsScreen.swift @@ -30,14 +30,14 @@ struct SettingsScreen: View { } label: { HStack { Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(accent) .frame(width: 24) Text("Radio & Audio") .foregroundStyle(textPrimary) Spacer() Text(settings.rigModel.rawValue) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textMuted) } } @@ -54,14 +54,14 @@ struct SettingsScreen: View { } label: { HStack { Image(systemName: "bolt.horizontal") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(accent) .frame(width: 24) Text("Transmission") .foregroundStyle(textPrimary) Spacer() Text("\(settings.txPowerWatts)W / \(settings.pttMode.rawValue)") - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textMuted) } } @@ -78,7 +78,7 @@ struct SettingsScreen: View { } label: { HStack { Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(accent) .frame(width: 24) Text("Decode Filters") @@ -91,14 +91,14 @@ struct SettingsScreen: View { } label: { HStack { Image(systemName: "hand.raised") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(accent) .frame(width: 24) Text("Blocked Callsigns") .foregroundStyle(textPrimary) Spacer() Text("\(settings.blockedCallsigns.count)") - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textMuted) } } @@ -110,10 +110,27 @@ struct SettingsScreen: View { // Logging section Section { + NavigationLink { + LoggingSettings() + } label: { + HStack { + Image(systemName: "icloud.and.arrow.up") + .font(.ft8afUI(size: 14)) + .foregroundStyle(accent) + .frame(width: 24) + Text("Online Logging") + .foregroundStyle(textPrimary) + Spacer() + Text(onlineLoggingSummary) + .font(.ft8afMono(size: 14)) + .foregroundStyle(textMuted) + } + } + Toggle(isOn: $settings.autoLog) { HStack { Image(systemName: "book") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(accent) .frame(width: 24) Text("Auto-log QSOs") @@ -134,7 +151,7 @@ struct SettingsScreen: View { } label: { HStack { Image(systemName: "gearshape.2") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(accent) .frame(width: 24) Text("Advanced") @@ -154,14 +171,14 @@ struct SettingsScreen: View { } label: { HStack { Image(systemName: "info.circle") - .font(.system(size: 14)) + .font(.ft8afUI(size: 14)) .foregroundStyle(accent) .frame(width: 24) Text("About") .foregroundStyle(textPrimary) Spacer() Text(appVersion) - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textFaint) } } @@ -187,6 +204,17 @@ struct SettingsScreen: View { } } + /// Short summary of enabled online-logging services for the nav row. + private var onlineLoggingSummary: String { + let s = appState.settings + let parts = [ + s.cloudlogEnabled ? "CL" : nil, + s.qrzLogbookEnabled ? "QRZ" : nil, + s.pskReporterEnabled ? "PSK" : nil, + ].compactMap { $0 } + return parts.isEmpty ? "Off" : parts.joined(separator: " ") + } + private var appVersion: String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" @@ -206,24 +234,24 @@ private struct OperatorIdentityCard: View { RoundedRectangle(cornerRadius: 10) .fill(accent.opacity(0.14)) Text(settings.myCall.isEmpty ? "?" : String(settings.myCall.prefix(2))) - .font(.system(size: 16, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 16, weight: .bold)) .foregroundStyle(accent) } .frame(width: 44, height: 44) VStack(alignment: .leading, spacing: 3) { Text(settings.myCall.isEmpty ? "No Callsign" : settings.myCall) - .font(.system(size: 16, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 16, weight: .bold)) .foregroundStyle(settings.myCall.isEmpty ? textFaint : textPrimary) HStack(spacing: 8) { if !settings.myGrid.isEmpty { Label(settings.myGrid, systemImage: "mappin") - .font(.system(size: 11, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .medium)) .foregroundStyle(textMuted) } Label(settings.rigModel.rawValue, systemImage: "antenna.radiowaves.left.and.right") - .font(.system(size: 11, weight: .medium)) + .font(.ft8afUI(size: 11, weight: .medium)) .foregroundStyle(textFaint) } } @@ -231,7 +259,7 @@ private struct OperatorIdentityCard: View { Spacer() Image(systemName: "pencil.circle") - .font(.system(size: 18)) + .font(.ft8afUI(size: 18)) .foregroundStyle(textFaint) } } @@ -254,7 +282,7 @@ private struct OperatorEditSheet: View { .foregroundStyle(textPrimary) Spacer() TextField("e.g. KD2OGR", text: $callsign) - .font(.system(.body, design: .monospaced)) + .font(.ft8afMono(size: 17)) .multilineTextAlignment(.trailing) .foregroundStyle(textPrimary) .textInputAutocapitalization(.characters) @@ -265,7 +293,7 @@ private struct OperatorEditSheet: View { .foregroundStyle(textPrimary) Spacer() TextField("e.g. FN20", text: $grid) - .font(.system(.body, design: .monospaced)) + .font(.ft8afMono(size: 17)) .multilineTextAlignment(.trailing) .foregroundStyle(textPrimary) .textInputAutocapitalization(.characters) diff --git a/ios/FT8AF/FT8AF/Screens/Settings/TransmissionSettings.swift b/ios/FT8AF/FT8AF/Screens/Settings/TransmissionSettings.swift index b0a1234e1..2f12a830d 100644 --- a/ios/FT8AF/FT8AF/Screens/Settings/TransmissionSettings.swift +++ b/ios/FT8AF/FT8AF/Screens/Settings/TransmissionSettings.swift @@ -14,7 +14,7 @@ struct TransmissionSettings: View { .foregroundStyle(textPrimary) Spacer() TextField("Watts", value: $settings.txPowerWatts, format: .number) - .font(.system(.body, design: .monospaced)) + .font(.ft8afMono(size: 17)) .multilineTextAlignment(.trailing) .foregroundStyle(textPrimary) .keyboardType(.numberPad) @@ -53,7 +53,7 @@ struct TransmissionSettings: View { .foregroundStyle(textPrimary) Spacer() Text("\(settings.txVolume)%") - .font(.system(size: 14, design: .monospaced)) + .font(.ft8afMono(size: 14)) .foregroundStyle(textMuted) } Slider( @@ -93,7 +93,7 @@ struct TransmissionSettings: View { Text("Hunt: Call CQ") .foregroundStyle(textPrimary) Text("Automatically call CQ when hunting") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } } @@ -104,7 +104,7 @@ struct TransmissionSettings: View { Text("Auto-Call Follow") .foregroundStyle(textPrimary) Text("Automatically respond to stations calling you") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } } @@ -115,7 +115,7 @@ struct TransmissionSettings: View { Text("Early Decode") .foregroundStyle(textPrimary) Text("Start decoding before end of RX cycle") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } } @@ -126,7 +126,7 @@ struct TransmissionSettings: View { Text("Auto-CQ After QSO") .foregroundStyle(textPrimary) Text("Resume calling CQ after completing a QSO") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } } @@ -147,7 +147,7 @@ struct TransmissionSettings: View { Text("TX Watchdog") .foregroundStyle(textPrimary) Text("Auto-stop TX after timeout") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } Spacer() @@ -167,7 +167,7 @@ struct TransmissionSettings: View { Text("Stop After") .foregroundStyle(textPrimary) Text("Max CQ attempts before stopping") - .font(.system(size: 11)) + .font(.ft8afUI(size: 11)) .foregroundStyle(textFaint) } Spacer() @@ -181,11 +181,30 @@ struct TransmissionSettings: View { .tint(accent) .frame(width: 100) } + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Tune Timeout") + .foregroundStyle(textPrimary) + Text("Max carrier time for the TUNE button") + .font(.ft8afUI(size: 11)) + .foregroundStyle(textFaint) + } + Spacer() + Picker("", selection: $settings.tuneTimeoutSec) { + ForEach([10, 15, 30, 60, 120], id: \.self) { sec in + Text("\(sec) s").tag(sec) + } + } + .pickerStyle(.menu) + .tint(accent) + .frame(width: 100) + } } header: { Text("TX Safety") .foregroundStyle(textMuted) } footer: { - Text("Watchdog prevents runaway TX. Stop-after limits consecutive unanswered CQ calls.") + Text("Watchdog prevents runaway TX. Stop-after limits consecutive unanswered CQ calls. TUNE always stops itself at the timeout.") .foregroundStyle(textFaint) } .listRowBackground(bgSurface) @@ -205,5 +224,6 @@ struct TransmissionSettings: View { .onChange(of: settings.autoCQAfterQSO) { _, _ in SettingsPersistence.save(appState.settings) } .onChange(of: settings.txWatchdogMin) { _, _ in SettingsPersistence.save(appState.settings) } .onChange(of: settings.stopAfterAttempts) { _, _ in SettingsPersistence.save(appState.settings) } + .onChange(of: settings.tuneTimeoutSec) { _, _ in SettingsPersistence.save(appState.settings) } } } diff --git a/ios/FT8AF/FT8AF/Screens/SplashScreen.swift b/ios/FT8AF/FT8AF/Screens/SplashScreen.swift index 0c9a9c2ef..a5832d67d 100644 --- a/ios/FT8AF/FT8AF/Screens/SplashScreen.swift +++ b/ios/FT8AF/FT8AF/Screens/SplashScreen.swift @@ -35,7 +35,7 @@ struct SplashScreen: View { // Center icon Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 36, weight: .semibold)) + .font(.ft8afUI(size: 36, weight: .semibold)) .foregroundStyle(accent) } .scaleEffect(logoScale) @@ -44,11 +44,11 @@ struct SplashScreen: View { // App title VStack(spacing: 6) { Text("FT8AF") - .font(.system(size: 32, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 32, weight: .bold)) .foregroundStyle(textPrimary) Text("Amateur Radio FT8") - .font(.system(size: 14, weight: .medium)) + .font(.ft8afUI(size: 14, weight: .medium)) .foregroundStyle(textMuted) } .opacity(titleOpacity) @@ -62,14 +62,14 @@ struct SplashScreen: View { .frame(width: 200) Text("Initializing...") - .font(.system(size: 11, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 11, weight: .medium)) .foregroundStyle(textFaint) } .opacity(subtitleOpacity) // Version Text(appVersion) - .font(.system(size: 10, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .medium)) .foregroundStyle(textDim) .opacity(subtitleOpacity) .padding(.bottom, 40) diff --git a/ios/FT8AF/FT8AF/Screens/Waterfall/FrequencyRuler.swift b/ios/FT8AF/FT8AF/Screens/Waterfall/FrequencyRuler.swift index 9b56bd1ca..7d0847da3 100644 --- a/ios/FT8AF/FT8AF/Screens/Waterfall/FrequencyRuler.swift +++ b/ios/FT8AF/FT8AF/Screens/Waterfall/FrequencyRuler.swift @@ -2,15 +2,23 @@ import SwiftUI import FT8Audio /// Horizontal ruler showing Hz tick marks across the displayed audio band. -/// Ticks span the same range the waterfall/spectrum data covers -/// (`WaterfallAxis.displayMaxHz`) so the labels line up with the trace. +/// Ticks span `waterfall.displayMaxHz` — the range the drawn waterfall and +/// spectrum data actually cover (the loop keeps it in step with the +/// spectrum-width setting while live) — so the labels line up with the trace +/// even when RX is stopped and the setting changes. +/// Tick geometry comes from `WaterfallAxis.rulerTicks`, which places each +/// label at its true `hz / width` fraction (mirrors the Android fix for ruler +/// labels drifting when the width isn't a 500 Hz multiple, commit 647b12e8). struct FrequencyRuler: View { - private let ticks = stride(from: 0, through: Int(WaterfallAxis.displayMaxHz), by: 500).map { $0 } + @Environment(AppState.self) private var appState var body: some View { + let ticks = WaterfallAxis.rulerTicks( + displayMaxHz: appState.waterfall.displayMaxHz + ) Canvas { context, size in - for hz in ticks { - let x = CGFloat(WaterfallAxis.fraction(forHz: Float(hz))) * size.width + for tick in ticks { + let x = CGFloat(tick.fraction) * size.width // Tick mark let tickPath = Path { p in p.move(to: CGPoint(x: x, y: 0)) @@ -18,10 +26,9 @@ struct FrequencyRuler: View { } context.stroke(tickPath, with: .color(textFaint), lineWidth: 0.5) // Label - let label = "\(hz)" context.draw( - Text(label) - .font(.system(size: 8, weight: .medium, design: .monospaced)) + Text("\(tick.hz)") + .font(.ft8afMono(size: 8, weight: .medium)) .foregroundStyle(textFaint), at: CGPoint(x: x, y: 14), anchor: .center diff --git a/ios/FT8AF/FT8AF/Screens/Waterfall/SpectrumStrip.swift b/ios/FT8AF/FT8AF/Screens/Waterfall/SpectrumStrip.swift index e45191f19..7b365425b 100644 --- a/ios/FT8AF/FT8AF/Screens/Waterfall/SpectrumStrip.swift +++ b/ios/FT8AF/FT8AF/Screens/Waterfall/SpectrumStrip.swift @@ -3,11 +3,19 @@ import FT8Audio /// Vertical bar chart of live FFT magnitudes with a TX frequency marker. /// Reads normalized magnitudes (0...1) from `appState.waterfall.spectrum`. -/// Tap or drag to set the TX frequency. +/// Tap or drag to set the TX frequency. All frequency ↔ x mappings use the +/// span the drawn data actually covers so overlays track the drawn bars. struct SpectrumStrip: View { @Environment(AppState.self) private var appState @State private var touchFreq: Float? + /// Top of the displayed band — the span the drawn rows/spectrum actually + /// cover (`waterfall.displayMaxHz`, maintained by the waterfall loop). + /// NOT the settings width: when the loop isn't running, a settings change + /// hasn't been applied to the data yet, and mapping against it would put + /// overlays and tap-to-tune on the wrong frequencies. + private var displayMaxHz: Float { appState.waterfall.displayMaxHz } + var body: some View { GeometryReader { geo in Canvas { context, size in @@ -28,19 +36,23 @@ struct SpectrumStrip: View { } // TX frequency marker line - let txX = CGFloat(WaterfallAxis.clampedFraction(forHz: appState.waterfall.txFreqHz)) * size.width + let txX = CGFloat(WaterfallAxis.clampedFraction( + forHz: appState.waterfall.txFreqHz, displayMaxHz: displayMaxHz + )) * size.width let markerRect = CGRect(x: txX - 0.5, y: 0, width: 1, height: size.height) context.fill(Path(markerRect), with: .color(accent)) // Touch frequency indicator if let tf = touchFreq { - let touchX = CGFloat(WaterfallAxis.fraction(forHz: tf)) * size.width + let touchX = CGFloat(WaterfallAxis.fraction( + forHz: tf, displayMaxHz: displayMaxHz + )) * size.width let touchRect = CGRect(x: touchX - 0.5, y: 0, width: 1, height: size.height) context.fill(Path(touchRect), with: .color(target.opacity(0.7))) // Frequency label let label = context.resolve(Text("\(Int(tf)) Hz") - .font(.system(size: 9, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 9, weight: .bold)) .foregroundStyle(textPrimary)) let labelX = min(max(touchX, 30), size.width - 30) context.draw(label, at: CGPoint(x: labelX, y: 8), anchor: .top) @@ -57,11 +69,15 @@ struct SpectrumStrip: View { DragGesture(minimumDistance: 0) .onChanged { value in let fraction = Float(value.location.x / geo.size.width) - touchFreq = WaterfallAxis.tunedTxHz(forFraction: fraction) + touchFreq = WaterfallAxis.tunedTxHz( + forFraction: fraction, displayMaxHz: displayMaxHz + ) } .onEnded { value in let fraction = Float(value.location.x / geo.size.width) - let clamped = WaterfallAxis.tunedTxHz(forFraction: fraction) + let clamped = WaterfallAxis.tunedTxHz( + forFraction: fraction, displayMaxHz: displayMaxHz + ) appState.waterfall.txFreqHz = clamped // Clear touch indicator after short delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/ios/FT8AF/FT8AF/Screens/Waterfall/WaterfallCanvas.swift b/ios/FT8AF/FT8AF/Screens/Waterfall/WaterfallCanvas.swift index b6ba3e9bd..96e518f37 100644 --- a/ios/FT8AF/FT8AF/Screens/Waterfall/WaterfallCanvas.swift +++ b/ios/FT8AF/FT8AF/Screens/Waterfall/WaterfallCanvas.swift @@ -5,10 +5,18 @@ import FT8Audio /// Each row is a `[UInt8]` of brightness values (0...255) produced by /// `WaterfallRowBuilder`. Renders newest rows at the bottom. /// When message marking is enabled, decoded callsign labels are drawn at -/// their frequency positions. +/// their frequency positions. Rows carrying a UTC boundary label (the first +/// row of each 15 s FT8 period) get a subtle divider line plus the period +/// start time at the left edge, matching Android's slot gridline. struct WaterfallCanvas: View { @Environment(AppState.self) private var appState + /// Top of the displayed band — the span the drawn rows actually cover + /// (`waterfall.displayMaxHz`, maintained by the waterfall loop). All + /// overlays map frequencies against this span so they sit on the trace + /// even when the loop is stopped and the settings width has changed. + private var displayMaxHz: Float { appState.waterfall.displayMaxHz } + var body: some View { Canvas { context, size in let rows = appState.waterfall.rows @@ -35,15 +43,22 @@ struct WaterfallCanvas: View { } } + // UTC period-boundary dividers + labels (rows flagged by the + // engine's timestamp gate, one per 15 s slot). + drawPeriodTimestamps(context: context, size: size, numRows: numRows, cellH: cellH) + // Draw message frequency labels when enabled if appState.waterfall.messageMarking { drawMessageLabels(context: context, size: size, numCols: numCols) } // Draw TX frequency marker. Map with the same span the waterfall - // columns cover (WaterfallAxis) so the marker sits on the trace. + // columns cover (the spectrum-width setting) so the marker sits on + // the trace. let txFreq = appState.waterfall.txFreqHz - let txX = CGFloat(WaterfallAxis.clampedFraction(forHz: txFreq)) * size.width + let txX = CGFloat(WaterfallAxis.clampedFraction( + forHz: txFreq, displayMaxHz: displayMaxHz + )) * size.width let txPath = Path { p in p.move(to: CGPoint(x: txX, y: 0)) p.addLine(to: CGPoint(x: txX, y: size.height)) @@ -55,6 +70,36 @@ struct WaterfallCanvas: View { .clipShape(RoundedRectangle(cornerRadius: 4)) } + /// Divider line + "HH:mm:ss" label on each row that starts a new FT8 + /// period. Left edge, faint monospaced caption — Android's look. + private func drawPeriodTimestamps( + context: GraphicsContext, size: CGSize, numRows: Int, cellH: CGFloat + ) { + let stamps = appState.waterfall.rowTimestamps + guard !stamps.isEmpty else { return } + + for r in 0.. 0, x < size.width else { continue } let label = context.resolve(Text(msg.callFrom) - .font(.system(size: 8, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 8, weight: .bold)) .foregroundStyle(labelColor(msg))) context.draw(label, at: CGPoint(x: x, y: 8), anchor: .top) diff --git a/ios/FT8AF/FT8AF/Screens/Waterfall/WaterfallScreen.swift b/ios/FT8AF/FT8AF/Screens/Waterfall/WaterfallScreen.swift index a370dfb02..ed92d093f 100644 --- a/ios/FT8AF/FT8AF/Screens/Waterfall/WaterfallScreen.swift +++ b/ios/FT8AF/FT8AF/Screens/Waterfall/WaterfallScreen.swift @@ -1,3 +1,4 @@ +import FT8Audio import SwiftUI struct WaterfallScreen: View { @@ -23,12 +24,12 @@ struct WaterfallScreen: View { // Info bar (bottom) HStack { Text(utcString) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 12, weight: .semibold)) .foregroundStyle(textPrimary) // TX frequency display Text("TX \(Int(appState.waterfall.txFreqHz)) Hz") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(accent) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -37,6 +38,13 @@ struct WaterfallScreen: View { .fill(accentSoft) ) + // RX input level meter (Android: InputLevelIndicator). + InputLevelMeter( + peak: appState.waterfall.inputPeak, + rms: appState.waterfall.inputRms + ) + .padding(.leading, 4) + Spacer() HStack(spacing: 12) { @@ -55,7 +63,7 @@ struct WaterfallScreen: View { .buttonStyle(.plain) Text("\(appState.waterfall.updateCount)") - .font(.system(size: 10, weight: .medium, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .medium)) .foregroundStyle(textDim) LiveIndicator(isLive: appState.waterfall.isLive) @@ -76,13 +84,75 @@ struct WaterfallScreen: View { } } +/// Compact live RX input-level meter for the waterfall info bar: an +/// RMS-filled bar with a peak tick and a color-coded status word. Mirrors +/// Android's `InputLevelIndicator`; the level→status classification is the +/// pure, kit-tested `AudioInputLevel` (same thresholds as Android). +private struct InputLevelMeter: View { + let peak: Float + let rms: Float + + private static let barWidth: CGFloat = 44 + + private var status: AudioInputLevel.Status { + AudioInputLevel.fromPeakRms(peak: peak, rms: rms).status + } + + /// Status color: faint = no/too-low input, green = good, yellow = hot, + /// red = clipping. + private var color: Color { + switch status { + case .silent, .low: return textFaint + case .good: return statusConfirmed + case .high: return statusWarn + case .clipping: return statusBad + } + } + + private var statusText: String { + switch status { + case .silent, .low: return "LOW" + case .good: return "GOOD" + case .high: return "HIGH" + case .clipping: return "CLIP" + } + } + + var body: some View { + HStack(spacing: 4) { + Text("RX") + .font(.ft8afMono(size: 9, weight: .bold)) + .foregroundStyle(textMuted) + + // Meter bar: RMS fill + peak tick. + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(bgSurface3) + RoundedRectangle(cornerRadius: 3) + .fill(color) + .frame(width: Self.barWidth * CGFloat(AudioInputLevel.meterFraction(rms))) + Rectangle() + .fill(color) + .frame(width: 1) + .offset(x: max(0, Self.barWidth * CGFloat(AudioInputLevel.meterFraction(peak)) - 1)) + } + .frame(width: Self.barWidth, height: 6) + .clipShape(RoundedRectangle(cornerRadius: 3)) + + Text(statusText) + .font(.ft8afMono(size: 9, weight: .bold)) + .foregroundStyle(color) + } + } +} + private struct InfoChip: View { let label: String let isOn: Bool var body: some View { Text(label) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .semibold)) .foregroundStyle(isOn ? accent : textFaint) .padding(.horizontal, 6) .padding(.vertical, 3) @@ -102,7 +172,7 @@ private struct LiveIndicator: View { .fill(isLive ? statusConfirmed : statusBad) .frame(width: 6, height: 6) Text(isLive ? "LIVE" : "OFF") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.ft8afMono(size: 10, weight: .bold)) .foregroundStyle(isLive ? statusConfirmed : textFaint) } } diff --git a/ios/FT8AF/FT8AF/Theme/Typography.swift b/ios/FT8AF/FT8AF/Theme/Typography.swift new file mode 100644 index 000000000..e8566248a --- /dev/null +++ b/ios/FT8AF/FT8AF/Theme/Typography.swift @@ -0,0 +1,101 @@ +import SwiftUI +import UIKit +import CoreText + +// MARK: - FT8AF typography (parity with Android theme/Type.kt) +// +// Android uses Inter (a single variable TTF whose `wght` axis is pinned per +// FontWeight) for all UI text, and the Geist Mono statics for tabular data +// columns (callsigns, grids, frequencies, SNR, clocks) whose alignment relies +// on fixed advance widths. The OpenType "zero" (slashed zero) feature is +// enabled app-wide so `0` is unambiguous against `O` (Android issue #438). +// +// iOS mirrors that here: +// Font.ft8afUI(size:weight:) → Inter (falls back to SF) +// Font.ft8afMono(size:weight:) → Geist Mono (falls back to SF Mono) +// Both enable the slashed-zero feature on the resolved font. + +enum FT8AFFonts { + /// PostScript names of the Inter variable font's named instances + /// (verified against inter_variable.ttf's fvar table via CoreText). + static func interName(for weight: Font.Weight) -> String { + switch weight { + case .medium: return "Inter-Regular_Medium" + case .semibold: return "Inter-Regular_SemiBold" + case .bold, .heavy, .black: return "Inter-Regular_Bold" + default: return "Inter-Regular" + } + } + + /// PostScript names of the bundled Geist Mono static weights. + static func geistMonoName(for weight: Font.Weight) -> String { + switch weight { + case .medium: return "GeistMono-Medium" + case .semibold: return "GeistMono-SemiBold" + case .bold, .heavy, .black: return "GeistMono-Bold" + default: return "GeistMono-Regular" + } + } + + /// Inter `wght` axis values matching Android's FontVariation.weight pins. + /// Used only if the named instances aren't registered on this OS. + static func interAxisValue(for weight: Font.Weight) -> CGFloat { + switch weight { + case .medium: return 500 + case .semibold: return 600 + case .bold, .heavy, .black: return 700 + default: return 400 + } + } + + /// The OpenType 'wght' variation axis identifier (four-char code). + private static let wghtAxis = 0x7767_6874 + + /// Resolves a bundled font by PostScript name with slashed zero enabled. + static func uiFont(postScriptName: String, size: CGFloat) -> UIFont? { + guard let base = UIFont(name: postScriptName, size: size) else { return nil } + return withSlashedZero(base, size: size) + } + + /// Fallback for OS versions that don't register a variable font's named + /// instances: take the base Inter face and pin the `wght` axis directly. + static func interVariableFallback(size: CGFloat, weight: Font.Weight) -> UIFont? { + guard let base = UIFont(name: "Inter-Regular", size: size) else { return nil } + let variation: [Int: CGFloat] = [wghtAxis: interAxisValue(for: weight)] + let descriptor = base.fontDescriptor.addingAttributes( + [UIFontDescriptor.AttributeName(rawValue: kCTFontVariationAttribute as String): variation] + ) + return withSlashedZero(UIFont(descriptor: descriptor, size: size), size: size) + } + + /// Enables the OpenType `zero` feature (slashed zero) on a font. Uses the + /// CoreText OpenType feature keys, which UIFontDescriptor.featureSettings + /// bridges through unchanged. + private static func withSlashedZero(_ font: UIFont, size: CGFloat) -> UIFont { + let feature: [UIFontDescriptor.FeatureKey: Any] = [ + UIFontDescriptor.FeatureKey(rawValue: kCTFontOpenTypeFeatureTag as String): "zero", + UIFontDescriptor.FeatureKey(rawValue: kCTFontOpenTypeFeatureValue as String): 1, + ] + let descriptor = font.fontDescriptor.addingAttributes([.featureSettings: [feature]]) + return UIFont(descriptor: descriptor, size: size) + } +} + +extension Font { + /// UI text — Inter (Android's UI family), slashed zero, SF fallback. + static func ft8afUI(size: CGFloat, weight: Font.Weight = .regular) -> Font { + if let ui = FT8AFFonts.uiFont(postScriptName: FT8AFFonts.interName(for: weight), size: size) + ?? FT8AFFonts.interVariableFallback(size: size, weight: weight) { + return Font(ui) + } + return .system(size: size, weight: weight) + } + + /// Data columns — Geist Mono (slashed zero by design), SF Mono fallback. + static func ft8afMono(size: CGFloat, weight: Font.Weight = .regular) -> Font { + if let ui = FT8AFFonts.uiFont(postScriptName: FT8AFFonts.geistMonoName(for: weight), size: size) { + return Font(ui) + } + return .system(size: size, weight: weight, design: .monospaced) + } +} diff --git a/ios/FT8AF/project.yml b/ios/FT8AF/project.yml index cd47b8b45..0cac70656 100644 --- a/ios/FT8AF/project.yml +++ b/ios/FT8AF/project.yml @@ -16,17 +16,40 @@ targets: platform: iOS sources: - FT8AF + # Explicit Info.plist (generated by XcodeGen) instead of Xcode's + # GENERATE_INFOPLIST_FILE + INFOPLIST_KEY_* settings, because UIAppFonts + # has no INFOPLIST_KEY_ equivalent. Every previous INFOPLIST_KEY_* value + # is carried over below. + info: + path: FT8AF/Info.plist + properties: + LSRequiresIPhoneOS: true + NSMicrophoneUsageDescription: "FT8AF needs microphone access to capture radio audio for FT8 decoding" + NSLocalNetworkUsageDescription: "FT8AF uses the local network to send WSJT-X UDP messages to companion apps like GridTracker and JTAlert" + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: true + UISceneConfigurations: {} + UIApplicationSupportsIndirectInputEvents: true + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UIAppFonts: + - inter_variable.ttf + - geist_mono_regular.ttf + - geist_mono_medium.ttf + - geist_mono_semibold.ttf + - geist_mono_bold.ttf settings: base: - GENERATE_INFOPLIST_FILE: YES - INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES - INFOPLIST_KEY_UILaunchScreen_Generation: YES - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" SWIFT_VERSION: "5.9" PRODUCT_BUNDLE_IDENTIFIER: radio.ks3ckc.ft8af - INFOPLIST_KEY_NSMicrophoneUsageDescription: "FT8AF needs microphone access to capture radio audio for FT8 decoding" dependencies: - package: FT8AFKit product: FT8DSP diff --git a/ios/FT8AFKit/Sources/FT8Audio/AudioInputLevel.swift b/ios/FT8AFKit/Sources/FT8Audio/AudioInputLevel.swift new file mode 100644 index 000000000..652e7b4f0 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Audio/AudioInputLevel.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Pure audio input-level metering for the receive level indicator — the +/// classification logic behind the waterfall's RX meter, kept off the SwiftUI +/// views so it is host-testable. Port of the Android `AudioInputLevel` class +/// (the app's single RX level classifier), same thresholds, so the two +/// platforms judge the same audio identically. +/// +/// A user comparing the app to WSJT-X often has the radio's audio set too +/// quiet (weak signals fall below the decode threshold) or hot enough to clip +/// (intermodulation wrecks the decode). This turns a raw 12 kHz mono float +/// buffer (samples in roughly [-1, 1]) into a peak/RMS reading plus a coarse +/// `Status` the UI can color, so the operator can set gain the way WSJT-X's +/// meter lets them. +/// +/// All thresholds are expressed in dBFS (decibels relative to full scale, so +/// 0 dB is a sample magnitude of 1.0 and every value here is <= 0). Clipping +/// is judged from the peak; "too quiet" from the RMS, which tracks the +/// noise/signal energy rather than a lone spike. +public enum AudioInputLevel { + /// Coarse health of the input level. + public enum Status: Equatable, Sendable { + /// Essentially no input — disconnected cable, wrong source, or muted radio. + case silent + /// Audible but too quiet; weak signals sit below the decode threshold. + case low + /// In the healthy window — what the operator should aim for. + case good + /// Hot: peaks are close to full scale, raise nothing further. + case high + /// Peaks pinned at full scale; clipping distortion will block decodes. + case clipping + } + + /// Immutable result of metering one buffer. + public struct Reading: Equatable, Sendable { + /// Largest sample magnitude in the buffer, linear (0..~1). + public let peak: Float + /// Root-mean-square sample magnitude, linear. + public let rms: Float + /// `peak` in dBFS, clamped at `minDbfs`. + public let peakDbfs: Float + /// `rms` in dBFS, clamped at `minDbfs`. + public let rmsDbfs: Float + /// Coarse health used to color the meter. + public let status: Status + } + + /// Floor we report instead of -infinity for a digitally silent buffer. + public static let minDbfs: Float = -120 + + // Peak at/above this linear magnitude is treated as clipping (~ -0.13 dBFS). + static let clipPeak: Float = 0.985 + // Peak dBFS at/above this is "hot" (clipping risk on transients). + static let highPeakDbfs: Float = -3 + // Below this RMS the buffer carries no usable signal. + static let silentRmsDbfs: Float = -75 + // RMS below this (but above SILENT) is too quiet for reliable weak-signal decode. + static let lowRmsDbfs: Float = -45 + + /// A reading for an empty/absent buffer: digital silence. + public static let silence = Reading( + peak: 0, rms: 0, peakDbfs: minDbfs, rmsDbfs: minDbfs, status: .silent + ) + + /// Meter one buffer of mono float samples in roughly [-1, 1]. An empty + /// buffer yields a `.silent` reading rather than trapping, so the caller + /// can meter unconditionally. + public static func measure(_ samples: [Float]) -> Reading { + guard !samples.isEmpty else { return silence } + var peak: Float = 0 + var sumSquares: Double = 0 + for s in samples { + let a = abs(s) + if a > peak { peak = a } + sumSquares += Double(s) * Double(s) + } + let rms = Float((sumSquares / Double(samples.count)).squareRoot()) + return fromPeakRms(peak: peak, rms: rms) + } + + /// Meter from an already-accumulated linear peak/RMS pair — e.g. the + /// metering window the waterfall loop snapshots. Single classification + /// entry point so the "healthy gain window" lives in exactly one set of + /// thresholds (mirrors Android issue #405). + public static func fromPeakRms(peak: Float, rms: Float) -> Reading { + // Sanitize non-finite inputs to digital silence: comparisons against a + // NaN are all false, which would misreport GOOD with NaN dB values. + let p = peak.isFinite ? max(0, peak) : 0 + let r = rms.isFinite ? max(0, rms) : 0 + let peakDbfs = toDbfs(p) + let rmsDbfs = toDbfs(r) + return Reading( + peak: p, rms: r, peakDbfs: peakDbfs, rmsDbfs: rmsDbfs, + status: classify(peak: p, peakDbfs: peakDbfs, rmsDbfs: rmsDbfs) + ) + } + + /// Meter-bar fill fraction for a linear sample level, clamped to 0...1. + public static func meterFraction(_ level: Float) -> Float { + guard level.isFinite else { return 0 } + return min(max(level, 0), 1) + } + + /// Linear magnitude (>= 0) to dBFS, with a `minDbfs` floor for silence. + static func toDbfs(_ linear: Float) -> Float { + guard linear > 0 else { return minDbfs } + let db = 20 * log10(linear) + return db < minDbfs ? minDbfs : db + } + + static func classify(peak: Float, peakDbfs: Float, rmsDbfs: Float) -> Status { + if peak >= clipPeak { return .clipping } + if peakDbfs >= highPeakDbfs { return .high } + if rmsDbfs < silentRmsDbfs { return .silent } + if rmsDbfs < lowRmsDbfs { return .low } + return .good + } +} diff --git a/ios/FT8AFKit/Sources/FT8Audio/AudioPortLabel.swift b/ios/FT8AFKit/Sources/FT8Audio/AudioPortLabel.swift new file mode 100644 index 000000000..4ef202055 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Audio/AudioPortLabel.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Category of an audio port, mapped from `AVAudioSession.Port` by the app +/// layer. Kept as a plain enum here so label composition stays +/// platform-neutral and host-testable (FT8Audio has no AVFoundation). +public enum AudioPortKind: String, Sendable { + case builtInMic + case speaker + case receiver + case wired + case usb + case bluetooth + case airPlay + case carAudio + case other +} + +/// Display labels for audio input/output ports, matching the Android +/// device-picker convention of " ()" with the transport +/// suffix dropped when the port name already says it. +public enum AudioPortLabel { + public static func label(kind: AudioPortKind, portName: String) -> String { + let name = portName.trimmingCharacters(in: .whitespaces) + switch kind { + case .builtInMic: + return "Built-in Microphone" + case .speaker: + return "Speaker" + case .receiver: + return "Receiver" + case .wired: + return name.isEmpty ? "Wired" : name + case .usb: + return suffixed(name, transport: "USB", fallback: "USB Audio") + case .bluetooth: + return suffixed(name, transport: "Bluetooth", fallback: "Bluetooth Audio") + case .airPlay: + return suffixed(name, transport: "AirPlay", fallback: "AirPlay") + case .carAudio: + return suffixed(name, transport: "CarPlay", fallback: "CarPlay") + case .other: + return name.isEmpty ? "Unknown Device" : name + } + } + + private static func suffixed(_ name: String, transport: String, fallback: String) -> String { + if name.isEmpty { return fallback } + if name.range(of: transport, options: .caseInsensitive) != nil { return name } + return "\(name) (\(transport))" + } +} diff --git a/ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift b/ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift index 0b792dc91..fed3d7e2c 100644 --- a/ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift +++ b/ios/FT8AFKit/Sources/FT8Audio/WaterfallAxis.swift @@ -3,36 +3,58 @@ /// /// The waterfall and spectrum columns produced by `WaterfallRowBuilder` are /// drawn edge-to-edge across the full view width and cover the audio band -/// `0 ... WaterfallRowBuilder.maxHz` (`columns(...) * binHz(...)`, i.e. the top -/// of the displayed band — 3500 Hz, matching the desktop engine's `WF_MAX_HZ`). -/// Any overlay that positions a frequency on that view — the TX marker, decoded -/// message labels, the ruler ticks, tap-to-tune — must map with the **same** -/// span, or it drifts off the trace it annotates. Previously the overlays used a -/// hard-coded 3000 Hz while the data spanned 3500 Hz, so every marker sat ~17% -/// away from its signal and tapping a visible trace keyed up on the wrong audio -/// frequency. +/// `0 ... displayMaxHz` (`columns(...) * binHz(...)`, i.e. the top of the +/// displayed band). Any overlay that positions a frequency on that view — the +/// TX marker, decoded message labels, the ruler ticks, tap-to-tune — must map +/// with the **same** span, or it drifts off the trace it annotates. Previously +/// the overlays used a hard-coded 3000 Hz while the data spanned 3500 Hz, so +/// every marker sat ~17% away from its signal and tapping a visible trace keyed +/// up on the wrong audio frequency. +/// +/// The span is the operator's **spectrum width** setting (Android's +/// `GeneralVariables.spectrumWidth`), so every function takes it as a +/// parameter instead of baking in a constant. This is display-only: the FT8 +/// decoder keeps its fixed range regardless of what's shown. /// /// This is the single source of truth for that mapping, extracted off the /// SwiftUI `Canvas` views so it can be unit-tested (per the project rule of /// putting geometry in a plain, testable type). public enum WaterfallAxis { - /// Top of the displayed audio band, in Hz. Kept equal to the span the - /// waterfall/spectrum data is actually built to so overlays line up with the - /// columns. - public static var displayMaxHz: Float { WaterfallRowBuilder.maxHz } - - /// Lowest / highest audio offset the operator may transmit on. The upper - /// bound keeps TX inside the SSB passband and mirrors the desktop engine's - /// `tx_audio_hz.clamp(200, 3000)`; the display band (`displayMaxHz`) extends - /// above it so higher signals remain visible even though they can't be tuned. - public static let minTxHz: Float = 200 - public static let maxTxHz: Float = 3000 + /// Default top of the displayed audio band, in Hz — used before the + /// operator's spectrum-width setting is known. Kept equal to the span the + /// waterfall/spectrum data is built to by default. + public static var defaultDisplayMaxHz: Float { WaterfallRowBuilder.defaultMaxHz } + + /// Lowest audio offset the operator may transmit on. Mirrors Android, + /// which clamps the audio frequency to `100 ... spectrumWidth - 100` + /// (`WaterfallView` tap-to-tune and the settings input share the clamp). + public static let minTxHz: Float = 100 + + /// Absolute ceiling on the TX audio offset, regardless of how wide the + /// display is. Keeps TX inside the SSB passband (the desktop engine's + /// `tx_audio_hz.clamp(..., 3000)`); the display band can extend above it so + /// higher signals remain visible even though they can't be tuned. + public static let txCeilingHz: Float = 3000 + + /// Margin kept between the tunable range and the display edge, mirroring + /// Android's `100 ... spectrumWidth - 100` clamp. + public static let txEdgeGuardHz: Float = 100 + + /// Hz step between adjacent frequency-ruler labels (Android `RulerStepHz`). + public static let rulerStepHz = 500 + + /// Highest audio offset the operator may transmit on for a given display + /// width: `min(3000, width - 100)` — the Android clamp with the SSB + /// passband ceiling. Never collapses below `minTxHz` even for a degenerate + /// width, so the clamp range stays valid. + public static func maxTxHz(displayMaxHz: Float) -> Float { + return max(minTxHz, min(txCeilingHz, displayMaxHz - txEdgeGuardHz)) + } /// Horizontal position (0...1 of the view width) for a frequency. - public static func fraction(forHz hz: Float) -> Float { - let span = displayMaxHz - guard span > 0 else { return 0 } - return hz / span + public static func fraction(forHz hz: Float, displayMaxHz: Float) -> Float { + guard displayMaxHz > 0 else { return 0 } + return hz / displayMaxHz } /// Horizontal position for a TX-marker overlay, clamped to the drawable @@ -40,21 +62,56 @@ public enum WaterfallAxis { /// — a WSJT-X UDP reply carries a raw `deltaFreq` — so an out-of-band value /// would otherwise push the marker line off-canvas. Clamping keeps it pinned /// to the nearest edge instead of vanishing. - public static func clampedFraction(forHz hz: Float) -> Float { - return min(max(fraction(forHz: hz), 0), 1) + public static func clampedFraction(forHz hz: Float, displayMaxHz: Float) -> Float { + return min(max(fraction(forHz: hz, displayMaxHz: displayMaxHz), 0), 1) } /// Frequency (Hz) at a horizontal position given as a 0...1 fraction of the - /// view width. Inverse of `fraction(forHz:)`. - public static func hz(forFraction fraction: Float) -> Float { + /// view width. Inverse of `fraction(forHz:displayMaxHz:)`. + public static func hz(forFraction fraction: Float, displayMaxHz: Float) -> Float { return fraction * displayMaxHz } /// Tap-to-tune: the frequency under a horizontal fraction of the view, - /// clamped to the valid TX offset range. The fraction is read against the - /// display span so the tap lands on the trace under the finger; the result - /// is then clamped so it never exceeds the SSB passband. - public static func tunedTxHz(forFraction fraction: Float) -> Float { - return min(max(hz(forFraction: fraction), minTxHz), maxTxHz) + /// clamped to the valid TX offset range for this display width. The + /// fraction is read against the display span so the tap lands on the trace + /// under the finger; the result is then clamped to + /// `100 ... min(3000, width - 100)` (the Android clamp) so it never leaves + /// the usable passband. + public static func tunedTxHz(forFraction fraction: Float, displayMaxHz: Float) -> Float { + let hz = hz(forFraction: fraction, displayMaxHz: displayMaxHz) + return min(max(hz, minTxHz), maxTxHz(displayMaxHz: displayMaxHz)) + } + + /// Ruler labels at `rulerStepHz` intervals from 0 up to `displayMaxHz`, + /// each paired with the horizontal fraction it must sit at. + /// + /// A label's fraction is `hz / displayMaxHz` — the same mapping the + /// waterfall columns use — NOT an even spread to the far edge. When the + /// width is a multiple of the step the top tick equals it (fraction 1.0) + /// and the gaps are uniform; otherwise the top tick sits below the far edge + /// instead of being stretched to it. Mirrors the Android fix for ruler + /// labels drifting when `spectrumWidth` isn't a 500 multiple (commit + /// 647b12e8 / PR #575). + public static func rulerTicks(displayMaxHz: Float) -> [RulerTick] { + guard displayMaxHz > 0 else { return [RulerTick(hz: 0, fraction: 0)] } + var ticks: [RulerTick] = [] + var hz = 0 + while Float(hz) <= displayMaxHz { + ticks.append(RulerTick(hz: hz, fraction: Float(hz) / displayMaxHz)) + hz += rulerStepHz + } + return ticks + } +} + +/// A single frequency-ruler label: its frequency and where it sits across the +/// strip (0...1 of the view width). +public struct RulerTick: Equatable, Sendable { + public let hz: Int + public let fraction: Float + public init(hz: Int, fraction: Float) { + self.hz = hz + self.fraction = fraction } } diff --git a/ios/FT8AFKit/Sources/FT8Audio/WaterfallRowBuilder.swift b/ios/FT8AFKit/Sources/FT8Audio/WaterfallRowBuilder.swift index 7741b4f34..d25e3c300 100644 --- a/ios/FT8AFKit/Sources/FT8Audio/WaterfallRowBuilder.swift +++ b/ios/FT8AFKit/Sources/FT8Audio/WaterfallRowBuilder.swift @@ -26,7 +26,11 @@ public struct WaterfallRowBuilder { public static let fftSize = 2048 // ~5.86 Hz/bin at 12 kHz public static let segments = 6 // WF_AVG (Welch segments) public static let step = fftSize / 2 // WF_STEP, 50% overlap - public static let maxHz: Float = 3500 // displayed top of the audio band + /// Default displayed top of the audio band (desktop `WF_MAX_HZ`). The + /// operator's spectrum-width setting overrides it per call via the `maxHz` + /// parameter of `columns(sampleRate:maxHz:)` — display-only; the FT8 + /// decoder's range is unaffected. + public static let defaultMaxHz: Float = 3500 public static let spanDb: Float = 26 // dB above black that maps to full brightness public static let blackOffsetDb: Float = 4 // push black above the noise floor public static let noiseFloorPercentile: Float = 0.30 @@ -40,8 +44,9 @@ public struct WaterfallRowBuilder { /// Hz per FFT bin at a given sample rate. public static func binHz(sampleRate: Int) -> Float { Float(sampleRate) / Float(fftSize) } - /// Number of displayed columns: bins up to `maxHz`, capped at the Nyquist half. - public static func columns(sampleRate: Int) -> Int { + /// Number of displayed columns: bins up to `maxHz` (the operator's spectrum + /// width; defaults to `defaultMaxHz`), capped at the Nyquist half. + public static func columns(sampleRate: Int, maxHz: Float = WaterfallRowBuilder.defaultMaxHz) -> Int { let bh = binHz(sampleRate: sampleRate) guard bh > 0 else { return 1 } return min(fftSize / 2, max(1, Int(maxHz / bh))) diff --git a/ios/FT8AFKit/Sources/FT8Audio/WaterfallTimestampGate.swift b/ios/FT8AFKit/Sources/FT8Audio/WaterfallTimestampGate.swift new file mode 100644 index 000000000..b980c5294 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Audio/WaterfallTimestampGate.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Gates the waterfall's UTC-timestamp gridline (the horizontal boundary line +/// plus the time label) to one emission per transmit/decode slot — the pure, +/// unit-testable core behind the labels the waterfall draws at each 15 s FT8 +/// period boundary. Port of the Android `WaterfallTimestampGate`, including +/// its mode-aware slot length (FT8 15 s today; FT4/FT2 would pass a shorter +/// `slotMs`). +/// +/// The line marks where one cycle ends and the next begins on the scrolling +/// waterfall so an operator can line signals up against the slot grid. The +/// waterfall loop ticks ~4x per second, so without the gate the label would be +/// stamped on every row of a slot instead of exactly once at its boundary. +public struct WaterfallTimestampGate { + /// Fallback slot length (FT8, ms) used if the caller has no valid mode slot. + public static let defaultSlotMs: Int64 = 15_000 + + private var lastPeriod: Int64 = -1 + /// Slot length `lastPeriod` was computed under; -1 = none yet. + private var lastSlotMs: Int64 = -1 + + public init() {} + + /// The slot index `utcMs` falls in for a mode of slot length `slotMs`. A + /// non-positive `slotMs` falls back to the 15 s FT8 grid so a mid-init + /// caller can never divide by zero. Euclidean division keeps the index + /// correct even if a clock correction pushes the time negative. + public static func slotPeriod(utcMs: Int64, slotMs: Int64 = defaultSlotMs) -> Int64 { + let slot = slotMs > 0 ? slotMs : defaultSlotMs + return SlotClock.floorDiv(utcMs, slot) + } + + /// Epoch ms of the start of the slot containing `utcMs` — the instant the + /// boundary label should display (Android draws the period-start time, not + /// the wall clock of whichever frame crossed the boundary). + public static func slotStartMs(utcMs: Int64, slotMs: Int64 = defaultSlotMs) -> Int64 { + let slot = slotMs > 0 ? slotMs : defaultSlotMs + return slotPeriod(utcMs: utcMs, slotMs: slot) * slot + } + + /// Whether the boundary line + label should be emitted now: true the first + /// call that `utcMs` crosses into a new slot, false while it stays within + /// the same slot (so the label is stamped exactly once per slot). + /// + /// Slot indices are only comparable within one mode, so when `slotMs` + /// changes mid-stream the gate re-baselines to the new grid silently, + /// emitting only if `utcMs` happens to land exactly on a new-mode boundary + /// (mirrors the Android gate's mode-change rule). + public mutating func shouldDraw(utcMs: Int64, slotMs: Int64 = defaultSlotMs) -> Bool { + let slot = slotMs > 0 ? slotMs : Self.defaultSlotMs + let period = SlotClock.floorDiv(utcMs, slot) + if lastSlotMs > 0 && slot != lastSlotMs { + // Mode change: adopt the new grid without comparing across + // incompatible indices. + lastSlotMs = slot + lastPeriod = period + return SlotClock.floorMod(utcMs, slot) == 0 + } + lastSlotMs = slot + if period == lastPeriod { + return false + } + lastPeriod = period + return true + } + + /// Forget the last emitted slot and mode (e.g. when the waterfall history + /// is cleared), so the next call stamps a fresh label. + public mutating func reset() { + lastPeriod = -1 + lastSlotMs = -1 + } + + /// "HH:mm:ss" UTC label for an epoch-ms instant — the text drawn at the + /// boundary row (matches Android's period-start `HH:mm:ss` stamp). + public static func utcLabel(forUtcMs ms: Int64) -> String { + let secondsOfDay = SlotClock.floorMod(ms, 86_400_000) / 1000 + let h = secondsOfDay / 3600 + let m = (secondsOfDay % 3600) / 60 + let s = secondsOfDay % 60 + return String(format: "%02d:%02d:%02d", h, m, s) + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/CallerQueue.swift b/ios/FT8AFKit/Sources/FT8Engine/CallerQueue.swift new file mode 100644 index 000000000..186d5ce71 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/CallerQueue.swift @@ -0,0 +1,105 @@ +import Foundation + +/// A station that called us while we were busy with another QSO (or running a +/// CQ), waiting its turn (port of Android's `QueuedCaller`). +public struct QueuedCaller: Equatable, Sendable { + public let callsign: String + public var snr: Int + public var freqHz: Float + public var grid: String + /// When the station was last heard calling us (refreshes on re-hear). + public var queuedAtMs: Int64 + + public init(callsign: String, snr: Int, freqHz: Float, grid: String, queuedAtMs: Int64) { + self.callsign = callsign + self.snr = snr + self.freqHz = freqHz + self.grid = grid + self.queuedAtMs = queuedAtMs + } +} + +/// Pure FIFO caller-queue policy (port of the Android `callerQueue` logic in +/// `FT8TransmitSignal`): dedup by callsign (a re-heard caller refreshes its +/// SNR/frequency/timestamp but keeps its place), a hard capacity cap, and a +/// staleness rule — a caller not re-heard for a few slots has moved on and is +/// pruned rather than called into dead air. +public struct CallerQueue: Equatable, Sendable { + /// Maximum queued callers (Android `MAX_QUEUE_SIZE`). + public static let maxSize = 10 + /// Drop callers not re-heard within this window (~4 FT8 slots — a station + /// still interested re-calls every other slot). + public static let staleAfterMs: Int64 = 60_000 + + public private(set) var callers: [QueuedCaller] = [] + + public init() {} + + public var isEmpty: Bool { callers.isEmpty } + public var count: Int { callers.count } + + /// Whether a heard station belongs in the queue at all: never ourselves, + /// never the station we are already working, never a blocked callsign. + public static func shouldEnqueue( + callsign: String, + myCall: String, + currentTarget: String?, + blocked: [String] + ) -> Bool { + let call = callsign.uppercased() + guard !call.isEmpty else { return false } + if call == myCall.uppercased() { return false } + if let target = currentTarget, call == target.uppercased() { return false } + if blocked.contains(where: { $0.uppercased() == call }) { return false } + return true + } + + /// Add or refresh a caller. Dedup is by callsign: an existing entry keeps + /// its queue position but takes the new SNR/frequency/grid/timestamp. A new + /// caller is appended only while under capacity. + /// - Returns: `true` if the queue now contains the caller. + @discardableResult + public mutating func enqueue(_ caller: QueuedCaller) -> Bool { + let call = caller.callsign.uppercased() + if let idx = callers.firstIndex(where: { $0.callsign.uppercased() == call }) { + callers[idx].snr = caller.snr + callers[idx].freqHz = caller.freqHz + if !caller.grid.isEmpty { callers[idx].grid = caller.grid } + callers[idx].queuedAtMs = caller.queuedAtMs + return true + } + guard callers.count < Self.maxSize else { return false } + callers.append(caller) + return true + } + + /// Drop callers last heard more than `staleAfterMs` ago. + public mutating func removeStale(nowMs: Int64) { + callers.removeAll { nowMs - $0.queuedAtMs > Self.staleAfterMs } + } + + /// Pop the head of the queue (oldest caller first). + public mutating func dequeueNext() -> QueuedCaller? { + callers.isEmpty ? nil : callers.removeFirst() + } + + /// Pop a specific caller by callsign (case-insensitive). + public mutating func dequeue(callsign: String) -> QueuedCaller? { + let call = callsign.uppercased() + guard let idx = callers.firstIndex(where: { $0.callsign.uppercased() == call }) else { + return nil + } + return callers.remove(at: idx) + } + + /// Remove a caller without returning it (e.g. the engine locked them on + /// its own, so their queue slot is obsolete). + public mutating func remove(callsign: String) { + let call = callsign.uppercased() + callers.removeAll { $0.callsign.uppercased() == call } + } + + public mutating func removeAll() { + callers.removeAll() + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/CloudlogClient.swift b/ios/FT8AFKit/Sources/FT8Engine/CloudlogClient.swift new file mode 100644 index 000000000..65d5744e4 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/CloudlogClient.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Pure request-builders and response-parsers for the Cloudlog/Wavelog/Nextlog +/// HTTP API. No networking here — the app-side `OnlineLogService` performs the +/// actual URLSession calls. Ports the wire shapes from Android +/// `ThirdPartyService` (uploadAdifToCloudlog / CheckCloudlogConnection). +public enum CloudlogClient { + + /// A fully-built HTTP request the app layer just has to send. + public struct Request: Equatable, Sendable { + public let url: String + /// JSON body for POSTs; empty for GETs. + public let body: String + } + + /// Normalize a server address: trim whitespace and ensure a trailing "/". + public static func normalizedAddress(_ address: String) -> String { + let t = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !t.isEmpty else { return t } + return t.hasSuffix("/") ? t : t + "/" + } + + /// Build the `POST api/qso` request that uploads one ADIF record. The JSON + /// key order matches Android's JSONStringer output so payloads are + /// byte-identical across platforms. Returns nil when the address or API + /// key is missing (nothing to send). + /// + /// Note: the documented endpoint is `api/qso` with NO trailing slash — + /// Wavelog/Nextlog 308-redirect the slashed form on POST. + public static func qsoUploadRequest( + serverAddress: String, apiKey: String, stationId: String, adif: String + ) -> Request? { + let base = normalizedAddress(serverAddress) + guard !base.isEmpty, !apiKey.isEmpty else { return nil } + let body = "{\"key\":\(jsonString(apiKey))" + + ",\"station_profile_id\":\(jsonString(stationId))" + + ",\"type\":\"adif\"" + + ",\"string\":\(jsonString(adif))}" + return Request(url: base + "api/qso", body: body) + } + + /// URL for the test-connection GET (`api/auth/`). The key is a path + /// segment, so the URL is credential-bearing — never log it. Returns nil + /// when address or key is missing. + public static func authUrl(serverAddress: String, apiKey: String) -> String? { + let base = normalizedAddress(serverAddress) + guard !base.isEmpty, !apiKey.isEmpty else { return nil } + return base + "api/auth/" + apiKey + } + + /// Parse the `api/auth` response. Cloudlog, Wavelog and Nextlog all + /// implement it but differ in whitespace/XML-declaration noise, so match + /// the meaningful markers on a whitespace-stripped copy. + public static func isAuthValid(_ response: String?) -> Bool { + guard let response, !response.isEmpty else { return false } + let compact = response.filter { !$0.isWhitespace } + return compact.contains("Valid") + && compact.contains("rw") + } + + /// Whether an HTTP status code counts as upload success. Cloudlog answers + /// 201 Created for a stored QSO; some deployments answer 200. + public static func isSuccessStatus(_ code: Int) -> Bool { + code == 200 || code == 201 + } + + /// Minimal JSON string literal encoder (quote, backslash, control chars) + /// so request bodies are deterministic and unit-testable byte-for-byte. + static func jsonString(_ s: String) -> String { + var out = "\"" + for scalar in s.unicodeScalars { + switch scalar { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if scalar.value < 0x20 { + out += String(format: "\\u%04x", scalar.value) + } else { + out.unicodeScalars.append(scalar) + } + } + } + return out + "\"" + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/DecodeAnnotator.swift b/ios/FT8AFKit/Sources/FT8Engine/DecodeAnnotator.swift new file mode 100644 index 000000000..ae0f5341f --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/DecodeAnnotator.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Highlight category for a decode-list row, mirroring the Android +/// `resolveQsoStatus` priority order (highest first): +/// PENDING (to me) > POTA > NEW DXCC > NEW GRID > NEW BAND > WORKED > CQ. +public enum DecodeHighlight: String, CaseIterable, Sendable { + case pending + case pota + case newDxcc + case newGrid + case newBand + case worked + case cq +} + +/// User toggles gating the logbook-derived highlight categories +/// (Settings → Decode Filters → Highlights). PENDING, POTA and CQ are +/// always-on because they reflect live on-air state, not logbook history. +public struct HighlightToggles: Equatable, Sendable { + public var newDxcc: Bool + public var newGrid: Bool + public var newBand: Bool + public var worked: Bool + + public init(newDxcc: Bool = true, newGrid: Bool = true, newBand: Bool = true, worked: Bool = true) { + self.newDxcc = newDxcc + self.newGrid = newGrid + self.newBand = newBand + self.worked = worked + } +} + +/// One logged QSO, reduced to the fields the annotator needs. +public struct LoggedQso: Equatable, Sendable { + public let call: String + public let grid: String + public let band: String + + public init(call: String, grid: String, band: String) { + self.call = call + self.grid = grid + self.band = band + } +} + +/// Pre-computed lookup index over the logbook, built once per render pass so +/// per-row classification is O(1). All keys are uppercased; grids are +/// truncated to their 4-char square. +public struct LogbookIndex: Sendable { + public let workedCalls: Set + public let workedGrids: Set + public let bandsByCall: [String: Set] + public let workedEntities: Set + + public init(qsos: [LoggedQso]) { + var calls = Set() + var grids = Set() + var bands = [String: Set]() + var entities = Set() + for qso in qsos { + let call = qso.call.uppercased() + guard !call.isEmpty else { continue } + calls.insert(call) + let grid = grid4(qso.grid.uppercased()) + if grid.count >= 4 { grids.insert(grid) } + let band = qso.band.uppercased() + if !band.isEmpty { bands[call, default: []].insert(band) } + if let entity = DxccPrefix.entity(for: call) { + entities.insert(entity.name) + } + } + workedCalls = calls + workedGrids = grids + bandsByCall = bands + workedEntities = entities + } + + public static let empty = LogbookIndex(qsos: []) +} + +/// True when a decode's destination field is a CQ (plain or with modifier, +/// e.g. "CQ", "CQ POTA", "CQ DX"). +public func isCQMessage(callTo: String) -> Bool { + let to = callTo.trimmingCharacters(in: .whitespaces).uppercased() + return to == "CQ" || to.hasPrefix("CQ ") +} + +/// True when a decode is directed at the operator's own callsign. +public func isDirectedToMe(callTo: String, myCall: String) -> Bool { + let my = myCall.trimmingCharacters(in: .whitespaces).uppercased() + guard !my.isEmpty else { return false } + return callTo.trimmingCharacters(in: .whitespaces).uppercased() == my +} + +/// True when a CQ looks like a POTA activation: explicit "CQ POTA" modifier, +/// or a park reference (e.g. "K-1234", "VE-5082") in the free-text/extra field. +public func isPotaCq(callTo: String, extra: String) -> Bool { + guard isCQMessage(callTo: callTo) else { return false } + let to = callTo.trimmingCharacters(in: .whitespaces).uppercased() + if to == "CQ POTA" || to.hasPrefix("CQ POTA ") { return true } + return looksLikeParkRef(extra) +} + +/// True when a token matches the POTA park-reference shape: 1–4 alphanumeric +/// program characters, a dash, then 4–5 digits (K-1234, VE-5082, DL-03000). +public func looksLikeParkRef(_ token: String) -> Bool { + let t = token.trimmingCharacters(in: .whitespaces).uppercased() + guard let dash = t.firstIndex(of: "-") else { return false } + let program = t[t.startIndex.. DecodeHighlight? { + let call = callFrom.trimmingCharacters(in: .whitespaces).uppercased() + let cq = isCQMessage(callTo: callTo) + + if isDirectedToMe(callTo: callTo, myCall: myCall) { return .pending } + if isPotaCq(callTo: callTo, extra: extra) { return .pota } + + if toggles.newDxcc, + let entity = DxccPrefix.entity(for: call), + !index.workedEntities.contains(entity.name) { + return .newDxcc + } + + let square = grid4(grid.uppercased()) + if toggles.newGrid, + square.count >= 4, + gridToLatLon(square) != nil, + !index.workedGrids.contains(square) { + return .newGrid + } + + let workedBands = index.bandsByCall[call] ?? [] + let workedThisBand = workedBands.contains(band.uppercased()) + if toggles.newBand, !workedBands.isEmpty, !workedThisBand { + return .newBand + } + + if toggles.worked, index.workedCalls.contains(call) { + return .worked + } + + if cq { return .cq } + return nil +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/DisplayFormatters.swift b/ios/FT8AFKit/Sources/FT8Engine/DisplayFormatters.swift new file mode 100644 index 000000000..b71d4fbee --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/DisplayFormatters.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Pure display formatters for the decode list (age, distance). Kept in the +/// kit so they are host-testable; the SwiftUI rows are thin wrappers. + +/// Relative age of a decode, Android-style buckets: +/// <5 s → "now", <60 s → "32s", <1 h → "5m", <24 h → "2h", else "3d". +/// Negative deltas (clock skew) clamp to "now". +public func relativeAge(secondsAgo: Int) -> String { + let s = max(0, secondsAgo) + switch s { + case ..<5: return "now" + case ..<60: return "\(s)s" + case ..<3600: return "\(s / 60)m" + case ..<86400: return "\(s / 3600)h" + default: return "\(s / 86400)d" + } +} + +/// Great-circle distance in kilometers between two Maidenhead locators, or +/// nil when either grid is malformed (uses the shared `gridToLatLon`, which +/// also rejects the "RR73"/"RR" sign-off tokens). +public func gridDistanceKm(from myGrid: String, to theirGrid: String) -> Double? { + guard let a = gridToLatLon(myGrid), let b = gridToLatLon(theirGrid) else { return nil } + let earthRadiusKm = 6371.0 + let dLat = (b.0 - a.0) * .pi / 180 + let dLon = (b.1 - a.1) * .pi / 180 + let lat1 = a.0 * .pi / 180 + let lat2 = b.0 * .pi / 180 + let sinDLat = sin(dLat / 2) + let sinDLon = sin(dLon / 2) + let h = sinDLat * sinDLat + cos(lat1) * cos(lat2) * sinDLon * sinDLon + return 2 * earthRadiusKm * asin(min(1, sqrt(h))) +} + +/// Kilometers → miles. +public func kilometersToMiles(_ km: Double) -> Double { km * 0.621371 } + +/// Format a QSO-path distance for the decode meta row: whole units with the +/// operator's preferred unit ("1832 km" / "1139 mi"). Zero/negative → "". +public func formatQsoDistance(km: Double, inMiles: Bool) -> String { + guard km > 0 else { return "" } + let value = inMiles ? kilometersToMiles(km) : km + let rounded = Int(value.rounded()) + guard rounded > 0 else { return "" } + return "\(rounded) \(inMiles ? "mi" : "km")" +} + +/// Convenience: formatted grid-to-grid distance, or "" when unavailable. +public func gridDistanceText(from myGrid: String, to theirGrid: String, inMiles: Bool) -> String { + guard let km = gridDistanceKm(from: myGrid, to: theirGrid) else { return "" } + return formatQsoDistance(km: km, inMiles: inMiles) +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/DxccPrefix.swift b/ios/FT8AFKit/Sources/FT8Engine/DxccPrefix.swift new file mode 100644 index 000000000..cea1b208a --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/DxccPrefix.swift @@ -0,0 +1,333 @@ +import Foundation + +/// A DXCC entity resolved from a callsign prefix. +public struct DxccEntity: Equatable, Sendable { + /// Entity display name (e.g. "United States", "Japan"). + public let name: String + /// Continent code: NA / SA / EU / AF / AS / OC / AN. + public let continent: String + + public init(name: String, continent: String) { + self.name = name + self.continent = continent + } +} + +/// Compact callsign-prefix → DXCC-entity heuristic (top ~130 entities). +/// +/// Mirrors what the Android app derives from its bundled callsign database, +/// scaled down to a longest-prefix-match table that covers the entities an +/// operator actually sees on FT8. Used for NEW DXCC highlighting, the decode +/// location line, and the DX-only / continent filters — never for logging, so +/// a rare-prefix miss is cosmetic only. +public enum DxccPrefix { + /// Resolve the DXCC entity for a callsign, or nil when no prefix matches. + /// + /// Portable designators are handled by matching every `/`-separated + /// segment and keeping the segment whose matched prefix is longest — + /// "EA8/W1AW" → Canary Islands (EA8, 3 chars beats W, 1 char); + /// "W1AW/P" → United States (bare "P" matches nothing). + /// Ties go to the earlier segment, so "W1AW/M" stays United States. + public static func entity(for call: String) -> DxccEntity? { + let segments = call.uppercased().split(separator: "/").map(String.init) + var best: (length: Int, entity: DxccEntity)? = nil + for segment in segments { + guard let (length, entity) = longestPrefixMatch(segment) else { continue } + if best == nil || length > best!.length { + best = (length, entity) + } + } + return best?.entity + } + + /// Continent code (NA/SA/EU/AF/AS/OC/AN) for a callsign, or nil when unknown. + public static func continent(for call: String) -> String? { + entity(for: call)?.continent + } + + /// Longest table prefix matching the start of `segment`, with its length. + static func longestPrefixMatch(_ segment: String) -> (Int, DxccEntity)? { + guard !segment.isEmpty else { return nil } + let maxLen = min(4, segment.count) + for len in stride(from: maxLen, through: 1, by: -1) { + let prefix = String(segment.prefix(len)) + if let entity = table[prefix] { + return (len, entity) + } + } + return nil + } + + // MARK: - Prefix table + + private static let usa = DxccEntity(name: "United States", continent: "NA") + private static let canada = DxccEntity(name: "Canada", continent: "NA") + private static let japan = DxccEntity(name: "Japan", continent: "AS") + private static let germany = DxccEntity(name: "Germany", continent: "EU") + private static let england = DxccEntity(name: "England", continent: "EU") + private static let russiaEu = DxccEntity(name: "European Russia", continent: "EU") + private static let russiaAs = DxccEntity(name: "Asiatic Russia", continent: "AS") + private static let ukraine = DxccEntity(name: "Ukraine", continent: "EU") + private static let spain = DxccEntity(name: "Spain", continent: "EU") + private static let brazil = DxccEntity(name: "Brazil", continent: "SA") + private static let cuba = DxccEntity(name: "Cuba", continent: "NA") + private static let netherlands = DxccEntity(name: "Netherlands", continent: "EU") + private static let sweden = DxccEntity(name: "Sweden", continent: "EU") + private static let poland = DxccEntity(name: "Poland", continent: "EU") + private static let southKorea = DxccEntity(name: "South Korea", continent: "AS") + private static let argentina = DxccEntity(name: "Argentina", continent: "SA") + private static let chile = DxccEntity(name: "Chile", continent: "SA") + private static let southAfrica = DxccEntity(name: "South Africa", continent: "AF") + + static let table: [String: DxccEntity] = [ + // ---- North America ---- + "K": usa, "W": usa, "N": usa, "A": usa, + "KL": DxccEntity(name: "Alaska", continent: "NA"), + "KH6": DxccEntity(name: "Hawaii", continent: "OC"), + "KH2": DxccEntity(name: "Guam", continent: "OC"), + "KP4": DxccEntity(name: "Puerto Rico", continent: "NA"), + "KP2": DxccEntity(name: "US Virgin Islands", continent: "NA"), + "VE": canada, "VA": canada, "VO": canada, "VY": canada, + "XE": DxccEntity(name: "Mexico", continent: "NA"), + "XF": DxccEntity(name: "Mexico", continent: "NA"), + "TI": DxccEntity(name: "Costa Rica", continent: "NA"), + "TG": DxccEntity(name: "Guatemala", continent: "NA"), + "YS": DxccEntity(name: "El Salvador", continent: "NA"), + "HR": DxccEntity(name: "Honduras", continent: "NA"), + "YN": DxccEntity(name: "Nicaragua", continent: "NA"), + "HP": DxccEntity(name: "Panama", continent: "NA"), + "HI": DxccEntity(name: "Dominican Republic", continent: "NA"), + "CO": cuba, "CM": cuba, + "6Y": DxccEntity(name: "Jamaica", continent: "NA"), + "ZF": DxccEntity(name: "Cayman Islands", continent: "NA"), + "C6": DxccEntity(name: "Bahamas", continent: "NA"), + "V3": DxccEntity(name: "Belize", continent: "NA"), + "VP9": DxccEntity(name: "Bermuda", continent: "NA"), + "8P": DxccEntity(name: "Barbados", continent: "NA"), + "FM": DxccEntity(name: "Martinique", continent: "NA"), + "FG": DxccEntity(name: "Guadeloupe", continent: "NA"), + "J3": DxccEntity(name: "Grenada", continent: "NA"), + "OX": DxccEntity(name: "Greenland", continent: "NA"), + + // ---- South America ---- + "PY": brazil, "PP": brazil, "PR": brazil, "PS": brazil, "PT": brazil, "PU": brazil, + "LU": argentina, "LW": argentina, + "CE": chile, "CA": chile, "XQ": chile, + "HK": DxccEntity(name: "Colombia", continent: "SA"), + "HJ": DxccEntity(name: "Colombia", continent: "SA"), + "YV": DxccEntity(name: "Venezuela", continent: "SA"), + "OA": DxccEntity(name: "Peru", continent: "SA"), + "HC": DxccEntity(name: "Ecuador", continent: "SA"), + "CX": DxccEntity(name: "Uruguay", continent: "SA"), + "ZP": DxccEntity(name: "Paraguay", continent: "SA"), + "CP": DxccEntity(name: "Bolivia", continent: "SA"), + "PZ": DxccEntity(name: "Suriname", continent: "SA"), + "9Y": DxccEntity(name: "Trinidad & Tobago", continent: "SA"), + + // ---- Europe ---- + "G": england, "M": england, "2E": england, + "GM": DxccEntity(name: "Scotland", continent: "EU"), + "MM": DxccEntity(name: "Scotland", continent: "EU"), + "2M": DxccEntity(name: "Scotland", continent: "EU"), + "GW": DxccEntity(name: "Wales", continent: "EU"), + "MW": DxccEntity(name: "Wales", continent: "EU"), + "2W": DxccEntity(name: "Wales", continent: "EU"), + "GI": DxccEntity(name: "Northern Ireland", continent: "EU"), + "MI": DxccEntity(name: "Northern Ireland", continent: "EU"), + "GD": DxccEntity(name: "Isle of Man", continent: "EU"), + "GJ": DxccEntity(name: "Jersey", continent: "EU"), + "GU": DxccEntity(name: "Guernsey", continent: "EU"), + "EI": DxccEntity(name: "Ireland", continent: "EU"), + "EJ": DxccEntity(name: "Ireland", continent: "EU"), + "F": DxccEntity(name: "France", continent: "EU"), + "DA": germany, "DB": germany, "DC": germany, "DD": germany, "DF": germany, + "DG": germany, "DH": germany, "DJ": germany, "DK": germany, "DL": germany, + "DM": germany, "DN": germany, "DO": germany, "DP": germany, "DR": germany, + "I": DxccEntity(name: "Italy", continent: "EU"), + "EA": spain, "EB": spain, "EC": spain, "ED": spain, "EE": spain, + "EF": spain, "EG": spain, "EH": spain, + "EA6": DxccEntity(name: "Balearic Islands", continent: "EU"), + "EA8": DxccEntity(name: "Canary Islands", continent: "AF"), + "EA9": DxccEntity(name: "Ceuta & Melilla", continent: "AF"), + "CT": DxccEntity(name: "Portugal", continent: "EU"), + "CT3": DxccEntity(name: "Madeira Islands", continent: "AF"), + "CU": DxccEntity(name: "Azores", continent: "EU"), + "ON": DxccEntity(name: "Belgium", continent: "EU"), + "OO": DxccEntity(name: "Belgium", continent: "EU"), + "OT": DxccEntity(name: "Belgium", continent: "EU"), + "PA": netherlands, "PB": netherlands, "PC": netherlands, "PD": netherlands, + "PE": netherlands, "PF": netherlands, "PG": netherlands, "PH": netherlands, + "PI": netherlands, + "HB": DxccEntity(name: "Switzerland", continent: "EU"), + "HB0": DxccEntity(name: "Liechtenstein", continent: "EU"), + "OE": DxccEntity(name: "Austria", continent: "EU"), + "OZ": DxccEntity(name: "Denmark", continent: "EU"), + "LA": DxccEntity(name: "Norway", continent: "EU"), + "LB": DxccEntity(name: "Norway", continent: "EU"), + "SM": sweden, "SA": sweden, "SK": sweden, "SL": sweden, + "OH": DxccEntity(name: "Finland", continent: "EU"), + "OH0": DxccEntity(name: "Aland Islands", continent: "EU"), + "OY": DxccEntity(name: "Faroe Islands", continent: "EU"), + "TF": DxccEntity(name: "Iceland", continent: "EU"), + "SP": poland, "SN": poland, "SO": poland, "SQ": poland, "3Z": poland, + "OK": DxccEntity(name: "Czech Republic", continent: "EU"), + "OL": DxccEntity(name: "Czech Republic", continent: "EU"), + "OM": DxccEntity(name: "Slovakia", continent: "EU"), + "HA": DxccEntity(name: "Hungary", continent: "EU"), + "HG": DxccEntity(name: "Hungary", continent: "EU"), + "YO": DxccEntity(name: "Romania", continent: "EU"), + "YP": DxccEntity(name: "Romania", continent: "EU"), + "YQ": DxccEntity(name: "Romania", continent: "EU"), + "YR": DxccEntity(name: "Romania", continent: "EU"), + "LZ": DxccEntity(name: "Bulgaria", continent: "EU"), + "SV": DxccEntity(name: "Greece", continent: "EU"), + "SW": DxccEntity(name: "Greece", continent: "EU"), + "SX": DxccEntity(name: "Greece", continent: "EU"), + "SY": DxccEntity(name: "Greece", continent: "EU"), + "SZ": DxccEntity(name: "Greece", continent: "EU"), + "9A": DxccEntity(name: "Croatia", continent: "EU"), + "S5": DxccEntity(name: "Slovenia", continent: "EU"), + "E7": DxccEntity(name: "Bosnia-Herzegovina", continent: "EU"), + "YU": DxccEntity(name: "Serbia", continent: "EU"), + "YT": DxccEntity(name: "Serbia", continent: "EU"), + "Z3": DxccEntity(name: "North Macedonia", continent: "EU"), + "ZA": DxccEntity(name: "Albania", continent: "EU"), + "4O": DxccEntity(name: "Montenegro", continent: "EU"), + "Z6": DxccEntity(name: "Kosovo", continent: "EU"), + // Russia: R/UA–UI default to European; the 8/9/0 call areas are Asiatic. + "R": russiaEu, + "UA": russiaEu, "UB": russiaEu, "UC": russiaEu, "UD": russiaEu, + "UE": russiaEu, "UF": russiaEu, "UG": russiaEu, "UH": russiaEu, "UI": russiaEu, + "R8": russiaAs, "R9": russiaAs, "R0": russiaAs, + "RA8": russiaAs, "RA9": russiaAs, "RA0": russiaAs, + "UA8": russiaAs, "UA9": russiaAs, "UA0": russiaAs, + "UR": ukraine, "US": ukraine, "UT": ukraine, "UU": ukraine, "UV": ukraine, + "UW": ukraine, "UX": ukraine, "UY": ukraine, "UZ": ukraine, + "EM": ukraine, "EN": ukraine, "EO": ukraine, + "EU": DxccEntity(name: "Belarus", continent: "EU"), + "EV": DxccEntity(name: "Belarus", continent: "EU"), + "EW": DxccEntity(name: "Belarus", continent: "EU"), + "YL": DxccEntity(name: "Latvia", continent: "EU"), + "LY": DxccEntity(name: "Lithuania", continent: "EU"), + "ES": DxccEntity(name: "Estonia", continent: "EU"), + "ER": DxccEntity(name: "Moldova", continent: "EU"), + "LX": DxccEntity(name: "Luxembourg", continent: "EU"), + "9H": DxccEntity(name: "Malta", continent: "EU"), + "ZB": DxccEntity(name: "Gibraltar", continent: "EU"), + "T7": DxccEntity(name: "San Marino", continent: "EU"), + "HV": DxccEntity(name: "Vatican City", continent: "EU"), + "C3": DxccEntity(name: "Andorra", continent: "EU"), + "3A": DxccEntity(name: "Monaco", continent: "EU"), + + // ---- Asia ---- + "JA": japan, "JE": japan, "JF": japan, "JG": japan, "JH": japan, + "JI": japan, "JJ": japan, "JK": japan, "JL": japan, "JM": japan, + "JN": japan, "JO": japan, "JP": japan, "JQ": japan, "JR": japan, + "JS": japan, "7J": japan, "7K": japan, "7L": japan, "7M": japan, "7N": japan, + "HL": southKorea, "DS": southKorea, "6K": southKorea, + "P5": DxccEntity(name: "North Korea", continent: "AS"), + "B": DxccEntity(name: "China", continent: "AS"), + "BV": DxccEntity(name: "Taiwan", continent: "AS"), + "VR2": DxccEntity(name: "Hong Kong", continent: "AS"), + "XX9": DxccEntity(name: "Macao", continent: "AS"), + "VU": DxccEntity(name: "India", continent: "AS"), + "AP": DxccEntity(name: "Pakistan", continent: "AS"), + "4S": DxccEntity(name: "Sri Lanka", continent: "AS"), + "S2": DxccEntity(name: "Bangladesh", continent: "AS"), + "9N": DxccEntity(name: "Nepal", continent: "AS"), + "A5": DxccEntity(name: "Bhutan", continent: "AS"), + "HS": DxccEntity(name: "Thailand", continent: "AS"), + "E2": DxccEntity(name: "Thailand", continent: "AS"), + "XV": DxccEntity(name: "Vietnam", continent: "AS"), + "3W": DxccEntity(name: "Vietnam", continent: "AS"), + "XU": DxccEntity(name: "Cambodia", continent: "AS"), + "XW": DxccEntity(name: "Laos", continent: "AS"), + "XZ": DxccEntity(name: "Myanmar", continent: "AS"), + "9V": DxccEntity(name: "Singapore", continent: "AS"), + "9M": DxccEntity(name: "Malaysia", continent: "AS"), + "HZ": DxccEntity(name: "Saudi Arabia", continent: "AS"), + "7Z": DxccEntity(name: "Saudi Arabia", continent: "AS"), + "A4": DxccEntity(name: "Oman", continent: "AS"), + "A6": DxccEntity(name: "United Arab Emirates", continent: "AS"), + "A7": DxccEntity(name: "Qatar", continent: "AS"), + "A9": DxccEntity(name: "Bahrain", continent: "AS"), + "9K": DxccEntity(name: "Kuwait", continent: "AS"), + "OD": DxccEntity(name: "Lebanon", continent: "AS"), + "YK": DxccEntity(name: "Syria", continent: "AS"), + "JY": DxccEntity(name: "Jordan", continent: "AS"), + "YI": DxccEntity(name: "Iraq", continent: "AS"), + "EP": DxccEntity(name: "Iran", continent: "AS"), + "4X": DxccEntity(name: "Israel", continent: "AS"), + "4Z": DxccEntity(name: "Israel", continent: "AS"), + "TA": DxccEntity(name: "Turkey", continent: "AS"), + "TB": DxccEntity(name: "Turkey", continent: "AS"), + "TC": DxccEntity(name: "Turkey", continent: "AS"), + "EK": DxccEntity(name: "Armenia", continent: "AS"), + "4J": DxccEntity(name: "Azerbaijan", continent: "AS"), + "4K": DxccEntity(name: "Azerbaijan", continent: "AS"), + "4L": DxccEntity(name: "Georgia", continent: "AS"), + "UN": DxccEntity(name: "Kazakhstan", continent: "AS"), + "UP": DxccEntity(name: "Kazakhstan", continent: "AS"), + "UK": DxccEntity(name: "Uzbekistan", continent: "AS"), + "UJ": DxccEntity(name: "Uzbekistan", continent: "AS"), + "EX": DxccEntity(name: "Kyrgyzstan", continent: "AS"), + "EY": DxccEntity(name: "Tajikistan", continent: "AS"), + "EZ": DxccEntity(name: "Turkmenistan", continent: "AS"), + "T6": DxccEntity(name: "Afghanistan", continent: "AS"), + "JT": DxccEntity(name: "Mongolia", continent: "AS"), + "8Q": DxccEntity(name: "Maldives", continent: "AS"), + + // ---- Oceania ---- + "VK": DxccEntity(name: "Australia", continent: "OC"), + "ZL": DxccEntity(name: "New Zealand", continent: "OC"), + "YB": DxccEntity(name: "Indonesia", continent: "OC"), + "YC": DxccEntity(name: "Indonesia", continent: "OC"), + "YD": DxccEntity(name: "Indonesia", continent: "OC"), + "YE": DxccEntity(name: "Indonesia", continent: "OC"), + "DU": DxccEntity(name: "Philippines", continent: "OC"), + "DV": DxccEntity(name: "Philippines", continent: "OC"), + "DW": DxccEntity(name: "Philippines", continent: "OC"), + "DX": DxccEntity(name: "Philippines", continent: "OC"), + "V8": DxccEntity(name: "Brunei", continent: "OC"), + "P2": DxccEntity(name: "Papua New Guinea", continent: "OC"), + "FK": DxccEntity(name: "New Caledonia", continent: "OC"), + "3D2": DxccEntity(name: "Fiji", continent: "OC"), + "5W": DxccEntity(name: "Samoa", continent: "OC"), + "A3": DxccEntity(name: "Tonga", continent: "OC"), + "YJ": DxccEntity(name: "Vanuatu", continent: "OC"), + + // ---- Africa ---- + "ZS": southAfrica, "ZR": southAfrica, "ZT": southAfrica, "ZU": southAfrica, + "SU": DxccEntity(name: "Egypt", continent: "AF"), + "CN": DxccEntity(name: "Morocco", continent: "AF"), + "7X": DxccEntity(name: "Algeria", continent: "AF"), + "3V": DxccEntity(name: "Tunisia", continent: "AF"), + "5A": DxccEntity(name: "Libya", continent: "AF"), + "ST": DxccEntity(name: "Sudan", continent: "AF"), + "ET": DxccEntity(name: "Ethiopia", continent: "AF"), + "5Z": DxccEntity(name: "Kenya", continent: "AF"), + "5H": DxccEntity(name: "Tanzania", continent: "AF"), + "9J": DxccEntity(name: "Zambia", continent: "AF"), + "Z2": DxccEntity(name: "Zimbabwe", continent: "AF"), + "7Q": DxccEntity(name: "Malawi", continent: "AF"), + "C9": DxccEntity(name: "Mozambique", continent: "AF"), + "A2": DxccEntity(name: "Botswana", continent: "AF"), + "V5": DxccEntity(name: "Namibia", continent: "AF"), + "5N": DxccEntity(name: "Nigeria", continent: "AF"), + "9G": DxccEntity(name: "Ghana", continent: "AF"), + "TU": DxccEntity(name: "Cote d'Ivoire", continent: "AF"), + "6W": DxccEntity(name: "Senegal", continent: "AF"), + "EL": DxccEntity(name: "Liberia", continent: "AF"), + "TZ": DxccEntity(name: "Mali", continent: "AF"), + "5R": DxccEntity(name: "Madagascar", continent: "AF"), + "3B8": DxccEntity(name: "Mauritius", continent: "AF"), + "FR": DxccEntity(name: "Reunion Island", continent: "AF"), + "D2": DxccEntity(name: "Angola", continent: "AF"), + "D4": DxccEntity(name: "Cape Verde", continent: "AF"), + "9Q": DxccEntity(name: "DR Congo", continent: "AF"), + "TR": DxccEntity(name: "Gabon", continent: "AF"), + "TJ": DxccEntity(name: "Cameroon", continent: "AF"), + "9X": DxccEntity(name: "Rwanda", continent: "AF"), + "5X": DxccEntity(name: "Uganda", continent: "AF"), + ] +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/OnlineLogAdif.swift b/ios/FT8AFKit/Sources/FT8Engine/OnlineLogAdif.swift new file mode 100644 index 000000000..56fdf4c2a --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/OnlineLogAdif.swift @@ -0,0 +1,34 @@ +import Foundation + +public extension Adif { + /// Single-record ADIF body for online logbook uploads (Cloudlog/Wavelog + /// "api/qso" and the QRZ logbook INSERT). Ports Android + /// `ThirdPartyService.QSLRecordToADIF`: no `` header, no QSL flags, + /// same field order, freq passed through as an MHz string, and the comment + /// always emitted (even when empty). Lengths are UTF-8 byte counts. + static func singleRecord(_ r: QsoRecord) -> String { + var out = "" + append(&out, "call", r.call) + append(&out, "gridsquare", r.gridsquare) + append(&out, "mode", r.mode) + append(&out, "rst_sent", r.rstSent) + append(&out, "rst_rcvd", r.rstRcvd) + append(&out, "qso_date", r.qsoDate) + append(&out, "time_on", r.timeOn) + append(&out, "band", r.band) + append(&out, "qso_date_off", r.qsoDateOff) + append(&out, "time_off", r.timeOff) + append(&out, "freq", r.freq) + append(&out, "station_callsign", r.stationCallsign) + append(&out, "my_gridsquare", r.myGridsquare) + out += "\(r.comment) \n" + return out + } + + /// `value ` — emitted only when the value is non-empty + /// (matches Android's appendAdif / the null-checks in QSLRecordToADIF). + private static func append(_ out: inout String, _ name: String, _ value: String) { + guard !value.isEmpty else { return } + out += "<\(name):\(value.utf8.count)>\(value) " + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/OnlineLogSync.swift b/ios/FT8AFKit/Sources/FT8Engine/OnlineLogSync.swift new file mode 100644 index 000000000..f5409a0e7 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/OnlineLogSync.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Pure catch-up-sync planning over the QSO log — which records still need an +/// upload to at least one enabled service. Mirrors Android +/// `ThirdPartyService.unsyncedFilter` / `countUnsyncedQSOs` so the badge count +/// and the actual sync always agree. +public enum OnlineLogSync { + + /// Records still awaiting upload to an enabled service, in log order. + /// Empty when neither service is enabled (nothing to do). + public static func unsynced( + _ records: [QsoRecord], cloudlogEnabled: Bool, qrzEnabled: Bool + ) -> [QsoRecord] { + guard cloudlogEnabled || qrzEnabled else { return [] } + return records.filter { r in + (cloudlogEnabled && !r.syncedCloudlog) || (qrzEnabled && !r.syncedQrz) + } + } + + /// Number of records `unsynced` would return. + public static func unsyncedCount( + _ records: [QsoRecord], cloudlogEnabled: Bool, qrzEnabled: Bool + ) -> Int { + unsynced(records, cloudlogEnabled: cloudlogEnabled, qrzEnabled: qrzEnabled).count + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/PotaActivations.swift b/ios/FT8AFKit/Sources/FT8Engine/PotaActivations.swift new file mode 100644 index 000000000..37b7586f7 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/PotaActivations.swift @@ -0,0 +1,113 @@ +import Foundation + +/// One POTA activation session, persisted as JSON by the app's activation store. +/// Mirrors Android's `pota_activation` row (`PotaActivation` model): a park ref +/// (comma-separated for two-fers), a start/end window, and the QSO count frozen +/// at end time. An open activation has `endedAtMs == nil`. +public struct PotaActivationRecord: Codable, Equatable, Identifiable, Sendable { + public var id: String + public var parkRef: String + public var notes: String + public var startedAtMs: Int64 + public var endedAtMs: Int64? + /// QSO count frozen when the activation ends. While active, derive the live + /// count from the logbook via `PotaQsoWindow.qsoCount` instead. + public var qsoCount: Int + + public init( + id: String = UUID().uuidString, + parkRef: String, + notes: String = "", + startedAtMs: Int64, + endedAtMs: Int64? = nil, + qsoCount: Int = 0 + ) { + self.id = id + self.parkRef = parkRef + self.notes = notes + self.startedAtMs = startedAtMs + self.endedAtMs = endedAtMs + self.qsoCount = qsoCount + } + + public var isActive: Bool { endedAtMs == nil } + + /// Individual park references (splits comma-separated `parkRef`), matching + /// Android's multi-park ("two-fer") handling. + public var parkRefs: [String] { + parkRef.split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// Human-readable display: "K-1234 + K-5678". + public var parkRefsDisplay: String { parkRefs.joined(separator: " + ") } +} + +/// Past activations to list (newest first). Drops any still-active row so the +/// in-progress activation is never shown as "past" — a direct port of Android's +/// `historyForDisplay`. +public func potaHistoryForDisplay(_ all: [PotaActivationRecord]) -> [PotaActivationRecord] { + all.filter { !$0.isActive }.sorted { $0.startedAtMs > $1.startedAtMs } +} + +/// Scopes logged QSOs to a POTA activation's time window. Port of Android's +/// `PotaQsoWindow` (shared by its DAO and ADIF exporter): QSO rows are compared +/// as fixed-width `yyyyMMddHHmmss` UTC stamps built from `qso_date` + a +/// normalized `time_on`, against stamps of the activation's start/end instants. +public enum PotaQsoWindow { + + /// Upper bound for an open (still-active) activation that has no end time. + public static let openEnd = "99991231235959" + + /// Format an epoch-millis instant as the matching `yyyyMMddHHmmss` UTC stamp. + public static func stamp(ms: Int64) -> String { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "GMT") + fmt.dateFormat = "yyyyMMddHHmmss" + return fmt.string(from: Date(timeIntervalSince1970: Double(ms) / 1000.0)) + } + + /// Fixed-width stamp for one QSO row. `time_on` is stored variable-width: + /// app-logged rows are HHMMSS, but imported ADIF rows may carry HHMM or drop + /// a leading zero ("815" for 08:15). Same normalization as Android's + /// `ROW_STAMP` SQL: pad even-width times to HHMMSS with trailing zeros; + /// prepend a zero to odd-width times (a dropped leading zero) first. + public static func rowStamp(qsoDate: String, timeOn: String) -> String { + let time: String + if timeOn.isEmpty { + time = "000000" + } else if timeOn.count % 2 == 1 { + time = String(("0" + timeOn + "000000").prefix(6)) + } else { + time = String((timeOn + "000000").prefix(6)) + } + return qsoDate + time + } + + /// The records logged inside the activation window, in QSO order. + /// `endedAtMs == nil` (still active) uses the far-future `openEnd` bound. + public static func qsos( + in records: [QsoRecord], startedAtMs: Int64, endedAtMs: Int64? + ) -> [QsoRecord] { + let start = stamp(ms: startedAtMs) + let end = endedAtMs.map { stamp(ms: $0) } ?? openEnd + return records + .filter { r in + let s = rowStamp(qsoDate: r.qsoDate, timeOn: r.timeOn) + return s >= start && s <= end + } + .sorted { + rowStamp(qsoDate: $0.qsoDate, timeOn: $0.timeOn) + < rowStamp(qsoDate: $1.qsoDate, timeOn: $1.timeOn) + } + } + + /// Live QSO count for an activation (drives the 10-QSO progress bar). + public static func qsoCount( + records: [QsoRecord], startedAtMs: Int64, endedAtMs: Int64? + ) -> Int { + qsos(in: records, startedAtMs: startedAtMs, endedAtMs: endedAtMs).count + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/PotaAdif.swift b/ios/FT8AFKit/Sources/FT8Engine/PotaAdif.swift new file mode 100644 index 000000000..54f14aa4f --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/PotaAdif.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Per-park POTA ADIF export — a port of Android's +/// `PotaAdifExporter.buildActivationAdif`. New functions only; the existing +/// `Adif.export`/`Adif.parse` used by the general logbook are untouched. +extension Adif { + + /// One park's ADIF document plus the filename it should be shared under. + /// Mirrors Android's `PotaAdifExporter.NamedAdif`. + public struct PotaDocument: Equatable, Sendable { + public let parkRef: String + public let filename: String + public let content: String + + public init(parkRef: String, filename: String, content: String) { + self.parkRef = parkRef + self.filename = filename + self.content = content + } + } + + /// Build one ADIF document per park reference for `activation`, from the + /// QSOs logged inside its time window (same `PotaQsoWindow` scoping the + /// live progress count uses, so the export always agrees with the UI). + /// + /// pota.app's uploader expects each QSO to carry MY_SIG=POTA / + /// MY_SIG_INFO=; for multi-park activations (two-fers) each + /// document contains the same QSOs with MY_SIG_INFO pinned to that single + /// park. Returns [] when no QSOs fall in the window — callers must treat + /// that as "nothing to share" rather than emitting header-only files. + public static func potaActivationExport( + records: [QsoRecord], activation: PotaActivationRecord + ) -> [PotaDocument] { + let rows = PotaQsoWindow.qsos( + in: records, + startedAtMs: activation.startedAtMs, + endedAtMs: activation.endedAtMs + ) + if rows.isEmpty { return [] } + + let ts = potaFilenameStamp(ms: activation.startedAtMs) + return activation.parkRefs.map { parkRef in + var out = "FT8AF POTA Activation \(parkRef)\n" + out += "3.1.4 " + out += "FT8AF " + out += "\n" + for r in rows { + potaField(&out, "CALL", r.call) + potaField(&out, "GRIDSQUARE", r.gridsquare) + potaMode(&out, r.mode) + potaField(&out, "BAND", r.band) + potaField(&out, "FREQ", r.freq) + potaField(&out, "RST_SENT", r.rstSent) + potaField(&out, "RST_RCVD", r.rstRcvd) + potaField(&out, "QSO_DATE", r.qsoDate) + potaField(&out, "TIME_ON", r.timeOn) + potaField(&out, "TIME_OFF", r.timeOff) + potaField(&out, "STATION_CALLSIGN", r.stationCallsign) + potaField(&out, "MY_GRIDSQUARE", r.myGridsquare) + potaField(&out, "MY_SIG", "POTA") + // Pinned to this single park (not the comma-separated activation ref). + potaField(&out, "MY_SIG_INFO", parkRef) + out += "\n" + } + return PotaDocument( + parkRef: parkRef, + filename: "pota-\(parkRef)-\(ts).adi", + content: out + ) + } + } + + /// FT4/FT2 are ADIF *submodes* of MFSK — a bare `FT2` is what + /// pota.app rejects as invalid. For those, emit MODE=MFSK + SUBMODE; every + /// other mode (FT8 is a first-class ADIF mode) passes through unchanged. + /// Port of Android's `AdifFormat.mfskSubmode` + `PotaAdifExporter.adifMode`. + internal static func mfskSubmode(_ mode: String) -> String? { + let normalized = mode.trimmingCharacters(in: .whitespaces).uppercased() + return (normalized == "FT4" || normalized == "FT2") ? normalized : nil + } + + private static func potaMode(_ out: inout String, _ mode: String) { + if let submode = mfskSubmode(mode) { + potaField(&out, "MODE", "MFSK") + potaField(&out, "SUBMODE", submode) + } else { + potaField(&out, "MODE", mode) + } + } + + /// `value ` — length in UTF-8 bytes (not characters), skipping + /// empty values, exactly like Android's `PotaAdifExporter.adifField`. + private static func potaField(_ out: inout String, _ name: String, _ value: String) { + if value.isEmpty { return } + out += "<\(name):\(value.utf8.count)>\(value) " + } + + /// "yyyyMMdd-HHmm" stamp for the export filename. Rendered in UTC so the + /// same activation always produces the same filename regardless of the + /// device timezone (Android uses local time here; the difference is + /// cosmetic — the filename never reaches the POTA parser). + internal static func potaFilenameStamp(ms: Int64) -> String { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "GMT") + fmt.dateFormat = "yyyyMMdd-HHmm" + return fmt.string(from: Date(timeIntervalSince1970: Double(ms) / 1000.0)) + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/PotaSpots.swift b/ios/FT8AFKit/Sources/FT8Engine/PotaSpots.swift new file mode 100644 index 000000000..61e9382f3 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/PotaSpots.swift @@ -0,0 +1,140 @@ +import Foundation + +/// One row from the live POTA spots feed (`https://api.pota.app/spot/activator`). +/// Field set mirrors Android `radio.ks3ckc.ft8af.pota.model.PotaSpot`: the feed's +/// `name` key is the park name, `frequency` is a *string* in kHz. +public struct PotaSpot: Equatable, Identifiable, Sendable { + public var activator: String + public var frequencyKhz: Double + public var mode: String + public var reference: String + public var parkName: String + public var locationDesc: String + public var spotter: String + /// Raw feed timestamp, e.g. "2026-07-13T19:44:50" (UTC, optional fractional + /// seconds). Parsed by `PotaSpots.ageSeconds`. + public var spotTimeUtc: String + public var comments: String + + /// Same identity Android's hunt list keys rows by. + public var id: String { "\(activator)|\(reference)|\(frequencyKhz)" } + + public var frequencyHz: Int64 { Int64(frequencyKhz * 1000) } + + /// Display frequency, matching Android's `"%.1f kHz"`. + public var frequencyText: String { String(format: "%.1f kHz", frequencyKhz) } + + public var isFT8: Bool { mode.uppercased() == "FT8" } + + public init( + activator: String, frequencyKhz: Double, mode: String, reference: String, + parkName: String, locationDesc: String, spotter: String, + spotTimeUtc: String, comments: String + ) { + self.activator = activator + self.frequencyKhz = frequencyKhz + self.mode = mode + self.reference = reference + self.parkName = parkName + self.locationDesc = locationDesc + self.spotter = spotter + self.spotTimeUtc = spotTimeUtc + self.comments = comments + } +} + +/// Pure decode + display logic for the POTA activator-spot feed. Network-free; +/// the app-side `PotaService` owns the URLSession fetch. +public enum PotaSpots { + + /// The public activator-spot feed (same endpoint Android's `PotaClient` polls). + public static let activatorSpotsURLString = "https://api.pota.app/spot/activator" + + /// Decode the feed's JSON array. Tolerant the way Android's `optString` + /// parsing is: missing/null fields become "", `frequency` may be a string + /// ("14074", "14061.0") or a bare number, unparseable frequencies become 0, + /// and the activator callsign is uppercased. Returns nil only when the + /// payload isn't a JSON array (mirroring Android returning null on a parse + /// error); malformed *elements* are skipped rather than failing the batch. + public static func decode(_ data: Data) -> [PotaSpot]? { + guard let root = try? JSONSerialization.jsonObject(with: data), + let arr = root as? [Any] else { return nil } + var out: [PotaSpot] = [] + out.reserveCapacity(arr.count) + for element in arr { + guard let o = element as? [String: Any] else { continue } + out.append(PotaSpot( + activator: str(o["activator"]).uppercased(), + frequencyKhz: khz(o["frequency"]), + mode: str(o["mode"]), + reference: str(o["reference"]), + parkName: str(o["name"]), + locationDesc: str(o["locationDesc"]), + spotter: str(o["spotter"]), + spotTimeUtc: str(o["spotTime"]), + comments: str(o["comments"]) + )) + } + return out + } + + /// Hunt-list ordering + mode constraint: Android fetches with + /// `modeFilter = "FT8"` (case-insensitive) and sorts ascending by frequency. + /// The iOS toggle applies the same filter client-side so flipping it doesn't + /// need a refetch. + public static func forDisplay(_ spots: [PotaSpot], ft8Only: Bool) -> [PotaSpot] { + let filtered = ft8Only ? spots.filter(\.isFT8) : spots + return filtered.sorted { $0.frequencyKhz < $1.frequencyKhz } + } + + /// Seconds elapsed since the spot's UTC timestamp, or nil when the timestamp + /// doesn't parse. Accepts "yyyy-MM-dd'T'HH:mm:ss" with optional fractional + /// seconds / trailing zone designator (only the first 19 chars are read). + /// Negative ages (clock skew) clamp to 0. Feed the result to `relativeAge`. + public static func ageSeconds(spotTimeUtc: String, now: Date) -> Int? { + let trimmed = String(spotTimeUtc.prefix(19)) + guard trimmed.count == 19 else { return nil } + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "UTC") + fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + guard let date = fmt.date(from: trimmed) else { return nil } + return max(0, Int(now.timeIntervalSince(date))) + } + + /// Amateur band (in the app's "20M" naming) containing a spot frequency, or + /// nil when it falls outside the HF/6m allocations the band picker offers. + /// Ranges are the full band allocations, not just the FT8 dial, so a spot + /// anywhere in-band still maps (the app then tunes the band's FT8 dial). + public static func band(forFrequencyKhz khz: Double) -> String? { + switch khz { + case 1_800..<2_000: return "160M" + case 3_500..<4_000: return "80M" + case 5_250..<5_450: return "60M" + case 7_000..<7_300: return "40M" + case 10_100..<10_150: return "30M" + case 14_000..<14_350: return "20M" + case 18_068..<18_168: return "17M" + case 21_000..<21_450: return "15M" + case 24_890..<24_990: return "12M" + case 28_000..<29_700: return "10M" + case 50_000..<54_000: return "6M" + default: return nil + } + } + + // MARK: - Tolerant JSON field extraction + + private static func str(_ any: Any?) -> String { + if let s = any as? String { return s } + if any == nil || any is NSNull { return "" } + if let n = any as? NSNumber { return n.stringValue } + return "" + } + + private static func khz(_ any: Any?) -> Double { + if let s = any as? String { return Double(s) ?? 0 } + if let n = any as? NSNumber { return n.doubleValue } + return 0 + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/PskReporter.swift b/ios/FT8AFKit/Sources/FT8Engine/PskReporter.swift new file mode 100644 index 000000000..327810cc2 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/PskReporter.swift @@ -0,0 +1,340 @@ +import Foundation + +/// One decoded spot destined for PSK Reporter. +public struct PskSpot: Equatable, Sendable { + public let senderCallsign: String + public let frequencyHz: Int64 + public let snr: Int + public let mode: String + public let senderLocator: String? + public let flowStartSeconds: Int64 + + public init(senderCallsign: String, frequencyHz: Int64, snr: Int, mode: String, + senderLocator: String?, flowStartSeconds: Int64) { + self.senderCallsign = senderCallsign + self.frequencyHz = frequencyHz + self.snr = snr + self.mode = mode + self.senderLocator = senderLocator + self.flowStartSeconds = flowStartSeconds + } +} + +/// Spot-policy helpers shared by the encoder and the app-side sender. Pure +/// functions — ports of the filter logic in Android `PskReporterSender`. +public enum PskReporter { + /// Where the datagrams go. + public static let host = "report.pskreporter.info" + public static let port: UInt16 = 4739 + /// Flush cadence and per-callsign/band dedup window (5 minutes, like + /// Android and the WSJT-X convention). + public static let sendIntervalMs: Int64 = 5 * 60 * 1000 + public static let dedupWindowMs: Int64 = 5 * 60 * 1000 + + /// Build the PSKReporter "decodingSoftware" string, optionally including + /// the connected rig name. + public static func buildSoftwareString(version: String, rigName: String?) -> String { + let base = "FT8AF \(version)" + if let rigName, !rigName.trimmingCharacters(in: .whitespaces).isEmpty { + return "\(base) (\(rigName))" + } + return base + } + + /// Convert one decode into a spot, applying the Android sender's policy: + /// drop empty/unresolved-hash calls and free text (i3=0, n3=0), strip + /// angle brackets from hashed callsigns, never report our own callsign, + /// require a >= 4-char locator (else omit it), and report the RF frequency + /// as dial + audio offset. Returns nil when the decode must not be spotted. + public static func makeSpot( + callFrom: String, i3: UInt8, n3: UInt8, grid: String, snr: Int, + audioFreqHz: Float, dialFreqHz: Int64, myCall: String, utcSeconds: Int64 + ) -> PskSpot? { + if callFrom.isEmpty || callFrom == "<...>" { return nil } + let clean = callFrom + .replacingOccurrences(of: "<", with: "") + .replacingOccurrences(of: ">", with: "") + if clean.isEmpty { return nil } + if !myCall.isEmpty, clean.uppercased() == myCall.uppercased() { return nil } + if i3 == 0 && n3 == 0 { return nil } + return PskSpot( + senderCallsign: clean, + frequencyHz: dialFreqHz + Int64(audioFreqHz), + snr: snr, + mode: "FT8", + senderLocator: grid.count >= 4 ? grid : nil, + flowStartSeconds: utcSeconds + ) + } +} + +/// Per-callsign-per-band dedup: a station is reported at most once per +/// `PskReporter.dedupWindowMs` on a given band (keyed by dial-frequency MHz, +/// like Android's "CALL|BAND" key). +public final class PskDedup { + private var lastSeen: [String: Int64] = [:] + + public init() {} + + /// Returns true if the spot should be reported (and marks it seen now); + /// false when it is a duplicate inside the window. + public func markIfFresh(call: String, dialFreqHz: Int64, nowMs: Int64) -> Bool { + let key = "\(call)|\(dialFreqHz / 1_000_000)" + if let t = lastSeen[key], nowMs - t < PskReporter.dedupWindowMs { return false } + lastSeen[key] = nowMs + return true + } +} + +/// Builds the IPFIX-style binary UDP datagrams PSK Reporter's collector +/// expects (report.pskreporter.info:4739). Byte-layout port of Android +/// `PskReporterSender`: 16-byte IPFIX header, options/data template sets on +/// the first sends (and hourly refresh), a receiver record (our station) and +/// N sender records (the spots), each datagram kept under the MTU budget. +/// +/// Pure byte-building — no sockets. Time is injected (`nowMs`) so packet +/// construction is fully deterministic and testable. +public final class PskReporterEncoder { + static let maxDatagram = 1400 + static let enterpriseNumber: UInt32 = 30351 // 0x0000768F + static let ipfixVersion: UInt16 = 0x000A + + // Template set IDs + static let templateSetIdOptions: UInt16 = 0x0003 // options template + static let templateSetIdData: UInt16 = 0x0002 // data template + + // Template IDs (== the data set IDs that reference them) + static let receiverTemplateId: UInt16 = 0x50E2 // 20706 + static let senderTemplateId: UInt16 = 0x50E3 // 20707 + + // PSKReporter's enterprise-specific element IDs under 30351. The collector + // maps element-ID -> column, so these must match exactly. + static let ftRxCallsign: UInt16 = 2 // receiverCallsign (0x8002) + static let ftRxLocator: UInt16 = 4 // receiverLocator (0x8004) + static let ftDecodingSw: UInt16 = 8 // decodingSoftware (0x8008) + static let ftRxAntenna: UInt16 = 9 // antennaInformation (0x8009) + static let ftRxRig: UInt16 = 13 // rigInformation (0x800D) + static let ftTxCallsign: UInt16 = 1 // senderCallsign (0x8001) + static let ftFrequency: UInt16 = 5 // frequency (0x8005) + static let ftSnr: UInt16 = 6 // sNR (0x8006) + static let ftMode: UInt16 = 10 // mode (0x800A) + static let ftTxLocator: UInt16 = 3 // senderLocator (0x8003) + static let ftInfoSource: UInt16 = 11 // informationSource (0x800B) + // flowStartSeconds is the STANDARD IANA IPFIX element 150 (0x0096): no + // enterprise bit, no enterprise number in its template descriptor. + static let ftFlowStart: UInt16 = 150 + + public let observationDomainId: UInt32 + public private(set) var sequenceNumber: UInt32 + private var packetsSent = 0 + private var lastTemplateSentMs: Int64 = 0 + + public init(observationDomainId: UInt32 = UInt32.random(in: 0...UInt32(Int32.max)), + sequenceNumber: UInt32 = 0) { + self.observationDomainId = observationDomainId + self.sequenceNumber = sequenceNumber + } + + /// Build one or more datagrams containing the given spots; each stays + /// under `maxDatagram` bytes. Advances the sequence number per packet and + /// tracks template cadence (first 3 packets and then hourly). + public func buildPackets( + rxCall: String, rxGrid: String, software: String, + antenna: String = "", rig: String = "", + spots: [PskSpot], nowMs: Int64 + ) -> [[UInt8]] { + guard !spots.isEmpty else { return [] } + var packets: [[UInt8]] = [] + let includeTemplates = needTemplates(nowMs: nowMs) + + var remaining = spots[...] + while !remaining.isEmpty { + let templateBytes: [UInt8] = includeTemplates ? Self.encodeTemplates() : [] + let receiverBytes = Self.encodeReceiverDataSet( + call: rxCall, grid: rxGrid, software: software, antenna: antenna, rig: rig) + + // Header is 16 bytes. Reserve the worst-case sender-set padding + // (3 bytes) inside the budget so the padded datagram can never + // exceed `maxDatagram`. + let overhead = 16 + templateBytes.count + receiverBytes.count + let budget = Self.maxDatagram - overhead - 3 + + // Encode as many sender records as fit. A record that cannot fit + // even alone in an otherwise-empty datagram is pathological — + // drop it (never emit an oversized datagram) and keep going. + var senderRecords: [[UInt8]] = [] + var senderSetSize = 4 // set header (setId + length) + var used = 0 + for spot in remaining { + let rec = Self.encodeSenderRecord(spot) + if senderSetSize + rec.count > budget { + if senderRecords.isEmpty { + used += 1 // oversized single record: drop it + continue + } + break + } + senderRecords.append(rec) + senderSetSize += rec.count + used += 1 + } + remaining = remaining.dropFirst(used) + // Everything consumed this pass was dropped as oversized — + // nothing to send in this datagram. + if senderRecords.isEmpty { continue } + + // Pad sender set to a 4-byte boundary. + let senderPadding = (4 - (senderSetSize % 4)) % 4 + senderSetSize += senderPadding + + let totalLen = 16 + templateBytes.count + receiverBytes.count + senderSetSize + var w = ByteWriter() + // IPFIX header + w.u16(Self.ipfixVersion) + w.u16(UInt16(totalLen)) + w.u32(UInt32(truncatingIfNeeded: nowMs / 1000)) + w.u32(sequenceNumber) + w.u32(observationDomainId) + sequenceNumber &+= 1 + packetsSent += 1 + + w.bytes.append(contentsOf: templateBytes) + w.bytes.append(contentsOf: receiverBytes) + + // Sender data set + w.u16(Self.senderTemplateId) + w.u16(UInt16(senderSetSize)) + for rec in senderRecords { w.bytes.append(contentsOf: rec) } + w.pad(senderPadding) + + packets.append(w.bytes) + if includeTemplates { lastTemplateSentMs = nowMs } + } + return packets + } + + func needTemplates(nowMs: Int64) -> Bool { + if packetsSent < 3 { return true } + if nowMs - lastTemplateSentMs > 3_600_000 { return true } + return false + } + + /// Receiver options template + sender data template, concatenated. + static func encodeTemplates() -> [UInt8] { + encodeReceiverTemplate() + encodeSenderTemplate() + } + + static func encodeReceiverTemplate() -> [UInt8] { + // Options template set (0x0003): template 0x50E2, 5 variable-length + // enterprise fields, scope field count 0. + let fieldCount = 5 + let templateRecordLen = 2 + 2 + 2 + fieldCount * 8 + let setLen = 4 + templateRecordLen + let padding = (4 - (setLen % 4)) % 4 + + var w = ByteWriter() + w.u16(templateSetIdOptions) + w.u16(UInt16(setLen + padding)) + w.u16(receiverTemplateId) + w.u16(UInt16(fieldCount)) + w.u16(0) // scope field count + for field in [ftRxCallsign, ftRxLocator, ftDecodingSw, ftRxAntenna, ftRxRig] { + w.u16(field | 0x8000) // enterprise bit + w.u16(0xFFFF) // variable length + w.u32(enterpriseNumber) + } + w.pad(padding) + return w.bytes + } + + static func encodeSenderTemplate() -> [UInt8] { + // Data template set (0x0002): template 0x50E3, 7 fields. Six are + // enterprise-specific (8-byte descriptor); flowStartSeconds is the + // standard element 0x0096 (4-byte descriptor, no enterprise number). + let templateRecordLen = 2 + 2 + 6 * 8 + 4 + let setLen = 4 + templateRecordLen + let padding = (4 - (setLen % 4)) % 4 + + var w = ByteWriter() + w.u16(templateSetIdData) + w.u16(UInt16(setLen + padding)) + w.u16(senderTemplateId) + w.u16(7) + // senderCallsign — variable length + w.u16(ftTxCallsign | 0x8000); w.u16(0xFFFF); w.u32(enterpriseNumber) + // frequency — uint32 + w.u16(ftFrequency | 0x8000); w.u16(4); w.u32(enterpriseNumber) + // sNR — int8 + w.u16(ftSnr | 0x8000); w.u16(1); w.u32(enterpriseNumber) + // mode — variable length + w.u16(ftMode | 0x8000); w.u16(0xFFFF); w.u32(enterpriseNumber) + // senderLocator — variable length + w.u16(ftTxLocator | 0x8000); w.u16(0xFFFF); w.u32(enterpriseNumber) + // informationSource — uint8 + w.u16(ftInfoSource | 0x8000); w.u16(1); w.u32(enterpriseNumber) + // flowStartSeconds — standard element, uint32, NO enterprise number + w.u16(ftFlowStart); w.u16(4) + w.pad(padding) + return w.bytes + } + + /// Receiver data set (our station info). Set ID = receiver template ID. + static func encodeReceiverDataSet( + call: String, grid: String, software: String, antenna: String, rig: String + ) -> [UInt8] { + let body = encodeVarString(call) + encodeVarString(grid) + + encodeVarString(software) + encodeVarString(antenna) + encodeVarString(rig) + let setLen = 4 + body.count + let padding = (4 - (setLen % 4)) % 4 + + var w = ByteWriter() + w.u16(receiverTemplateId) + w.u16(UInt16(setLen + padding)) + w.bytes.append(contentsOf: body) + w.pad(padding) + return w.bytes + } + + /// One sender record (one spot). No set header — these are concatenated + /// inside the sender data set. + static func encodeSenderRecord(_ spot: PskSpot) -> [UInt8] { + var w = ByteWriter() + w.bytes.append(contentsOf: encodeVarString(spot.senderCallsign)) + w.u32(UInt32(truncatingIfNeeded: spot.frequencyHz)) + w.u8(UInt8(bitPattern: Int8(truncatingIfNeeded: spot.snr))) + w.bytes.append(contentsOf: encodeVarString(spot.mode)) + w.bytes.append(contentsOf: encodeVarString(spot.senderLocator ?? "")) + w.u8(1) // informationSource = 1 (automatic) + w.u32(UInt32(truncatingIfNeeded: spot.flowStartSeconds)) + return w.bytes + } + + /// IPFIX variable-length string: 1-byte length prefix + UTF-8 bytes; a + /// length >= 255 uses a 3-byte prefix (0xFF + 2-byte big-endian length). + static func encodeVarString(_ s: String) -> [UInt8] { + let utf8 = Array(s.utf8) + if utf8.count < 255 { + return [UInt8(utf8.count)] + utf8 + } + return [0xFF, UInt8(utf8.count >> 8), UInt8(utf8.count & 0xFF)] + utf8 + } +} + +/// Tiny big-endian byte assembler (ByteBuffer.BIG_ENDIAN equivalent). +private struct ByteWriter { + var bytes: [UInt8] = [] + mutating func u8(_ v: UInt8) { bytes.append(v) } + mutating func u16(_ v: UInt16) { + bytes.append(UInt8(v >> 8)) + bytes.append(UInt8(v & 0xFF)) + } + mutating func u32(_ v: UInt32) { + bytes.append(UInt8((v >> 24) & 0xFF)) + bytes.append(UInt8((v >> 16) & 0xFF)) + bytes.append(UInt8((v >> 8) & 0xFF)) + bytes.append(UInt8(v & 0xFF)) + } + mutating func pad(_ n: Int) { + bytes.append(contentsOf: [UInt8](repeating: 0, count: n)) + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/QrzLogbookClient.swift b/ios/FT8AFKit/Sources/FT8Engine/QrzLogbookClient.swift new file mode 100644 index 000000000..ef188d557 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/QrzLogbookClient.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Pure request-builders and response-parsers for the QRZ.com logbook API +/// (logbook.qrz.com/api). No networking here — the app-side `OnlineLogService` +/// performs the actual URLSession calls. Ports the wire shapes from Android +/// `ThirdPartyService` (uploadAdifToQrz / CheckQRZConnection / parseQrzResult). +public enum QrzLogbookClient { + + /// All QRZ logbook calls POST to this single endpoint. POST keeps the API + /// key (and ADIF) in the body rather than the URL, where it could leak via + /// proxies or server access logs. + public static let endpoint = "https://logbook.qrz.com/api" + + /// Form body for `ACTION=INSERT` uploading a single ADIF record. + public static func insertBody(apiKey: String, adif: String) -> String { + "KEY=\(formEncode(apiKey))&ACTION=INSERT&ADIF=\(formEncode(adif))" + } + + /// Form body for `ACTION=STATUS` (the test-connection call). + public static func statusBody(apiKey: String) -> String { + "KEY=\(formEncode(apiKey))&ACTION=STATUS" + } + + /// Extract the `RESULT` value from a QRZ response — an `&`-separated body + /// of KEY=VALUE pairs (e.g. `RESULT=OK&COUNT=1&LOGID=...`). Returns nil if + /// the response is nil/empty or carries no RESULT field. + public static func result(of response: String?) -> String? { + guard let response, !response.isEmpty else { return nil } + for pair in response.split(separator: "&") { + let kv = pair.split(separator: "=", maxSplits: 1, + omittingEmptySubsequences: false) + if kv.count > 1, kv[0] == "RESULT" { + return String(kv[1]) + } + } + return nil + } + + /// INSERT success: RESULT=OK, or RESULT=REPLACE (the QSO already existed + /// and was updated — still "the record is now on QRZ"). + public static func isInsertSuccess(_ response: String?) -> Bool { + let r = result(of: response) + return r == "OK" || r == "REPLACE" + } + + /// STATUS success: RESULT=OK only. + public static func isStatusOk(_ response: String?) -> Bool { + result(of: response) == "OK" + } + + /// application/x-www-form-urlencoded encoding matching Java's + /// `URLEncoder.encode(s, UTF-8)` (what the Android app sends): unreserved + /// `[A-Za-z0-9.*_-]` pass through, space becomes `+`, everything else is + /// %XX-escaped per UTF-8 byte. + public static func formEncode(_ s: String) -> String { + var out = "" + for byte in Array(s.utf8) { + switch byte { + case UInt8(ascii: "a")...UInt8(ascii: "z"), + UInt8(ascii: "A")...UInt8(ascii: "Z"), + UInt8(ascii: "0")...UInt8(ascii: "9"), + UInt8(ascii: "."), UInt8(ascii: "-"), + UInt8(ascii: "*"), UInt8(ascii: "_"): + out.append(Character(UnicodeScalar(byte))) + case UInt8(ascii: " "): + out.append("+") + default: + out += String(format: "%%%02X", byte) + } + } + return out + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift b/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift index 60cc4a4dc..fe9c43c7f 100644 --- a/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift +++ b/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift @@ -60,6 +60,10 @@ public final class QsoEngine { private var reportRcvd: Int? private var pendingTx: String? private var startedMs: Int64 = 0 + /// True once `complete()` has emitted the record for the current QSO — + /// guards the courtesy-73 window (`stage == .bye73`, target still set) + /// against a second identical record from `forceLog()`. + private var recordEmitted = false public init(myCall: String, myGrid: String) { self.myCall = myCall.uppercased() @@ -139,6 +143,7 @@ public final class QsoEngine { gridRcvd = nil reportSent = nil reportRcvd = nil + recordEmitted = false startedMs = FT8Time.nowUnixMs() } @@ -240,8 +245,45 @@ public final class QsoEngine { stage = .rr73 } + /// Force-log the current QSO and move on without waiting for the partner's + /// 73 (the Active QSO panel's LOG action; port of Android + /// `forceLogAndMoveOn`). Only produces a record when the QSO progressed far + /// enough to be a real contact — both reports known, i.e. we have sent + /// R-report or beyond (Android `shouldForceLog`: order >= 3) — and + /// `complete()` hasn't already emitted it (the courtesy-73 window keeps + /// the target set after completion; a LOG tap there must not double-log). + /// Either way the QSO context is cleared and we return to CQ / stop per + /// `autoReturnToCq`. + public func forceLog() -> QsoOutcome? { + guard let dx = target else { return nil } + let progressed = stage == .rReport || stage == .rr73 || stage == .bye73 + let outcome: QsoOutcome? = (progressed && !recordEmitted) + ? .completed(makeRecord(dx)) : nil + finishQso() + return outcome + } + + /// Abandon the current target and return to the CQ baseline (the Active QSO + /// panel's ✕ action; port of Android `userResetToCQ`). Nothing is logged. + public func abandonToCq() { + finishQso() + } + private func complete(_ dx: String) -> QsoOutcome { - let record = QsoRecord( + let record = makeRecord(dx) + recordEmitted = true + + // If a courtesy "73" is queued (the .rReport path set stage = .bye73), + // leave pendingTx/stage so the scheduler transmits it; the next slot's + // .bye73 handler in processRx then wraps up. Otherwise finish now. + if stage != .bye73 { + finishQso() + } + return .completed(record) + } + + private func makeRecord(_ dx: String) -> QsoRecord { + QsoRecord( call: dx, gridsquare: gridRcvd ?? "", mode: "FT8", @@ -257,14 +299,6 @@ public final class QsoEngine { myGridsquare: myGrid, comment: "" ) - - // If a courtesy "73" is queued (the .rReport path set stage = .bye73), - // leave pendingTx/stage so the scheduler transmits it; the next slot's - // .bye73 handler in processRx then wraps up. Otherwise finish now. - if stage != .bye73 { - finishQso() - } - return .completed(record) } /// Return to calling CQ, or stop, per `autoReturnToCq`. diff --git a/ios/FT8AFKit/Sources/FT8Engine/QsoRecord.swift b/ios/FT8AFKit/Sources/FT8Engine/QsoRecord.swift index 7ae9bfed6..ca11ff672 100644 --- a/ios/FT8AFKit/Sources/FT8Engine/QsoRecord.swift +++ b/ios/FT8AFKit/Sources/FT8Engine/QsoRecord.swift @@ -20,6 +20,13 @@ public struct QsoRecord: Equatable, Codable { public var stationCallsign: String public var myGridsquare: String public var comment: String + /// Whether this QSO has been accepted by the Cloudlog/Wavelog server + /// (Android: QSLTable.synced_cloudlog). Defaults false; absent in logs + /// stored before online logging existed. + public var syncedCloudlog: Bool + /// Whether this QSO has been accepted by the QRZ.com logbook + /// (Android: QSLTable.synced_qrz). + public var syncedQrz: Bool public init( id: Int64? = nil, @@ -36,7 +43,9 @@ public struct QsoRecord: Equatable, Codable { freq: String, stationCallsign: String, myGridsquare: String, - comment: String + comment: String, + syncedCloudlog: Bool = false, + syncedQrz: Bool = false ) { self.id = id self.call = call @@ -53,5 +62,31 @@ public struct QsoRecord: Equatable, Codable { self.stationCallsign = stationCallsign self.myGridsquare = myGridsquare self.comment = comment + self.syncedCloudlog = syncedCloudlog + self.syncedQrz = syncedQrz + } + + /// Custom decode so JSON logs written before the sync flags existed still + /// load (the flags default to false when the keys are absent). Encoding + /// stays synthesized and always writes both flags. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(Int64.self, forKey: .id) + call = try c.decode(String.self, forKey: .call) + gridsquare = try c.decode(String.self, forKey: .gridsquare) + mode = try c.decode(String.self, forKey: .mode) + rstSent = try c.decode(String.self, forKey: .rstSent) + rstRcvd = try c.decode(String.self, forKey: .rstRcvd) + qsoDate = try c.decode(String.self, forKey: .qsoDate) + timeOn = try c.decode(String.self, forKey: .timeOn) + qsoDateOff = try c.decode(String.self, forKey: .qsoDateOff) + timeOff = try c.decode(String.self, forKey: .timeOff) + band = try c.decode(String.self, forKey: .band) + freq = try c.decode(String.self, forKey: .freq) + stationCallsign = try c.decode(String.self, forKey: .stationCallsign) + myGridsquare = try c.decode(String.self, forKey: .myGridsquare) + comment = try c.decode(String.self, forKey: .comment) + syncedCloudlog = try c.decodeIfPresent(Bool.self, forKey: .syncedCloudlog) ?? false + syncedQrz = try c.decodeIfPresent(Bool.self, forKey: .syncedQrz) ?? false } } diff --git a/ios/FT8AFKit/Sources/FT8Engine/TuneTone.swift b/ios/FT8AFKit/Sources/FT8Engine/TuneTone.swift new file mode 100644 index 000000000..967c3a587 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/TuneTone.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Pure generator for the TUNE carrier: a steady sine at the current TX audio +/// frequency, used for antenna/amplifier tuning (port of Android's tune +/// carrier, issue #408). Short raised-cosine ramps at both ends avoid key +/// clicks; the buffer length is capped by the tune safety timeout, so playback +/// simply ending enforces the hard max-on time even if the countdown UI dies. +public enum TuneTone { + /// Ramp length applied at the head and tail of the carrier. + public static let rampMs = 10 + + /// Label for the TUNE pill: the plain label when idle, "TUNE 7s" while the + /// carrier is up so the operator sees the safety timeout running (port of + /// Android `tuneChipLabel`). + public static func chipLabel( + _ label: String = "TUNE", + isTuning: Bool, + remainingSec: Int + ) -> String { + isTuning ? "\(label) \(max(0, remainingSec))s" : label + } + + /// Generate `durationSec` seconds of steady carrier at `freqHz`. + /// Returns an empty buffer for degenerate inputs (non-positive duration, + /// frequency, or sample rate). + public static func samples( + freqHz: Float, + sampleRate: Int, + durationSec: Int, + amplitude: Float = 0.9 + ) -> [Float] { + guard durationSec > 0, sampleRate > 0, freqHz > 0 else { return [] } + let count = sampleRate * durationSec + let rampSamples = min(count / 2, sampleRate * rampMs / 1000) + let omega = 2.0 * Double.pi * Double(freqHz) / Double(sampleRate) + + var out = [Float](repeating: 0, count: count) + for i in 0.. 0 { + // Raised-cosine fade-in / fade-out. + if i < rampSamples { + let t = Double(i) / Double(rampSamples) + a *= 0.5 * (1 - cos(.pi * t)) + } else if i >= count - rampSamples { + let t = Double(count - 1 - i) / Double(rampSamples) + a *= 0.5 * (1 - cos(.pi * t)) + } + } + out[i] = Float(a) + } + return out + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/TxStageSelector.swift b/ios/FT8AFKit/Sources/FT8Engine/TxStageSelector.swift new file mode 100644 index 000000000..67fa1016c --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/TxStageSelector.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Pure display/ordering rules for the TX message-selector chips in the +/// Active QSO panel (Android `TxSelector`: CQ / GRID / RPT / R-RPT / RR73 / 73). +/// The chip matching the engine's current stage renders active (amber); chips +/// for stages already passed render completed (cyan). +public enum TxStageSelector { + /// Chip order as displayed. Note the canonical QSO progression is + /// GRID → RPT → R-RPT → RR73 → 73, with CQ as the idle/baseline chip. + public static let chips: [TxStage] = [.cq, .grid, .report, .rReport, .rr73, .bye73] + + /// Short label for a chip, matching Android's `TxSelector` labels. + public static func label(for stage: TxStage) -> String { + switch stage { + case .cq: return "CQ" + case .grid: return "GRID" + case .report: return "RPT" + case .rReport: return "R-RPT" + case .rr73: return "RR73" + case .bye73: return "73" + case .idle: return "" + } + } + + /// Position of a stage in the QSO progression (CQ = 0 … 73 = 5); nil for idle. + public static func index(of stage: TxStage) -> Int? { + chips.firstIndex(of: stage) + } + + /// Whether `chip` is the currently active (queued) message. + public static func isActive(_ chip: TxStage, current: TxStage) -> Bool { + chip == current + } + + /// Whether `chip` represents a stage the QSO has already progressed past. + /// Idle marks nothing completed; CQ never shows as completed (it is the + /// baseline, not a QSO step). + public static func isCompleted(_ chip: TxStage, current: TxStage) -> Bool { + guard current != .idle, chip != .cq, + let chipIdx = index(of: chip), + let curIdx = index(of: current) else { return false } + return chipIdx < curIdx + } +} + +/// Pure header-state rule for the Active QSO panel (Android `StationHeader` +/// wording: Hunting / Calling CQ / "QSOing with X" / "Waiting for X"). +public enum QsoPanelHeader { + public enum State: Equatable { + case hunting + case callingCq + case qsoing(String) + case waiting(String) + case idle + } + + /// - Parameters: + /// - targetCall: locked QSO partner, empty/nil when none + /// - isActivated: the sequencer is armed (CQ run or QSO) + /// - huntEnabled: HUNT (auto-answer CQs) is armed + /// - huntCallsCQ: the hunt-calls-CQ hybrid setting — a CQing hunt shows + /// "Calling CQ" (it *is* transmitting); only a silent listening hunt + /// shows "Hunting" (mirrors Android's `isHunting` rule). + /// - isTransmitting: audio is keyed this cycle + public static func state( + targetCall: String?, + isActivated: Bool, + huntEnabled: Bool, + huntCallsCQ: Bool, + isTransmitting: Bool + ) -> State { + if let dx = targetCall, !dx.isEmpty { + return isTransmitting ? .qsoing(dx) : .waiting(dx) + } + if isActivated { + return (huntEnabled && !huntCallsCQ) ? .hunting : .callingCq + } + if huntEnabled { + return .hunting + } + return .idle + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/TxSupervisor.swift b/ios/FT8AFKit/Sources/FT8Engine/TxSupervisor.swift new file mode 100644 index 000000000..962beae23 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/TxSupervisor.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Pure counting/decision logic for the two TX safety nets (port of Android's +/// `launchSupervision` watchdog + `noReplyLimit` stop-after-N in +/// `FT8TransmitSignal`/`GeneralVariables`): +/// +/// - **Watchdog**: auto-stop any repeating TX (CQ or QSO retries) after N +/// minutes of continuous transmission cycles. The clock starts when TX is +/// armed and is refreshed on events that prove the operator/sequencer is +/// making progress (new CQ run, new target, completed QSO with auto-CQ, +/// hunt idle-listening) — a *stuck* QSO deliberately keeps aging it. +/// - **Stop-after-N**: stop calling after N consecutive transmissions of the +/// *same* message with no reply in between. A stage advance (message text +/// changes) or a reply from the target resets the streak. +/// +/// The type holds no timers and does no I/O: the caller feeds it events with +/// explicit `nowMs` timestamps and asks `stopReason` before each TX cycle, so +/// every rule is unit-testable. +public struct TxSupervisor: Equatable, Sendable { + public enum StopReason: Equatable, Sendable { + /// Continuous TX exceeded the watchdog timeout. + case watchdog + /// The same message went unanswered `stopAfterAttempts` times. + case noReply + } + + /// Epoch ms when the watchdog clock last (re)started. + public private(set) var watchdogStartMs: Int64 + /// Consecutive transmissions of `lastMessage` without an intervening reply. + public private(set) var noReplyCount: Int = 0 + /// The message text whose no-reply streak is being counted. + public private(set) var lastMessage: String? + + public init(nowMs: Int64) { + watchdogStartMs = nowMs + } + + /// Restart the watchdog clock (TX armed, target changed, QSO completed + /// with auto-CQ, hunt idle-listening slot, …). + public mutating func resetWatchdog(nowMs: Int64) { + watchdogStartMs = nowMs + } + + /// A reply from the current target arrived — the run is alive, so the + /// no-reply streak resets (the watchdog is deliberately *not* refreshed + /// here; see the type doc). + public mutating func noteReply() { + noReplyCount = 0 + } + + /// Record one transmission of `message`. Repeating the same text grows + /// the no-reply streak; any change of message (stage advance, new target) + /// starts a fresh streak of 1. + public mutating func noteTransmission(_ message: String) { + if message == lastMessage { + noReplyCount += 1 + } else { + lastMessage = message + noReplyCount = 1 + } + } + + /// Clear the no-reply streak entirely (new QSO/target/CQ run). + public mutating func resetAttempts() { + noReplyCount = 0 + lastMessage = nil + } + + /// Whether TX must stop *before* the next transmission cycle. + /// + /// - Parameters: + /// - nowMs: current epoch ms + /// - watchdogMin: watchdog timeout in minutes, 0 = off + /// - stopAfterAttempts: max consecutive no-reply sends of one message, 0 = off + public func stopReason(nowMs: Int64, watchdogMin: Int, stopAfterAttempts: Int) -> StopReason? { + if watchdogMin > 0, nowMs - watchdogStartMs > Int64(watchdogMin) * 60_000 { + return .watchdog + } + if stopAfterAttempts > 0, noReplyCount >= stopAfterAttempts { + return .noReply + } + return nil + } +} diff --git a/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift b/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift new file mode 100644 index 000000000..a773d2df6 --- /dev/null +++ b/ios/FT8AFKit/Sources/FT8Engine/TxTiming.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Pure timing math for TX playback scheduling. +/// +/// The FT8 waveform is 12.64 s in a 15 s cycle, leaving 2.36 s of slack. When +/// playback starts late into the cycle we clip the *leading* audio so the +/// transmission still ends on the cycle boundary — but ONLY the portion past +/// the slack. Clipping anything on an on-time start (e.g. a naive +/// `msIntoCycle % 15000`) chops the leading Costas sync array and produces an +/// audible-but-undecodable signal (see project CLAUDE.md, "FT8 TX audio +/// pipeline" gotcha #2, fixed on Android in PR #93). +public enum TxTiming { + /// Slack between the 12.64 s FT8 waveform and the 15 s cycle. + public static let defaultSlackMs: Int64 = 15_000 - 12_640 // 2360 + + /// Leading milliseconds to clip: `max(0, msIntoCycle - slack)`. + /// An on-time start (anything within the slack) clips nothing. + public static func lateStartClipMs( + msIntoCycle: Int64, + slackMs: Int64 = defaultSlackMs + ) -> Int64 { + max(0, msIntoCycle - max(0, slackMs)) + } + + /// Convert a clip duration to a leading sample count at `sampleRate`. + public static func clipSampleCount(clipMs: Int64, sampleRate: Int) -> Int { + max(0, Int(clipMs) * sampleRate / 1000) + } + + /// Whether to skip the transmission for this slot entirely. + /// + /// `toleranceMs` is the operator's late-start tolerance (the + /// "Late Start Tolerance" setting): the maximum amount of leading audio + /// they will accept losing to a late-start clip. It is a *skip threshold*, + /// never the clip slack — the clip slack is the physical + /// `defaultSlackMs` constant. A clip larger than the tolerance means the + /// TX would lose too much of its head (Costas sync and beyond), so the + /// whole slot is skipped instead of transmitting a mutilated signal. + /// Negative tolerances are clamped to 0 (skip any clipped start). + public static func shouldSkip(clipMs: Int64, toleranceMs: Int64) -> Bool { + clipMs > max(0, toleranceMs) + } +} diff --git a/ios/FT8AFKit/Tests/FT8AudioTests/AudioInputLevelTests.swift b/ios/FT8AFKit/Tests/FT8AudioTests/AudioInputLevelTests.swift new file mode 100644 index 000000000..9a30eec71 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8AudioTests/AudioInputLevelTests.swift @@ -0,0 +1,108 @@ +import XCTest +import FT8Audio + +/// Guards the RX input-level classifier behind the waterfall's level meter +/// (port of the Android `AudioInputLevel` thresholds — the two platforms must +/// judge the same audio identically). +final class AudioInputLevelTests: XCTestCase { + + /// Sine buffer of a given peak amplitude; RMS = amplitude / sqrt(2). + private func sine(amplitude: Float, count: Int = 3000) -> [Float] { + (0.. QueuedCaller { + QueuedCaller(callsign: call, snr: snr, freqHz: 1500, grid: "FN42", queuedAtMs: ms) + } + + // MARK: enqueue / dedup + + func testEnqueueAppendsInOrder() { + var q = CallerQueue() + q.enqueue(caller("W1AAA")) + q.enqueue(caller("W2BBB")) + XCTAssertEqual(q.callers.map(\.callsign), ["W1AAA", "W2BBB"]) + } + + func testDedupByCallsignRefreshesButKeepsPosition() { + var q = CallerQueue() + q.enqueue(caller("W1AAA", snr: -20, at: 0)) + q.enqueue(caller("W2BBB", at: 0)) + // Re-heard louder and later — updates in place, stays at the head. + XCTAssertTrue(q.enqueue(caller("w1aaa", snr: -3, at: 30_000))) + XCTAssertEqual(q.count, 2) + XCTAssertEqual(q.callers[0].callsign, "W1AAA") + XCTAssertEqual(q.callers[0].snr, -3) + XCTAssertEqual(q.callers[0].queuedAtMs, 30_000) + } + + func testCapacityCap() { + var q = CallerQueue() + for i in 0..K1ABC \n" + ) + XCTAssertEqual(req?.url, "https://log.example.com/api/qso") + XCTAssertEqual( + req?.body, + "{\"key\":\"cl123\",\"station_profile_id\":\"5\",\"type\":\"adif\"," + + "\"string\":\"K1ABC \\n\"}" + ) + } + + func testQsoUploadRequestEndpointHasNoTrailingSlash() { + // Wavelog/Nextlog 308-redirect "api/qso/" on POST; the endpoint must be + // the documented slash-less form. + let req = CloudlogClient.qsoUploadRequest( + serverAddress: "https://log.example.com/", apiKey: "k", stationId: "1", adif: "x") + XCTAssertEqual(req?.url, "https://log.example.com/api/qso") + } + + func testQsoUploadRequestNilWhenAddressOrKeyMissing() { + XCTAssertNil(CloudlogClient.qsoUploadRequest( + serverAddress: "", apiKey: "k", stationId: "1", adif: "x")) + XCTAssertNil(CloudlogClient.qsoUploadRequest( + serverAddress: "https://log.example.com", apiKey: "", stationId: "1", adif: "x")) + } + + func testQsoUploadRequestBodyIsValidJson() throws { + let req = try XCTUnwrap(CloudlogClient.qsoUploadRequest( + serverAddress: "https://log.example.com", + apiKey: "k\"ey\\", stationId: "2", + adif: "tab\there\n \n")) + let obj = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(req.body.utf8)) as? [String: String]) + XCTAssertEqual(obj["key"], "k\"ey\\") + XCTAssertEqual(obj["station_profile_id"], "2") + XCTAssertEqual(obj["type"], "adif") + XCTAssertEqual(obj["string"], "tab\there\n \n") + } + + // MARK: authUrl + + func testAuthUrl() { + XCTAssertEqual( + CloudlogClient.authUrl(serverAddress: "https://log.example.com", apiKey: "cl123"), + "https://log.example.com/api/auth/cl123") + XCTAssertNil(CloudlogClient.authUrl(serverAddress: "", apiKey: "cl123")) + XCTAssertNil(CloudlogClient.authUrl(serverAddress: "https://log.example.com", apiKey: "")) + } + + // MARK: isAuthValid + + func testIsAuthValidCloudlogShape() { + XCTAssertTrue(CloudlogClient.isAuthValid( + "Validrw")) + } + + func testIsAuthValidToleratesWhitespaceAndXmlDeclaration() { + // Wavelog/Nextlog return the same markers with extra noise. + let noisy = """ + + + Valid + + rw + + + """ + XCTAssertTrue(CloudlogClient.isAuthValid(noisy)) + } + + func testIsAuthValidRejectsReadOnlyInvalidAndMissing() { + XCTAssertFalse(CloudlogClient.isAuthValid( + "Validr")) + XCTAssertFalse(CloudlogClient.isAuthValid( + "Invalidrw")) + XCTAssertFalse(CloudlogClient.isAuthValid("")) + XCTAssertFalse(CloudlogClient.isAuthValid(nil)) + } + + // MARK: isSuccessStatus + + func testIsSuccessStatus() { + XCTAssertTrue(CloudlogClient.isSuccessStatus(200)) + XCTAssertTrue(CloudlogClient.isSuccessStatus(201)) // Cloudlog answers HTTP_CREATED + XCTAssertFalse(CloudlogClient.isSuccessStatus(308)) + XCTAssertFalse(CloudlogClient.isSuccessStatus(401)) + XCTAssertFalse(CloudlogClient.isSuccessStatus(500)) + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/DecodeAnnotatorTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/DecodeAnnotatorTests.swift new file mode 100644 index 000000000..0ae654ba8 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/DecodeAnnotatorTests.swift @@ -0,0 +1,166 @@ +import XCTest +@testable import FT8Engine + +final class DecodeAnnotatorTests: XCTestCase { + + private let myCall = "KD2OGR" + + /// Logbook: W1AW worked on 20M (FN31), DL4RCK on 40M (JO31). + private var index: LogbookIndex { + LogbookIndex(qsos: [ + LoggedQso(call: "W1AW", grid: "FN31", band: "20M"), + LoggedQso(call: "DL4RCK", grid: "JO31", band: "40M"), + ]) + } + + private func classify( + from: String, to: String, grid: String = "", extra: String = "", + band: String = "20M", + toggles: HighlightToggles = HighlightToggles(), + index: LogbookIndex? = nil + ) -> DecodeHighlight? { + classifyDecode( + callFrom: from, callTo: to, grid: grid, extra: extra, + myCall: myCall, band: band, index: index ?? self.index, toggles: toggles + ) + } + + // MARK: - Message-shape helpers + + func testIsCQMessage() { + XCTAssertTrue(isCQMessage(callTo: "CQ")) + XCTAssertTrue(isCQMessage(callTo: "CQ POTA")) + XCTAssertTrue(isCQMessage(callTo: "CQ DX")) + XCTAssertFalse(isCQMessage(callTo: "CQX1AB")) + XCTAssertFalse(isCQMessage(callTo: "W1AW")) + XCTAssertFalse(isCQMessage(callTo: "")) + } + + func testIsDirectedToMe() { + XCTAssertTrue(isDirectedToMe(callTo: "kd2ogr", myCall: myCall)) + XCTAssertFalse(isDirectedToMe(callTo: "W1AW", myCall: myCall)) + XCTAssertFalse(isDirectedToMe(callTo: "KD2OGR", myCall: "")) + } + + func testParkRefShape() { + XCTAssertTrue(looksLikeParkRef("K-1234")) + XCTAssertTrue(looksLikeParkRef("VE-5082")) + XCTAssertTrue(looksLikeParkRef("DL-03000")) + XCTAssertFalse(looksLikeParkRef("RR73")) + XCTAssertFalse(looksLikeParkRef("K-12")) // number too short + XCTAssertFalse(looksLikeParkRef("KABCD-1234")) // program too long + XCTAssertFalse(looksLikeParkRef("K-12A4")) // non-digit number + XCTAssertFalse(looksLikeParkRef("")) + } + + func testIsPotaCq() { + XCTAssertTrue(isPotaCq(callTo: "CQ POTA", extra: "")) + XCTAssertTrue(isPotaCq(callTo: "CQ", extra: "K-1234")) + XCTAssertFalse(isPotaCq(callTo: "CQ", extra: "FN31")) + XCTAssertFalse(isPotaCq(callTo: "W1AW", extra: "K-1234")) // not a CQ + } + + // MARK: - Priority order + + func testPendingBeatsEverything() { + // Directed at me, from a station that is also new DXCC → PENDING wins. + XCTAssertEqual(classify(from: "JA1ABC", to: "KD2OGR", grid: "PM95"), .pending) + } + + func testPotaBeatsNewDxcc() { + // New-DXCC station activating a park → POTA wins (Android order). + XCTAssertEqual(classify(from: "JA1ABC", to: "CQ POTA", grid: "PM95"), .pota) + XCTAssertEqual(classify(from: "JA1ABC", to: "CQ", grid: "PM95", extra: "K-1234"), .pota) + } + + func testNewDxccBeatsNewGrid() { + // Japan not in logbook, grid also new → NEW DXCC wins. + XCTAssertEqual(classify(from: "JA1ABC", to: "CQ", grid: "PM95"), .newDxcc) + } + + func testNewGridWhenEntityWorked() { + // USA entity is worked (W1AW), but EM12 is a new grid. + XCTAssertEqual(classify(from: "K5ABC", to: "CQ", grid: "EM12"), .newGrid) + } + + func testNewBandWhenGridAndEntityWorked() { + // DL4RCK worked on 40M only; on 20M with his already-worked grid → NEW BAND. + XCTAssertEqual(classify(from: "DL4RCK", to: "CQ", grid: "JO31", band: "20M"), .newBand) + } + + func testWorkedOnThisBand() { + // W1AW worked on 20M, known grid → WORKED. + XCTAssertEqual(classify(from: "W1AW", to: "CQ", grid: "FN31", band: "20M"), .worked) + } + + func testPlainCqFallback() { + // Entity/grid toggles off → plain CQ. + let off = HighlightToggles(newDxcc: false, newGrid: false, newBand: false, worked: false) + XCTAssertEqual(classify(from: "JA1ABC", to: "CQ", grid: "PM95", toggles: off), .cq) + } + + func testNilForUninterestingThirdPartyTraffic() { + // Mid-QSO with someone else, everything already worked → no pill. + XCTAssertEqual(classify(from: "W1AW", to: "K5XYZ", grid: "FN31", band: "20M", + toggles: HighlightToggles(worked: false)), nil) + } + + // MARK: - Toggle gating (each category falls through when disabled) + + func testNewDxccToggleFallsThrough() { + let t = HighlightToggles(newDxcc: false) + XCTAssertEqual(classify(from: "JA1ABC", to: "CQ", grid: "PM95", toggles: t), .newGrid) + } + + func testNewGridToggleFallsThrough() { + let t = HighlightToggles(newGrid: false) + // K5ABC: entity worked, grid EM12 new but gated off, never worked → CQ. + XCTAssertEqual(classify(from: "K5ABC", to: "CQ", grid: "EM12", toggles: t), .cq) + } + + func testNewBandToggleFallsThrough() { + let t = HighlightToggles(newBand: false) + XCTAssertEqual(classify(from: "DL4RCK", to: "CQ", grid: "JO31", band: "20M", toggles: t), .worked) + } + + func testWorkedToggleFallsThrough() { + let t = HighlightToggles(worked: false) + XCTAssertEqual(classify(from: "W1AW", to: "CQ", grid: "FN31", band: "20M", toggles: t), .cq) + } + + // MARK: - Grid validity + + func testRr73IsNotANewGrid() { + // "RR73" parses like a locator but is a sign-off; must not classify as NEW GRID. + XCTAssertEqual(classify(from: "K5ABC", to: "CQ", grid: "RR73", + toggles: HighlightToggles(worked: false)), .cq) + } + + func testShortGridIsNotANewGrid() { + XCTAssertEqual(classify(from: "K5ABC", to: "CQ", grid: "FN", + toggles: HighlightToggles(worked: false)), .cq) + } + + func testSixCharGridMatchesWorkedSquare() { + // FN31pr truncates to FN31, which is worked → not a new grid. + XCTAssertEqual(classify(from: "K5ABC", to: "CQ", grid: "FN31pr", + toggles: HighlightToggles(worked: false)), .cq) + } + + // MARK: - LogbookIndex construction + + func testIndexNormalizesCase() { + let idx = LogbookIndex(qsos: [LoggedQso(call: "w1aw", grid: "fn31pr", band: "20m")]) + XCTAssertTrue(idx.workedCalls.contains("W1AW")) + XCTAssertTrue(idx.workedGrids.contains("FN31")) + XCTAssertEqual(idx.bandsByCall["W1AW"], ["20M"]) + XCTAssertTrue(idx.workedEntities.contains("United States")) + } + + func testIndexSkipsEmptyRecords() { + let idx = LogbookIndex(qsos: [LoggedQso(call: "", grid: "", band: "")]) + XCTAssertTrue(idx.workedCalls.isEmpty) + XCTAssertTrue(idx.workedGrids.isEmpty) + XCTAssertTrue(idx.workedEntities.isEmpty) + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/DisplayFormattersTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/DisplayFormattersTests.swift new file mode 100644 index 000000000..320ffde88 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/DisplayFormattersTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import FT8Engine + +final class DisplayFormattersTests: XCTestCase { + + // MARK: - Relative age buckets + + func testAgeNow() { + XCTAssertEqual(relativeAge(secondsAgo: 0), "now") + XCTAssertEqual(relativeAge(secondsAgo: 4), "now") + } + + func testAgeSeconds() { + XCTAssertEqual(relativeAge(secondsAgo: 5), "5s") + XCTAssertEqual(relativeAge(secondsAgo: 32), "32s") + XCTAssertEqual(relativeAge(secondsAgo: 59), "59s") + } + + func testAgeMinutes() { + XCTAssertEqual(relativeAge(secondsAgo: 60), "1m") + XCTAssertEqual(relativeAge(secondsAgo: 5 * 60 + 20), "5m") + XCTAssertEqual(relativeAge(secondsAgo: 3599), "59m") + } + + func testAgeHours() { + XCTAssertEqual(relativeAge(secondsAgo: 3600), "1h") + XCTAssertEqual(relativeAge(secondsAgo: 2 * 3600 + 100), "2h") + XCTAssertEqual(relativeAge(secondsAgo: 86399), "23h") + } + + func testAgeDays() { + XCTAssertEqual(relativeAge(secondsAgo: 86400), "1d") + XCTAssertEqual(relativeAge(secondsAgo: 3 * 86400), "3d") + } + + func testAgeClampsNegative() { + XCTAssertEqual(relativeAge(secondsAgo: -7), "now") + } + + // MARK: - Grid distance + + func testGridDistanceKnownPath() { + // FN31 (Connecticut) → JO31 (western Germany): ~5900 km great circle. + let km = gridDistanceKm(from: "FN31", to: "JO31") + XCTAssertNotNil(km) + XCTAssertEqual(km!, 5900, accuracy: 200) + } + + func testGridDistanceZeroForSameGrid() { + let km = gridDistanceKm(from: "FN31", to: "FN31") + XCTAssertEqual(km!, 0, accuracy: 0.001) + } + + func testGridDistanceNilForMalformedGrid() { + XCTAssertNil(gridDistanceKm(from: "FN31", to: "")) + XCTAssertNil(gridDistanceKm(from: "??", to: "FN31")) + // RR73 is a sign-off token, not a locator. + XCTAssertNil(gridDistanceKm(from: "FN31", to: "RR73")) + } + + // MARK: - Distance formatting & unit conversion + + func testKilometersToMiles() { + XCTAssertEqual(kilometersToMiles(100), 62.1371, accuracy: 0.001) + } + + func testFormatDistanceKm() { + XCTAssertEqual(formatQsoDistance(km: 1832.4, inMiles: false), "1832 km") + XCTAssertEqual(formatQsoDistance(km: 1832.6, inMiles: false), "1833 km") + } + + func testFormatDistanceMiles() { + // 1000 km → 621.371 mi → "621 mi" + XCTAssertEqual(formatQsoDistance(km: 1000, inMiles: true), "621 mi") + } + + func testFormatDistanceEmptyForZero() { + XCTAssertEqual(formatQsoDistance(km: 0, inMiles: false), "") + XCTAssertEqual(formatQsoDistance(km: -5, inMiles: true), "") + // Rounds to zero → suppressed rather than "0 mi". + XCTAssertEqual(formatQsoDistance(km: 0.2, inMiles: true), "") + } + + func testGridDistanceTextEndToEnd() { + XCTAssertEqual(gridDistanceText(from: "FN31", to: "", inMiles: false), "") + let text = gridDistanceText(from: "FN31", to: "JO31", inMiles: false) + XCTAssertTrue(text.hasSuffix(" km")) + XCTAssertFalse(text.isEmpty) + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/DxccPrefixTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/DxccPrefixTests.swift new file mode 100644 index 000000000..b2cdf863e --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/DxccPrefixTests.swift @@ -0,0 +1,107 @@ +import XCTest +@testable import FT8Engine + +final class DxccPrefixTests: XCTestCase { + + // MARK: - Core entities from the task brief + + func testUsaPrefixes() { + XCTAssertEqual(DxccPrefix.entity(for: "W1AW")?.name, "United States") + XCTAssertEqual(DxccPrefix.entity(for: "K5ABC")?.name, "United States") + XCTAssertEqual(DxccPrefix.entity(for: "N0CALL")?.name, "United States") + XCTAssertEqual(DxccPrefix.entity(for: "AA4XX")?.name, "United States") + XCTAssertEqual(DxccPrefix.continent(for: "W1AW"), "NA") + } + + func testJapan() { + XCTAssertEqual(DxccPrefix.entity(for: "JA1ABC")?.name, "Japan") + XCTAssertEqual(DxccPrefix.entity(for: "JR2XYZ")?.name, "Japan") + XCTAssertEqual(DxccPrefix.entity(for: "7K1ABC")?.name, "Japan") + XCTAssertEqual(DxccPrefix.continent(for: "JA1ABC"), "AS") + } + + func testGermany() { + XCTAssertEqual(DxccPrefix.entity(for: "DL4RCK")?.name, "Germany") + XCTAssertEqual(DxccPrefix.entity(for: "DK9IP")?.name, "Germany") + XCTAssertEqual(DxccPrefix.continent(for: "DL4RCK"), "EU") + } + + func testEngland() { + XCTAssertEqual(DxccPrefix.entity(for: "G4ABC")?.name, "England") + XCTAssertEqual(DxccPrefix.entity(for: "M0XYZ")?.name, "England") + XCTAssertEqual(DxccPrefix.entity(for: "2E0ABC")?.name, "England") + XCTAssertEqual(DxccPrefix.continent(for: "G4ABC"), "EU") + } + + func testUkRegionsBeatEngland() { + // Longer prefixes (GM/GW/GI) must win over the 1-char England G. + XCTAssertEqual(DxccPrefix.entity(for: "GM3ABC")?.name, "Scotland") + XCTAssertEqual(DxccPrefix.entity(for: "GW4XYZ")?.name, "Wales") + XCTAssertEqual(DxccPrefix.entity(for: "GI0AAA")?.name, "Northern Ireland") + XCTAssertEqual(DxccPrefix.entity(for: "GD4BEG")?.name, "Isle of Man") + } + + func testRussia() { + XCTAssertEqual(DxccPrefix.entity(for: "UA3ABC")?.name, "European Russia") + XCTAssertEqual(DxccPrefix.entity(for: "R2XYZ")?.name, "European Russia") + XCTAssertEqual(DxccPrefix.continent(for: "UA3ABC"), "EU") + // Asiatic call areas resolve via the longer prefix. + XCTAssertEqual(DxccPrefix.entity(for: "UA9ABC")?.name, "Asiatic Russia") + XCTAssertEqual(DxccPrefix.continent(for: "R0XYZ"), "AS") + } + + func testTaskBriefSpotChecks() { + XCTAssertEqual(DxccPrefix.entity(for: "VK3BDX")?.name, "Australia") + XCTAssertEqual(DxccPrefix.continent(for: "VK3BDX"), "OC") + XCTAssertEqual(DxccPrefix.entity(for: "PY2SEX")?.name, "Brazil") + XCTAssertEqual(DxccPrefix.continent(for: "PY2SEX"), "SA") + XCTAssertEqual(DxccPrefix.entity(for: "EA4FKR")?.name, "Spain") + XCTAssertEqual(DxccPrefix.entity(for: "OH2BFO")?.name, "Finland") + XCTAssertEqual(DxccPrefix.entity(for: "ZL1BYZ")?.name, "New Zealand") + XCTAssertEqual(DxccPrefix.entity(for: "VE3XYZ")?.name, "Canada") + XCTAssertEqual(DxccPrefix.continent(for: "ZS6ABC"), "AF") + } + + // MARK: - Longest-prefix-match mechanics + + func testLongestPrefixWinsOverShorter() { + // EA8 (Canary Islands, AF) must beat EA (Spain, EU). + XCTAssertEqual(DxccPrefix.entity(for: "EA8ABC")?.name, "Canary Islands") + XCTAssertEqual(DxccPrefix.continent(for: "EA8ABC"), "AF") + // AP (Pakistan) must beat the 1-char USA "A". + XCTAssertEqual(DxccPrefix.entity(for: "AP2AM")?.name, "Pakistan") + // KH6 (Hawaii, OC) must beat K (USA, NA). + XCTAssertEqual(DxccPrefix.entity(for: "KH6XYZ")?.name, "Hawaii") + XCTAssertEqual(DxccPrefix.continent(for: "KH6XYZ"), "OC") + // CT3 (Madeira, AF) must beat CT (Portugal, EU). + XCTAssertEqual(DxccPrefix.entity(for: "CT3ABC")?.name, "Madeira Islands") + } + + func testLowercaseInput() { + XCTAssertEqual(DxccPrefix.entity(for: "ja1abc")?.name, "Japan") + XCTAssertEqual(DxccPrefix.entity(for: "w1aw")?.name, "United States") + } + + // MARK: - Portable designators + + func testPortablePrefixSegmentWins() { + // The DXCC-determining segment has the longer matched prefix. + XCTAssertEqual(DxccPrefix.entity(for: "EA8/W1AW")?.name, "Canary Islands") + XCTAssertEqual(DxccPrefix.entity(for: "W1AW/KH6")?.name, "Hawaii") + } + + func testPortableSuffixIgnored() { + // "/P" matches nothing; "/M" ties at length 1 and loses to the first segment. + XCTAssertEqual(DxccPrefix.entity(for: "W1AW/P")?.name, "United States") + XCTAssertEqual(DxccPrefix.entity(for: "W1AW/M")?.name, "United States") + XCTAssertEqual(DxccPrefix.entity(for: "DL4RCK/QRP")?.name, "Germany") + } + + // MARK: - Unknowns + + func testUnknownPrefixReturnsNil() { + XCTAssertNil(DxccPrefix.entity(for: "QQ1ABC")) + XCTAssertNil(DxccPrefix.entity(for: "")) + XCTAssertNil(DxccPrefix.continent(for: "QQ1ABC")) + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/OnlineLogSyncTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/OnlineLogSyncTests.swift new file mode 100644 index 000000000..e208d03d8 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/OnlineLogSyncTests.swift @@ -0,0 +1,119 @@ +import XCTest +import FT8Engine + +final class OnlineLogSyncTests: XCTestCase { + + private func rec( + id: Int64, call: String = "K1ABC", + syncedCloudlog: Bool = false, syncedQrz: Bool = false + ) -> QsoRecord { + QsoRecord(id: id, call: call, gridsquare: "FN42", mode: "FT8", rstSent: "-08", + rstRcvd: "-12", qsoDate: "20260618", timeOn: "123000", + qsoDateOff: "20260618", timeOff: "123115", band: "20M", freq: "14.074", + stationCallsign: "KD2OGR", myGridsquare: "FN20", comment: "", + syncedCloudlog: syncedCloudlog, syncedQrz: syncedQrz) + } + + // MARK: QsoRecord sync flags — persistence compatibility + + func testLegacyJsonWithoutSyncFlagsDecodesWithFalseDefaults() throws { + // Exactly what QsoLogStore wrote before the sync flags existed. + let legacy = """ + [{"id":1,"call":"W1AW","gridsquare":"FN31","mode":"FT8","rstSent":"-05", + "rstRcvd":"-08","qsoDate":"20260618","timeOn":"123000", + "qsoDateOff":"20260618","timeOff":"123115","band":"20M","freq":"14.074", + "stationCallsign":"KD2OGR","myGridsquare":"FN20","comment":""}] + """ + let records = try JSONDecoder().decode([QsoRecord].self, from: Data(legacy.utf8)) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].call, "W1AW") + XCTAssertFalse(records[0].syncedCloudlog) + XCTAssertFalse(records[0].syncedQrz) + } + + func testSyncFlagsRoundTripThroughJson() throws { + let original = rec(id: 3, syncedCloudlog: true, syncedQrz: true) + let data = try JSONEncoder().encode([original]) + let decoded = try JSONDecoder().decode([QsoRecord].self, from: data) + XCTAssertEqual(decoded, [original]) + XCTAssertTrue(decoded[0].syncedCloudlog) + XCTAssertTrue(decoded[0].syncedQrz) + } + + func testMemberwiseInitDefaultsFlagsToFalse() { + let r = rec(id: 1) + XCTAssertFalse(r.syncedCloudlog) + XCTAssertFalse(r.syncedQrz) + } + + // MARK: unsynced selection (mirror of Android unsyncedFilter) + + func testUnsyncedEmptyWhenNoServiceEnabled() { + let records = [rec(id: 1), rec(id: 2)] + XCTAssertTrue(OnlineLogSync.unsynced(records, cloudlogEnabled: false, + qrzEnabled: false).isEmpty) + XCTAssertEqual(OnlineLogSync.unsyncedCount(records, cloudlogEnabled: false, + qrzEnabled: false), 0) + } + + func testUnsyncedCloudlogOnly() { + let records = [ + rec(id: 1, syncedCloudlog: true), + rec(id: 2, syncedCloudlog: false, syncedQrz: true), + ] + let out = OnlineLogSync.unsynced(records, cloudlogEnabled: true, qrzEnabled: false) + XCTAssertEqual(out.map(\.id), [2]) + } + + func testUnsyncedQrzOnly() { + let records = [ + rec(id: 1, syncedQrz: true), + rec(id: 2), + ] + let out = OnlineLogSync.unsynced(records, cloudlogEnabled: false, qrzEnabled: true) + XCTAssertEqual(out.map(\.id), [2]) + } + + func testUnsyncedBothServicesIsOrOfPending() { + let records = [ + rec(id: 1, syncedCloudlog: true, syncedQrz: true), // fully synced — skipped + rec(id: 2, syncedCloudlog: true, syncedQrz: false), // QRZ pending + rec(id: 3, syncedCloudlog: false, syncedQrz: true), // Cloudlog pending + rec(id: 4), // both pending + ] + let out = OnlineLogSync.unsynced(records, cloudlogEnabled: true, qrzEnabled: true) + XCTAssertEqual(out.map(\.id), [2, 3, 4]) + XCTAssertEqual(OnlineLogSync.unsyncedCount(records, cloudlogEnabled: true, + qrzEnabled: true), 3) + } + + // MARK: single-record ADIF for online uploads + + func testSingleRecordAdifGolden() { + let r = rec(id: 7) + let expected = + "K1ABC " + + "FN42 " + + "FT8 " + + "-08 " + + "-12 " + + "20260618 " + + "123000 " + + "20M " + + "20260618 " + + "123115 " + + "14.074 " + + "KD2OGR " + + "FN20 " + + " \n" + XCTAssertEqual(Adif.singleRecord(r), expected) + } + + func testSingleRecordAdifOmitsEmptyOptionalsAndHasNoHeader() { + let r = QsoRecord(call: "W1AW", gridsquare: "", mode: "", rstSent: "", + rstRcvd: "", qsoDate: "", timeOn: "", qsoDateOff: "", + timeOff: "", band: "", freq: "", stationCallsign: "", + myGridsquare: "", comment: "") + XCTAssertEqual(Adif.singleRecord(r), "W1AW \n") + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/PotaActivationsTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/PotaActivationsTests.swift new file mode 100644 index 000000000..204456f27 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/PotaActivationsTests.swift @@ -0,0 +1,127 @@ +import XCTest +import FT8Engine + +final class PotaActivationsTests: XCTestCase { + + // 2026-07-13 12:00:00 UTC + private let noonMs: Int64 = 1_783_944_000_000 + + private func rec( + call: String, qsoDate: String, timeOn: String + ) -> QsoRecord { + QsoRecord( + call: call, gridsquare: "", mode: "FT8", rstSent: "", rstRcvd: "", + qsoDate: qsoDate, timeOn: timeOn, qsoDateOff: "", timeOff: "", + band: "20M", freq: "14.074", stationCallsign: "", myGridsquare: "", + comment: "") + } + + // MARK: - Stamps + + func testStampFormatsEpochMillisAsUtc() { + XCTAssertEqual(PotaQsoWindow.stamp(ms: noonMs), "20260713120000") + XCTAssertEqual(PotaQsoWindow.stamp(ms: 0), "19700101000000") + } + + func testRowStampNormalizesVariableWidthTimeOn() { + // App-logged HHMMSS passes through. + XCTAssertEqual(PotaQsoWindow.rowStamp(qsoDate: "20260713", timeOn: "121530"), + "20260713121530") + // Imported HHMM pads with trailing zeros. + XCTAssertEqual(PotaQsoWindow.rowStamp(qsoDate: "20260713", timeOn: "1215"), + "20260713121500") + // Dropped-leading-zero odd width ("815" = 08:15) gets the zero back. + XCTAssertEqual(PotaQsoWindow.rowStamp(qsoDate: "20260713", timeOn: "815"), + "20260713081500") + // Empty time -> midnight. + XCTAssertEqual(PotaQsoWindow.rowStamp(qsoDate: "20260713", timeOn: ""), + "20260713000000") + } + + // MARK: - Window scoping + + func testQsosInWindowIncludesOnlyRecordsBetweenStartAndEnd() { + let records = [ + rec(call: "EARLY", qsoDate: "20260713", timeOn: "115959"), + rec(call: "IN1", qsoDate: "20260713", timeOn: "120000"), // == start: included + rec(call: "IN2", qsoDate: "20260713", timeOn: "123000"), + rec(call: "LATE", qsoDate: "20260713", timeOn: "130001"), + ] + let endMs = noonMs + 3_600_000 // 13:00:00Z + let inWindow = PotaQsoWindow.qsos(in: records, startedAtMs: noonMs, endedAtMs: endMs) + XCTAssertEqual(inWindow.map(\.call), ["IN1", "IN2"]) + XCTAssertEqual( + PotaQsoWindow.qsoCount(records: records, startedAtMs: noonMs, endedAtMs: endMs), 2) + } + + func testOpenActivationUsesFarFutureUpperBound() { + let records = [ + rec(call: "OLD", qsoDate: "20260101", timeOn: "080000"), + rec(call: "NOW", qsoDate: "20260713", timeOn: "140000"), + rec(call: "NEXTDAY", qsoDate: "20260714", timeOn: "010000"), + ] + let inWindow = PotaQsoWindow.qsos(in: records, startedAtMs: noonMs, endedAtMs: nil) + XCTAssertEqual(inWindow.map(\.call), ["NOW", "NEXTDAY"]) + } + + func testQsosInWindowSortedByQsoTimeNotInputOrder() { + let records = [ + rec(call: "SECOND", qsoDate: "20260713", timeOn: "125000"), + rec(call: "FIRST", qsoDate: "20260713", timeOn: "121000"), + ] + let inWindow = PotaQsoWindow.qsos(in: records, startedAtMs: noonMs, endedAtMs: nil) + XCTAssertEqual(inWindow.map(\.call), ["FIRST", "SECOND"]) + } + + func testRepeatActivationAtSameParkOnlyCountsItsOwnWindow() { + // The exporter-drift bug Android's PotaQsoWindow exists to prevent: + // two visits to the same park must not lump QSOs together. + let records = [ + rec(call: "VISIT1", qsoDate: "20260701", timeOn: "100000"), + rec(call: "VISIT2", qsoDate: "20260713", timeOn: "123000"), + ] + XCTAssertEqual( + PotaQsoWindow.qsoCount(records: records, startedAtMs: noonMs, endedAtMs: nil), 1) + } + + // MARK: - Activation record + + func testParkRefsSplitsCommaSeparatedMultiPark() { + let twoFer = PotaActivationRecord( + parkRef: "K-1234, K-5678", startedAtMs: noonMs) + XCTAssertEqual(twoFer.parkRefs, ["K-1234", "K-5678"]) + XCTAssertEqual(twoFer.parkRefsDisplay, "K-1234 + K-5678") + // Trailing comma / blanks are dropped. + let messy = PotaActivationRecord(parkRef: "K-1234,,", startedAtMs: noonMs) + XCTAssertEqual(messy.parkRefs, ["K-1234"]) + } + + func testIsActiveTracksEndedAtMs() { + var a = PotaActivationRecord(parkRef: "K-1234", startedAtMs: noonMs) + XCTAssertTrue(a.isActive) + a.endedAtMs = noonMs + 1000 + XCTAssertFalse(a.isActive) + } + + func testCodableRoundTripIncludingOpenEnd() throws { + let a = PotaActivationRecord( + id: "abc", parkRef: "US-7443", notes: "windy", startedAtMs: noonMs, + endedAtMs: nil, qsoCount: 3) + let data = try JSONEncoder().encode([a]) + let back = try JSONDecoder().decode([PotaActivationRecord].self, from: data) + XCTAssertEqual(back, [a]) + XCTAssertTrue(back[0].isActive) + } + + // MARK: - History + + func testHistoryForDisplayDropsActiveAndSortsNewestFirst() { + let active = PotaActivationRecord(parkRef: "K-0001", startedAtMs: noonMs + 500) + let older = PotaActivationRecord( + parkRef: "K-0002", startedAtMs: noonMs - 86_400_000, endedAtMs: noonMs - 86_000_000) + let newer = PotaActivationRecord( + parkRef: "K-0003", startedAtMs: noonMs - 3_600_000, endedAtMs: noonMs - 3_000_000) + let history = potaHistoryForDisplay([older, active, newer]) + XCTAssertEqual(history.map(\.parkRef), ["K-0003", "K-0002"]) + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/PotaAdifTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/PotaAdifTests.swift new file mode 100644 index 000000000..221984c92 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/PotaAdifTests.swift @@ -0,0 +1,138 @@ +import XCTest +import FT8Engine + +final class PotaAdifTests: XCTestCase { + + // 2026-07-13 12:00:00 UTC + private let noonMs: Int64 = 1_783_944_000_000 + + private func rec( + call: String, grid: String = "FN31", mode: String = "FT8", + qsoDate: String = "20260713", timeOn: String = "123000", + timeOff: String = "123115" + ) -> QsoRecord { + QsoRecord( + call: call, gridsquare: grid, mode: mode, rstSent: "-05", rstRcvd: "-08", + qsoDate: qsoDate, timeOn: timeOn, qsoDateOff: qsoDate, timeOff: timeOff, + band: "20M", freq: "14.074", stationCallsign: "KD2OGR", + myGridsquare: "FN20", comment: "") + } + + private func activation( + parkRef: String = "US-7443", endedAtMs: Int64? = nil + ) -> PotaActivationRecord { + PotaActivationRecord(parkRef: parkRef, startedAtMs: noonMs, endedAtMs: endedAtMs) + } + + // MARK: - Golden document + + func testSingleParkExportMatchesAndroidFieldSetAndOrder() throws { + let docs = Adif.potaActivationExport( + records: [rec(call: "W1AW")], activation: activation()) + XCTAssertEqual(docs.count, 1) + let doc = docs[0] + XCTAssertEqual(doc.parkRef, "US-7443") + XCTAssertEqual(doc.filename, "pota-US-7443-20260713-1200.adi") + XCTAssertEqual(doc.content, """ + FT8AF POTA Activation US-7443 + 3.1.4 FT8AF + W1AW FN31 FT8 20M 14.074 \ + -05 -08 20260713 123000 \ + 123115 KD2OGR FN20 \ + POTA US-7443 + + """) + } + + func testEmptyFieldsAreOmittedNotEmittedZeroLength() { + var r = rec(call: "W1AW") + r.gridsquare = "" + r.timeOff = "" + let docs = Adif.potaActivationExport(records: [r], activation: activation()) + let content = docs[0].content + XCTAssertFalse(content.contains("FN20")) + XCTAssertTrue(content.contains("W1AW")) + } + + func testFieldLengthIsUtf8BytesNotCharacters() { + // "Ω" is 1 character but 2 UTF-8 bytes; a character count would misalign + // every following field for the POTA parser. + var r = rec(call: "W1AW") + r.stationCallsign = "ΩΩ" + let docs = Adif.potaActivationExport(records: [r], activation: activation()) + XCTAssertTrue(docs[0].content.contains("ΩΩ ")) + } + + // MARK: - Mode handling + + func testFt4AndFt2ExportAsMfskSubmodes() { + let docs = Adif.potaActivationExport( + records: [rec(call: "W1AW", mode: "FT4")], activation: activation()) + XCTAssertTrue(docs[0].content.contains("MFSK FT4 ")) + XCTAssertFalse(docs[0].content.contains("FT4")) + + let ft2 = Adif.potaActivationExport( + records: [rec(call: "W1AW", mode: "ft2")], activation: activation()) + XCTAssertTrue(ft2[0].content.contains("MFSK FT2 ")) + } + + func testFt8AndOtherModesPassThroughUnchanged() { + let docs = Adif.potaActivationExport( + records: [rec(call: "W1AW", mode: "FT8")], activation: activation()) + XCTAssertTrue(docs[0].content.contains("FT8 ")) + XCTAssertFalse(docs[0].content.contains("SUBMODE")) + } + + // MARK: - Multi-park (two-fer) + + func testTwoFerProducesOneDocumentPerParkWithPinnedSigInfo() { + let docs = Adif.potaActivationExport( + records: [rec(call: "W1AW"), rec(call: "JA1ABC", timeOn: "124500")], + activation: activation(parkRef: "K-1234,K-5678")) + XCTAssertEqual(docs.map(\.parkRef), ["K-1234", "K-5678"]) + XCTAssertEqual(docs.map(\.filename), + ["pota-K-1234-20260713-1200.adi", "pota-K-5678-20260713-1200.adi"]) + // Same QSOs in both documents, MY_SIG_INFO pinned to each single park. + for doc in docs { + XCTAssertTrue(doc.content.contains("W1AW")) + XCTAssertTrue(doc.content.contains("JA1ABC")) + XCTAssertTrue(doc.content.contains("\(doc.parkRef) ")) + XCTAssertFalse(doc.content.contains("K-1234,K-5678")) + } + } + + // MARK: - Window scoping + empty result + + func testExportScopesToActivationWindow() { + let records = [ + rec(call: "BEFORE", timeOn: "115900"), + rec(call: "DURING", timeOn: "123000"), + rec(call: "AFTER", timeOn: "140100"), + ] + let endMs = noonMs + 3_600_000 // 13:00Z + let docs = Adif.potaActivationExport( + records: records, activation: activation(endedAtMs: endMs)) + let content = docs[0].content + XCTAssertTrue(content.contains("DURING")) + XCTAssertFalse(content.contains("BEFORE")) + XCTAssertFalse(content.contains("AFTER")) + } + + func testNoQsosInWindowReturnsEmptyListNotHeaderOnlyFiles() { + let docs = Adif.potaActivationExport( + records: [rec(call: "OLD", qsoDate: "20250101")], + activation: activation(parkRef: "K-1234,K-5678")) + XCTAssertEqual(docs, []) + } + + func testFilenameStampIsUtcAcrossActivationStartTimes() { + // Epoch start renders as 1970 in UTC regardless of device timezone. + let docs = Adif.potaActivationExport( + records: [rec(call: "W1AW", qsoDate: "19700101", timeOn: "000000")], + activation: PotaActivationRecord(parkRef: "K-0001", startedAtMs: 0)) + XCTAssertEqual(docs[0].filename, "pota-K-0001-19700101-0000.adi") + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/PotaSpotsTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/PotaSpotsTests.swift new file mode 100644 index 000000000..a5656f957 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/PotaSpotsTests.swift @@ -0,0 +1,162 @@ +import XCTest +import FT8Engine + +final class PotaSpotsTests: XCTestCase { + + /// Canned excerpt of a real `api.pota.app/spot/activator` response + /// (2026-07-13), covering the shapes the decoder must survive: frequency as + /// "14061.0" and as "14074" (no decimal), null parkName, a lowercase-ish + /// activator, an RBN spotter with suffix, and a non-FT8 mode. + private let fixtureJson = """ + [ + {"spotId": 53435147, "activator": "KU5J", "frequency": "14061.0", "mode": "CW", + "reference": "US-7443", "parkName": null, + "spotTime": "2026-07-13T19:44:50", "spotter": "KG1A", "comments": "TnxTom", + "source": "Web", "invalid": null, + "name": "DeGray Lower Lake State Recreation Area", "locationDesc": "US-AR", + "grid4": "EM34", "grid6": "EM34ke", "latitude": 34.1783, "longitude": -93.0978, + "count": 7, "expire": 1}, + {"spotId": 53436877, "activator": "vb7f", "frequency": "18100.0", "mode": "FT8", + "reference": "CA-5540", "parkName": null, + "spotTime": "2026-07-13T20:14:32", "spotter": "G4IRN-#", + "comments": "RBN -18 dB via G4IRN-#", "source": "RBN", "invalid": null, + "name": "Beacon Hill Park", "locationDesc": "CA-BC", + "grid4": "CN88", "grid6": "CN88hj", "latitude": 48.4124, "longitude": -123.365, + "count": 51, "expire": 1783}, + {"spotId": 53435371, "activator": "VE6BR", "frequency": "14074", "mode": "ft8", + "reference": "CA-0632", "parkName": null, + "spotTime": "2026-07-13T19:48:16", "spotter": "VE6BR", "comments": null, + "source": "Web", "invalid": null, + "name": "Beauvais Lake Provincial Park", "locationDesc": "CA-AB", + "grid4": "DN29", "grid6": "DN29wj", "latitude": 49.4109, "longitude": -114.119, + "count": 15, "expire": 207} + ] + """ + + private var fixtureData: Data { Data(fixtureJson.utf8) } + + // MARK: - Decoding + + func testDecodeParsesAllRowsWithFeedFieldMapping() throws { + let spots = try XCTUnwrap(PotaSpots.decode(fixtureData)) + XCTAssertEqual(spots.count, 3) + + let cw = spots[0] + XCTAssertEqual(cw.activator, "KU5J") + XCTAssertEqual(cw.frequencyKhz, 14061.0, accuracy: 0.001) + XCTAssertEqual(cw.mode, "CW") + XCTAssertEqual(cw.reference, "US-7443") + // parkName comes from the feed's "name" key ("parkName" is null). + XCTAssertEqual(cw.parkName, "DeGray Lower Lake State Recreation Area") + XCTAssertEqual(cw.locationDesc, "US-AR") + XCTAssertEqual(cw.spotter, "KG1A") + XCTAssertEqual(cw.spotTimeUtc, "2026-07-13T19:44:50") + XCTAssertEqual(cw.comments, "TnxTom") + } + + func testDecodeUppercasesActivatorAndParsesIntegerStringFrequency() throws { + let spots = try XCTUnwrap(PotaSpots.decode(fixtureData)) + XCTAssertEqual(spots[1].activator, "VB7F") + // "14074" (no decimal point) still parses. + XCTAssertEqual(spots[2].frequencyKhz, 14074.0, accuracy: 0.001) + // null comments -> "". + XCTAssertEqual(spots[2].comments, "") + } + + func testDecodeReturnsNilForNonArrayPayload() { + XCTAssertNil(PotaSpots.decode(Data("{\"oops\": true}".utf8))) + XCTAssertNil(PotaSpots.decode(Data("not json".utf8))) + } + + func testDecodeSkipsMalformedElementsInsteadOfFailingBatch() throws { + let json = """ + [ 42, {"activator": "W1AW", "frequency": "7074", "mode": "FT8", + "reference": "US-0001"} ] + """ + let spots = try XCTUnwrap(PotaSpots.decode(Data(json.utf8))) + XCTAssertEqual(spots.count, 1) + XCTAssertEqual(spots[0].activator, "W1AW") + // Missing fields become "" (Android optString behavior). + XCTAssertEqual(spots[0].parkName, "") + XCTAssertEqual(spots[0].spotTimeUtc, "") + } + + func testDecodeUnparseableFrequencyBecomesZero() throws { + let json = """ + [{"activator": "W1AW", "frequency": "QRP", "mode": "FT8", "reference": "US-0001"}] + """ + let spots = try XCTUnwrap(PotaSpots.decode(Data(json.utf8))) + XCTAssertEqual(spots[0].frequencyKhz, 0) + } + + // MARK: - Display filter + sort + + func testForDisplayFT8OnlyFiltersCaseInsensitivelyAndSortsByFrequency() throws { + let spots = try XCTUnwrap(PotaSpots.decode(fixtureData)) + let shown = PotaSpots.forDisplay(spots, ft8Only: true) + // CW row dropped; "ft8" (lowercase mode) kept; ascending frequency. + XCTAssertEqual(shown.map(\.activator), ["VE6BR", "VB7F"]) + XCTAssertEqual(shown.map(\.frequencyKhz), [14074.0, 18100.0]) + } + + func testForDisplayAllModesKeepsEverythingSorted() throws { + let spots = try XCTUnwrap(PotaSpots.decode(fixtureData)) + let shown = PotaSpots.forDisplay(spots, ft8Only: false) + XCTAssertEqual(shown.map(\.activator), ["KU5J", "VE6BR", "VB7F"]) + } + + // MARK: - Formatting + + func testFrequencyTextMatchesAndroidFormat() { + let spot = PotaSpot( + activator: "W1AW", frequencyKhz: 14074, mode: "FT8", reference: "US-0001", + parkName: "", locationDesc: "", spotter: "", spotTimeUtc: "", comments: "") + XCTAssertEqual(spot.frequencyText, "14074.0 kHz") + XCTAssertEqual(spot.frequencyHz, 14_074_000) + } + + func testAgeSecondsParsesUtcTimestampWithAndWithoutFraction() { + let now = date(iso: "2026-07-13T20:15:00") + XCTAssertEqual(PotaSpots.ageSeconds(spotTimeUtc: "2026-07-13T20:14:32", now: now), 28) + // Fractional seconds (as POTA sometimes emits) are ignored. + XCTAssertEqual( + PotaSpots.ageSeconds(spotTimeUtc: "2026-07-13T20:14:32.2233333", now: now), 28) + // Future timestamp (clock skew) clamps to 0. + XCTAssertEqual(PotaSpots.ageSeconds(spotTimeUtc: "2026-07-13T20:16:00", now: now), 0) + } + + func testAgeSecondsReturnsNilForGarbage() { + let now = Date() + XCTAssertNil(PotaSpots.ageSeconds(spotTimeUtc: "", now: now)) + XCTAssertNil(PotaSpots.ageSeconds(spotTimeUtc: "yesterday", now: now)) + XCTAssertNil(PotaSpots.ageSeconds(spotTimeUtc: "2026-07-13", now: now)) + } + + // MARK: - Band mapping + + func testBandForFrequencyMapsFT8DialsAndInBandSpots() { + XCTAssertEqual(PotaSpots.band(forFrequencyKhz: 14074), "20M") + XCTAssertEqual(PotaSpots.band(forFrequencyKhz: 7074), "40M") + XCTAssertEqual(PotaSpots.band(forFrequencyKhz: 18100), "17M") + XCTAssertEqual(PotaSpots.band(forFrequencyKhz: 50313), "6M") + // Anywhere in-band maps, not just the FT8 dial. + XCTAssertEqual(PotaSpots.band(forFrequencyKhz: 14061.0), "20M") + XCTAssertEqual(PotaSpots.band(forFrequencyKhz: 28320), "10M") + } + + func testBandForFrequencyReturnsNilOutsideAllocations() { + XCTAssertNil(PotaSpots.band(forFrequencyKhz: 0)) + XCTAssertNil(PotaSpots.band(forFrequencyKhz: 6000)) // between 60m and 40m + XCTAssertNil(PotaSpots.band(forFrequencyKhz: 144_174)) // 2m: not in the picker + } + + // MARK: - Helpers + + private func date(iso: String) -> Date { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "UTC") + fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return fmt.date(from: iso)! + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/PskReporterTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/PskReporterTests.swift new file mode 100644 index 000000000..5f4e78639 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/PskReporterTests.swift @@ -0,0 +1,313 @@ +import XCTest +@testable import FT8Engine + +final class PskReporterTests: XCTestCase { + + private func spot( + call: String = "K1ABC", freq: Int64 = 14_074_123, snr: Int = -12, + mode: String = "FT8", locator: String? = "FN42", flowStart: Int64 = 1_700_000_000 + ) -> PskSpot { + PskSpot(senderCallsign: call, frequencyHz: freq, snr: snr, mode: mode, + senderLocator: locator, flowStartSeconds: flowStart) + } + + private func u32be(_ v: UInt32) -> [UInt8] { + [UInt8((v >> 24) & 0xFF), UInt8((v >> 16) & 0xFF), + UInt8((v >> 8) & 0xFF), UInt8(v & 0xFF)] + } + + // MARK: encodeVarString + + func testEncodeVarStringShort() { + XCTAssertEqual(PskReporterEncoder.encodeVarString(""), [0]) + XCTAssertEqual(PskReporterEncoder.encodeVarString("AB"), [2, 0x41, 0x42]) + } + + func testEncodeVarStringLongUsesThreeBytePrefix() { + let s = String(repeating: "x", count: 300) + let out = PskReporterEncoder.encodeVarString(s) + XCTAssertEqual(Array(out.prefix(3)), [0xFF, 0x01, 0x2C]) // 300 = 0x012C + XCTAssertEqual(out.count, 303) + } + + // MARK: templates (golden bytes vs the Android layout) + + func testReceiverTemplateGoldenBytes() { + let ent: [UInt8] = [0x00, 0x00, 0x76, 0x8F] // enterprise 30351 + var expected: [UInt8] = [ + 0x00, 0x03, // options template set + 0x00, 0x34, // total length 52 (50 + 2 padding) + 0x50, 0xE2, // template id 20706 + 0x00, 0x05, // field count 5 + 0x00, 0x00, // scope field count 0 + ] + for field: UInt8 in [0x02, 0x04, 0x08, 0x09, 0x0D] { + expected += [0x80, field, 0xFF, 0xFF] + ent + } + expected += [0x00, 0x00] // padding to 4-byte boundary + XCTAssertEqual(PskReporterEncoder.encodeReceiverTemplate(), expected) + } + + func testSenderTemplateGoldenBytes() { + let ent: [UInt8] = [0x00, 0x00, 0x76, 0x8F] + var expected: [UInt8] = [ + 0x00, 0x02, // data template set + 0x00, 0x3C, // total length 60 (no padding needed) + 0x50, 0xE3, // template id 20707 + 0x00, 0x07, // field count 7 + ] + expected += [0x80, 0x01, 0xFF, 0xFF] + ent // senderCallsign, var + expected += [0x80, 0x05, 0x00, 0x04] + ent // frequency, uint32 + expected += [0x80, 0x06, 0x00, 0x01] + ent // sNR, int8 + expected += [0x80, 0x0A, 0xFF, 0xFF] + ent // mode, var + expected += [0x80, 0x03, 0xFF, 0xFF] + ent // senderLocator, var + expected += [0x80, 0x0B, 0x00, 0x01] + ent // informationSource, uint8 + expected += [0x00, 0x96, 0x00, 0x04] // flowStartSeconds: STANDARD element, no enterprise + XCTAssertEqual(PskReporterEncoder.encodeSenderTemplate(), expected) + } + + // MARK: receiver data set + + func testReceiverDataSetGoldenBytes() { + let out = PskReporterEncoder.encodeReceiverDataSet( + call: "K1ABC", grid: "FN20", software: "FT8AF 1.0", antenna: "", rig: "") + // body = 6 + 5 + 10 + 1 + 1 = 23; set = 27; padded to 28 + var expected: [UInt8] = [0x50, 0xE2, 0x00, 0x1C] + expected += [5] + Array("K1ABC".utf8) + expected += [4] + Array("FN20".utf8) + expected += [9] + Array("FT8AF 1.0".utf8) + expected += [0, 0] // empty antenna + rig + expected += [0] // padding + XCTAssertEqual(out, expected) + } + + // MARK: sender record + + func testSenderRecordGoldenBytes() { + let out = PskReporterEncoder.encodeSenderRecord(spot()) + var expected: [UInt8] = [5] + Array("K1ABC".utf8) + expected += u32be(14_074_123) + expected += [0xF4] // snr -12 as int8 + expected += [3] + Array("FT8".utf8) + expected += [4] + Array("FN42".utf8) + expected += [1] // informationSource = automatic + expected += u32be(1_700_000_000) + XCTAssertEqual(out, expected) + } + + func testSenderRecordNilLocatorEncodesEmptyString() { + let out = PskReporterEncoder.encodeSenderRecord(spot(locator: nil)) + // call(6) + freq(4) + snr(1) + mode(4) + locator(1: just the 0 length) + info(1) + flow(4) + XCTAssertEqual(out.count, 21) + XCTAssertEqual(out[15], 0) // empty-locator length prefix + } + + // MARK: buildPackets + + func testBuildPacketsHeaderAndTemplateInclusion() { + let enc = PskReporterEncoder(observationDomainId: 0x1234_5678, sequenceNumber: 0) + let nowMs: Int64 = 1_700_000_000_000 + let packets = enc.buildPackets( + rxCall: "KD2OGR", rxGrid: "FN20", software: "FT8AF 1.0", + spots: [spot()], nowMs: nowMs) + XCTAssertEqual(packets.count, 1) + let p = packets[0] + XCTAssertEqual(Array(p[0..<2]), [0x00, 0x0A]) // IPFIX version + XCTAssertEqual(Int(p[2]) << 8 | Int(p[3]), p.count) // declared length + XCTAssertEqual(Array(p[4..<8]), u32be(1_700_000_000)) // export time (s) + XCTAssertEqual(Array(p[8..<12]), u32be(0)) // sequence number + XCTAssertEqual(Array(p[12..<16]), u32be(0x1234_5678)) // observation domain + XCTAssertEqual(Array(p[16..<18]), [0x00, 0x03]) // templates included + XCTAssertEqual(p.count % 4, 0) // 4-byte aligned + XCTAssertEqual(enc.sequenceNumber, 1) + } + + func testTemplatesSentOnFirstThreePacketsThenOmittedThenHourlyRefresh() { + let enc = PskReporterEncoder(observationDomainId: 1, sequenceNumber: 0) + let nowMs: Int64 = 1_700_000_000_000 + func firstSetId(_ atMs: Int64) -> [UInt8] { + let p = enc.buildPackets(rxCall: "K", rxGrid: "AA00", software: "s", + spots: [spot()], nowMs: atMs)[0] + return Array(p[16..<18]) + } + XCTAssertEqual(firstSetId(nowMs), [0x00, 0x03]) // packet 1: templates + XCTAssertEqual(firstSetId(nowMs), [0x00, 0x03]) // packet 2: templates + XCTAssertEqual(firstSetId(nowMs), [0x00, 0x03]) // packet 3: templates + XCTAssertEqual(firstSetId(nowMs), [0x50, 0xE2]) // packet 4: receiver set directly + XCTAssertEqual(firstSetId(nowMs + 3_600_001), [0x00, 0x03]) // hourly refresh + XCTAssertEqual(firstSetId(nowMs + 3_600_002), [0x50, 0xE2]) // fresh again + } + + func testBuildPacketsSplitsAtMtu() { + let enc = PskReporterEncoder(observationDomainId: 1, sequenceNumber: 0) + let many = (0..<200).map { i in + spot(call: String(format: "K%03dABC", i), freq: 14_074_000 + Int64(i)) + } + let packets = enc.buildPackets(rxCall: "KD2OGR", rxGrid: "FN20", + software: "FT8AF 1.0", spots: many, + nowMs: 1_700_000_000_000) + XCTAssertGreaterThan(packets.count, 1) + for p in packets { + XCTAssertLessThanOrEqual(p.count, 1400) + XCTAssertEqual(p.count % 4, 0) + } + // Sequence advanced once per packet. + XCTAssertEqual(enc.sequenceNumber, UInt32(packets.count)) + } + + /// True when `needle` appears as a contiguous byte run inside `haystack`. + private func containsBytes(_ haystack: [UInt8], _ needle: [UInt8]) -> Bool { + guard !needle.isEmpty, haystack.count >= needle.count else { return false } + for start in 0...(haystack.count - needle.count) + where Array(haystack[start..<(start + needle.count)]) == needle { + return true + } + return false + } + + func testExactBoundaryBatchNeverExceedsMaxDatagramIncludingPadding() { + // Sweep locator lengths 0/2/4/6 chars to shift the record size (and + // therefore the fit boundary) across every 4-byte padding alignment: + // whatever lands exactly at the budget, the padded datagram must stay + // <= 1400 bytes. + for locLen in [0, 2, 4, 6] { + let enc = PskReporterEncoder(observationDomainId: 1, sequenceNumber: 0) + let loc = locLen > 0 ? String(repeating: "A", count: locLen) : nil + let many = (0..<300).map { i in + spot(call: String(format: "K%03dABC", i), locator: loc) + } + let packets = enc.buildPackets(rxCall: "KD2OGR", rxGrid: "FN20", + software: "FT8AF 1.0", spots: many, + nowMs: 1_700_000_000_000) + XCTAssertGreaterThan(packets.count, 1) + for p in packets { + XCTAssertLessThanOrEqual(p.count, 1400, + "locator length \(locLen) busted the datagram cap") + XCTAssertEqual(p.count % 4, 0) + XCTAssertEqual(Int(p[2]) << 8 | Int(p[3]), p.count) // declared == actual + } + } + } + + func testOversizedSingleRecordDroppedSubsequentRecordsStillSent() { + // A record that cannot fit even alone in an empty datagram is + // pathological: drop it, keep sending the rest, never emit > 1400. + let enc = PskReporterEncoder(observationDomainId: 1, sequenceNumber: 0) + let huge = spot(call: String(repeating: "X", count: 1500)) + let packets = enc.buildPackets( + rxCall: "KD2OGR", rxGrid: "FN20", software: "FT8AF 1.0", + spots: [huge, spot(call: "K1ABC"), spot(call: "W9DEF")], + nowMs: 1_700_000_000_000) + XCTAssertEqual(packets.count, 1) + let p = packets[0] + XCTAssertLessThanOrEqual(p.count, 1400) + XCTAssertTrue(containsBytes(p, Array("K1ABC".utf8))) + XCTAssertTrue(containsBytes(p, Array("W9DEF".utf8))) + XCTAssertFalse(containsBytes(p, Array(String(repeating: "X", count: 20).utf8))) + // Sequence advanced once per emitted packet — dropped records don't + // consume sequence numbers. + XCTAssertEqual(enc.sequenceNumber, 1) + } + + func testOversizedRecordInMiddleOfBatchIsSkipped() { + let enc = PskReporterEncoder(observationDomainId: 1, sequenceNumber: 0) + let huge = spot(call: String(repeating: "X", count: 1500)) + let packets = enc.buildPackets( + rxCall: "KD2OGR", rxGrid: "FN20", software: "FT8AF 1.0", + spots: [spot(call: "K1ABC"), huge, spot(call: "W9DEF")], + nowMs: 1_700_000_000_000) + for p in packets { XCTAssertLessThanOrEqual(p.count, 1400) } + let all = packets.flatMap { $0 } + XCTAssertTrue(containsBytes(all, Array("K1ABC".utf8))) + XCTAssertTrue(containsBytes(all, Array("W9DEF".utf8))) + XCTAssertFalse(containsBytes(all, Array(String(repeating: "X", count: 20).utf8))) + XCTAssertEqual(enc.sequenceNumber, UInt32(packets.count)) + } + + func testAllRecordsOversizedProducesNoPackets() { + let enc = PskReporterEncoder(observationDomainId: 1, sequenceNumber: 0) + let packets = enc.buildPackets( + rxCall: "KD2OGR", rxGrid: "FN20", software: "FT8AF 1.0", + spots: [spot(call: String(repeating: "X", count: 1500)), + spot(call: String(repeating: "Y", count: 2000))], + nowMs: 1_700_000_000_000) + XCTAssertTrue(packets.isEmpty) + XCTAssertEqual(enc.sequenceNumber, 0) + } + + func testBuildPacketsEmptySpots() { + let enc = PskReporterEncoder(observationDomainId: 1, sequenceNumber: 0) + XCTAssertTrue(enc.buildPackets(rxCall: "K", rxGrid: "AA00", software: "s", + spots: [], nowMs: 0).isEmpty) + XCTAssertEqual(enc.sequenceNumber, 0) + } + + // MARK: dedup + + func testDedupWindowPerCallPerBand() { + let dedup = PskDedup() + let t0: Int64 = 1_000_000 + XCTAssertTrue(dedup.markIfFresh(call: "K1ABC", dialFreqHz: 14_074_000, nowMs: t0)) + // Same call+band inside the window: suppressed. + XCTAssertFalse(dedup.markIfFresh(call: "K1ABC", dialFreqHz: 14_074_000, nowMs: t0 + 1000)) + // Different band (dial MHz differs): fresh. + XCTAssertTrue(dedup.markIfFresh(call: "K1ABC", dialFreqHz: 7_074_000, nowMs: t0 + 1000)) + // Window expired: fresh again. + XCTAssertTrue(dedup.markIfFresh( + call: "K1ABC", dialFreqHz: 14_074_000, + nowMs: t0 + PskReporter.dedupWindowMs)) + } + + // MARK: makeSpot policy + + func testMakeSpotBuildsRfFrequencyFromDialPlusAudioOffset() { + let s = PskReporter.makeSpot( + callFrom: "K1ABC", i3: 1, n3: 0, grid: "FN42", snr: -7, + audioFreqHz: 1512, dialFreqHz: 14_074_000, myCall: "KD2OGR", + utcSeconds: 1_700_000_000) + XCTAssertEqual(s?.senderCallsign, "K1ABC") + XCTAssertEqual(s?.frequencyHz, 14_075_512) + XCTAssertEqual(s?.snr, -7) + XCTAssertEqual(s?.mode, "FT8") + XCTAssertEqual(s?.senderLocator, "FN42") + XCTAssertEqual(s?.flowStartSeconds, 1_700_000_000) + } + + func testMakeSpotStripsHashedCallsignBrackets() { + let s = PskReporter.makeSpot( + callFrom: "", i3: 1, n3: 0, grid: "", snr: 0, + audioFreqHz: 0, dialFreqHz: 14_074_000, myCall: "KD2OGR", utcSeconds: 0) + XCTAssertEqual(s?.senderCallsign, "K1ABC") + XCTAssertNil(s?.senderLocator) // short grid omitted + } + + func testMakeSpotDropsInvalidSelfAndFreeText() { + // Unresolved hash + XCTAssertNil(PskReporter.makeSpot( + callFrom: "<...>", i3: 1, n3: 0, grid: "", snr: 0, + audioFreqHz: 0, dialFreqHz: 0, myCall: "K", utcSeconds: 0)) + // Empty + XCTAssertNil(PskReporter.makeSpot( + callFrom: "", i3: 1, n3: 0, grid: "", snr: 0, + audioFreqHz: 0, dialFreqHz: 0, myCall: "K", utcSeconds: 0)) + // Our own callsign (case-insensitive) + XCTAssertNil(PskReporter.makeSpot( + callFrom: "kd2ogr", i3: 1, n3: 0, grid: "FN20", snr: 0, + audioFreqHz: 0, dialFreqHz: 0, myCall: "KD2OGR", utcSeconds: 0)) + // Free text (i3 = 0, n3 = 0) + XCTAssertNil(PskReporter.makeSpot( + callFrom: "K1ABC", i3: 0, n3: 0, grid: "FN42", snr: 0, + audioFreqHz: 0, dialFreqHz: 0, myCall: "KD2OGR", utcSeconds: 0)) + } + + // MARK: software string + + func testBuildSoftwareString() { + XCTAssertEqual(PskReporter.buildSoftwareString(version: "1.2", rigName: nil), + "FT8AF 1.2") + XCTAssertEqual(PskReporter.buildSoftwareString(version: "1.2", rigName: " "), + "FT8AF 1.2") + XCTAssertEqual(PskReporter.buildSoftwareString(version: "1.2", rigName: "IC-705"), + "FT8AF 1.2 (IC-705)") + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/QrzLogbookClientTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/QrzLogbookClientTests.swift new file mode 100644 index 000000000..564d733b3 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/QrzLogbookClientTests.swift @@ -0,0 +1,79 @@ +import XCTest +import FT8Engine + +final class QrzLogbookClientTests: XCTestCase { + + func testEndpoint() { + XCTAssertEqual(QrzLogbookClient.endpoint, "https://logbook.qrz.com/api") + } + + // MARK: request bodies + + func testInsertBodyGolden() { + let body = QrzLogbookClient.insertBody( + apiKey: "ABCD-1234", + adif: "K1ABC \n" + ) + XCTAssertEqual( + body, + "KEY=ABCD-1234&ACTION=INSERT&ADIF=%3Ccall%3A5%3EK1ABC+%3Ceor%3E%0A") + } + + func testStatusBodyGolden() { + XCTAssertEqual(QrzLogbookClient.statusBody(apiKey: "AB CD"), + "KEY=AB+CD&ACTION=STATUS") + } + + // MARK: form encoding (Java URLEncoder-compatible) + + func testFormEncodePassesUnreservedCharacters() { + XCTAssertEqual(QrzLogbookClient.formEncode("AZaz09.-*_"), "AZaz09.-*_") + } + + func testFormEncodeSpaceIsPlus() { + XCTAssertEqual(QrzLogbookClient.formEncode("a b"), "a+b") + } + + func testFormEncodeEscapesReservedAndUtf8Bytes() { + XCTAssertEqual(QrzLogbookClient.formEncode("<>:&=+\n"), + "%3C%3E%3A%26%3D%2B%0A") + XCTAssertEqual(QrzLogbookClient.formEncode("é"), "%C3%A9") + } + + // MARK: response parsing + + func testResultParsesFirstResultField() { + XCTAssertEqual(QrzLogbookClient.result(of: "RESULT=OK&COUNT=1&LOGID=999"), "OK") + XCTAssertEqual(QrzLogbookClient.result(of: "STATUS=FAIL&RESULT=FAIL&REASON=bad key"), + "FAIL") + XCTAssertEqual(QrzLogbookClient.result(of: "RESULT=REPLACE&COUNT=1"), "REPLACE") + } + + func testResultNilForMissingEmptyOrMalformed() { + XCTAssertNil(QrzLogbookClient.result(of: nil)) + XCTAssertNil(QrzLogbookClient.result(of: "")) + XCTAssertNil(QrzLogbookClient.result(of: "COUNT=1&LOGID=2")) + XCTAssertNil(QrzLogbookClient.result(of: "RESULT")) // no '=' + } + + func testResultKeepsEqualsInsideValue() { + XCTAssertEqual(QrzLogbookClient.result(of: "RESULT=A=B"), "A=B") + } + + // MARK: success predicates + + func testIsInsertSuccessAcceptsOkAndReplace() { + XCTAssertTrue(QrzLogbookClient.isInsertSuccess("RESULT=OK&COUNT=1")) + // REPLACE = the QSO already existed and was updated — still a success. + XCTAssertTrue(QrzLogbookClient.isInsertSuccess("RESULT=REPLACE&COUNT=1")) + XCTAssertFalse(QrzLogbookClient.isInsertSuccess("RESULT=FAIL&REASON=x")) + XCTAssertFalse(QrzLogbookClient.isInsertSuccess(nil)) + } + + func testIsStatusOkAcceptsOnlyOk() { + XCTAssertTrue(QrzLogbookClient.isStatusOk("RESULT=OK&DATA=...")) + XCTAssertFalse(QrzLogbookClient.isStatusOk("RESULT=REPLACE")) + XCTAssertFalse(QrzLogbookClient.isStatusOk("RESULT=FAIL")) + XCTAssertFalse(QrzLogbookClient.isStatusOk(nil)) + } +} diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift index a3ddbadaa..1b1705b73 100644 --- a/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift +++ b/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift @@ -204,4 +204,137 @@ final class QsoEngineTests: XCTestCase { XCTAssertEqual(FT8Time.yyyymmdd(fromMs: 1_700_000_000_000), "20231114") XCTAssertEqual(FT8Time.hhmmss(fromMs: 1_700_000_000_000), "221320") } + + // MARK: forceLog / abandonToCq (LOG and ✕ panel actions) + + func testForceLogAfterReportsExchangedLogsAndReturnsToCq() { + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) + _ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)]) // we sent R-08 → .rReport + XCTAssertEqual(e.status().stage, .rReport) + + guard case .completed(let rec)? = e.forceLog() else { + return XCTFail("expected a logged record after reports were exchanged") + } + XCTAssertEqual(rec.call, "K1ABC") + XCTAssertEqual(rec.rstSent, "-08") + XCTAssertEqual(rec.rstRcvd, "-12") + // autoReturnToCq (default true) → back at the CQ baseline. + XCTAssertEqual(e.status().stage, .cq) + XCTAssertEqual(e.txMessage, "CQ K0XYZ EN37") + XCTAssertNil(e.status().target) + } + + func testForceLogTooEarlyDoesNotLogButStillMovesOn() { + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) // stage .grid, no reports yet + XCTAssertNil(e.forceLog()) + XCTAssertNil(e.status().target) + XCTAssertEqual(e.status().stage, .cq) + } + + func testForceLogWithNoTargetIsNil() { + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.startCq() + XCTAssertNil(e.forceLog()) + // Untouched: still calling CQ. + XCTAssertEqual(e.txMessage, "CQ K0XYZ EN37") + } + + func testForceLogStopsWhenAutoReturnOff() { + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.autoReturnToCq = false + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) + _ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)]) + XCTAssertNotNil(e.forceLog()) + XCTAssertFalse(e.status().active) + XCTAssertNil(e.txMessage) + XCTAssertEqual(e.status().stage, .idle) + } + + func testForceLogDuringCourtesy73WindowDoesNotDoubleLog() { + // RR73-received flow: complete() logs the QSO and queues the courtesy + // 73 (stage .bye73, target still set). A LOG tap in that window must + // NOT emit a second identical record. + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) + _ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)]) // → .rReport + guard case .completed? = e.processRx([msg("K0XYZ", "K1ABC", "RR73", "", -8)]) else { + return XCTFail("expected the RR73 to complete and log the QSO") + } + XCTAssertEqual(e.status().stage, .bye73) + XCTAssertEqual(e.status().target, "K1ABC") + + // LOG during the courtesy-73 window: no second record, just move on. + XCTAssertNil(e.forceLog()) + XCTAssertNil(e.status().target) + XCTAssertEqual(e.status().stage, .cq) + } + + func testForceLogMidQsoStillEmitsExactlyOneRecord() { + // Genuine mid-QSO force-log (reports exchanged, NOT completed): + // exactly one record, and a repeat LOG produces nothing further. + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) + _ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)]) // → .rReport + guard case .completed(let rec)? = e.forceLog() else { + return XCTFail("expected a logged record from a mid-QSO force-log") + } + XCTAssertEqual(rec.call, "K1ABC") + XCTAssertNil(e.forceLog()) // target cleared — nothing more to log + } + + func testForceLogAfterManualBye73StillLogsOnce() { + // Operator manually picked Tx5 (73) before the sequencer completed: + // nothing has been logged yet, so LOG must still emit the record. + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) + _ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)]) // → .rReport + e.setStage(.bye73) + guard case .completed(let rec)? = e.forceLog() else { + return XCTFail("expected a record — this QSO was never logged") + } + XCTAssertEqual(rec.call, "K1ABC") + } + + func testNextQsoAfterCourtesy73LogsNormally() { + // The record-emitted latch must reset with the QSO context: a full + // second QSO with a new station logs its own record. + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) + _ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)]) + _ = e.processRx([msg("K0XYZ", "K1ABC", "RR73", "", -8)]) // logged, .bye73 + _ = e.processRx([]) // courtesy 73 slot passed → back to CQ + + _ = e.processRx([msg("K0XYZ", "W9DEF", "EN52", "EN52", -3)]) + _ = e.processRx([msg("K0XYZ", "W9DEF", "R-06", "", -3)]) + let out = e.processRx([msg("K0XYZ", "W9DEF", "73", "", -3)]) + guard case .completed(let rec)? = out else { + return XCTFail("expected the second QSO to complete and log") + } + XCTAssertEqual(rec.call, "W9DEF") + } + + func testAbandonToCqDropsTargetWithoutLogging() { + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) + _ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)]) + e.abandonToCq() + XCTAssertNil(e.status().target) + XCTAssertEqual(e.status().stage, .cq) + XCTAssertEqual(e.txMessage, "CQ K0XYZ EN37") + } + + func testAutoReturnToCqOffStopsAfterCompletion() { + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.autoReturnToCq = false + e.startCq() + _ = e.processRx([msg("K0XYZ", "K1ABC", "FN42", "FN42", -10)]) + _ = e.processRx([msg("K0XYZ", "K1ABC", "R-15", "", -10)]) + guard case .completed? = e.processRx([msg("K0XYZ", "K1ABC", "73", "", -10)]) else { + return XCTFail("expected completed QSO") + } + XCTAssertFalse(e.status().active) + XCTAssertNil(e.txMessage) + } } diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/TuneToneTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/TuneToneTests.swift new file mode 100644 index 000000000..62f758827 --- /dev/null +++ b/ios/FT8AFKit/Tests/FT8EngineTests/TuneToneTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import FT8Engine + +final class TuneToneTests: XCTestCase { + + func testSampleCountMatchesDuration() { + let s = TuneTone.samples(freqHz: 1500, sampleRate: 12_000, durationSec: 3) + XCTAssertEqual(s.count, 36_000) + } + + func testDegenerateInputsReturnEmpty() { + XCTAssertTrue(TuneTone.samples(freqHz: 1500, sampleRate: 12_000, durationSec: 0).isEmpty) + XCTAssertTrue(TuneTone.samples(freqHz: 1500, sampleRate: 0, durationSec: 5).isEmpty) + XCTAssertTrue(TuneTone.samples(freqHz: 0, sampleRate: 12_000, durationSec: 5).isEmpty) + XCTAssertTrue(TuneTone.samples(freqHz: -100, sampleRate: 12_000, durationSec: 5).isEmpty) + } + + func testAmplitudeBoundedAndReachedInSteadyState() { + let amp: Float = 0.9 + let s = TuneTone.samples(freqHz: 1000, sampleRate: 12_000, durationSec: 1, amplitude: amp) + let peak = s.map(abs).max() ?? 0 + XCTAssertLessThanOrEqual(peak, amp + 1e-4) + XCTAssertGreaterThan(peak, amp * 0.99) // full amplitude reached mid-buffer + } + + func testRampsAvoidKeyClicks() { + let s = TuneTone.samples(freqHz: 1500, sampleRate: 12_000, durationSec: 1) + // Starts and ends at (near) zero. + XCTAssertEqual(s.first ?? 1, 0, accuracy: 1e-6) + XCTAssertEqual(abs(s.last ?? 1), 0, accuracy: 0.01) + // Every sample inside the head ramp is attenuated below full scale. + let rampSamples = 12_000 * TuneTone.rampMs / 1000 + let headPeak = s[0..