diff --git a/RFR-App/RFR.xcodeproj/project.pbxproj b/RFR-App/RFR.xcodeproj/project.pbxproj index 84e9bfef..d8f79be1 100644 --- a/RFR-App/RFR.xcodeproj/project.pbxproj +++ b/RFR-App/RFR.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -23,8 +23,12 @@ AA3C062A2A3AF8B9002BD585 /* ControlBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C06292A3AF8B9002BD585 /* ControlBar.swift */; }; AA3C062D2A3B03C8002BD585 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA3C062B2A3B03C8002BD585 /* InfoPlist.strings */; }; AA3C06302A3B03C8002BD585 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA3C062E2A3B03C8002BD585 /* Localizable.strings */; }; - AA3C06512A40ED57002BD585 /* VoucherOverviewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C06502A40ED57002BD585 /* VoucherOverviewModel.swift */; }; AA3C06562A4180DC002BD585 /* MainMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C06552A4180DC002BD585 /* MainMap.swift */; }; + AA4054EA2BBEDC4E00370D81 /* VoucherRequirements.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4054E92BBEDC4E00370D81 /* VoucherRequirements.swift */; }; + AA4054EC2BC434BF00370D81 /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4054EB2BC434BF00370D81 /* MailView.swift */; }; + AA4054EF2BC56F7C00370D81 /* DataCapturing in Frameworks */ = {isa = PBXBuildFile; productRef = AA4054EE2BC56F7C00370D81 /* DataCapturing */; }; + AA40552A2BC6891200370D81 /* NoVoucher.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4055292BC6891200370D81 /* NoVoucher.swift */; }; + AA40552C2BC6895F00370D81 /* Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA40552B2BC6895F00370D81 /* Events.swift */; }; AA45BDA22AE6B420003FCB17 /* LoginStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA45BDA12AE6B420003FCB17 /* LoginStatus.swift */; }; AA4C48F02B5812C900164175 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = AA4C48EF2B5812C900164175 /* Sentry */; }; AA6C48962A93629800C3633D /* OAuthLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6C48952A93629800C3633D /* OAuthLoginView.swift */; }; @@ -48,7 +52,6 @@ AAA1AABA29E6FAE0002CBC91 /* SynchronizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA1AAB929E6FAE0002CBC91 /* SynchronizationViewModel.swift */; }; AAA1AABE29E8895A002CBC91 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA1AABD29E8895A002CBC91 /* Formatter.swift */; }; AAA1AAC129ED4544002CBC91 /* Statistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA1AAC029ED4544002CBC91 /* Statistics.swift */; }; - AAA1AAC429EED663002CBC91 /* HCaptcha in Frameworks */ = {isa = PBXBuildFile; productRef = AAA1AAC329EED663002CBC91 /* HCaptcha */; }; AAA5CEB02AC56E090088AFA6 /* ImpressumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA5CEAF2AC56E090088AFA6 /* ImpressumView.swift */; }; AAA5CEB22AC56E4A0088AFA6 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA5CEB12AC56E4A0088AFA6 /* ProfileView.swift */; }; AAC610642B231B070083AED5 /* DeleteAccountConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC610632B231B070083AED5 /* DeleteAccountConfirmationDialog.swift */; }; @@ -63,7 +66,6 @@ AACB768329BF639F00A9DB6F /* DataCapturing in Frameworks */ = {isa = PBXBuildFile; productRef = AACB768229BF639F00A9DB6F /* DataCapturing */; }; AACB768929C0989300A9DB6F /* RFRError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACB768829C0989300A9DB6F /* RFRError.swift */; }; AACB768C29C1C24B00A9DB6F /* InitializationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACB768B29C1C24B00A9DB6F /* InitializationView.swift */; }; - AAE8E46D2A2A33BB005AA4F1 /* VoucherOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE8E46C2A2A33BB005AA4F1 /* VoucherOverview.swift */; }; AAE8E46F2A2A33E3005AA4F1 /* VoucherReached.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE8E46E2A2A33E3005AA4F1 /* VoucherReached.swift */; }; AAE8E4712A2A33F5005AA4F1 /* VoucherEnabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE8E4702A2A33F5005AA4F1 /* VoucherEnabled.swift */; }; AAEF3B492B3425C800BDE7FE /* COPYING-APPENDIX-DE in Resources */ = {isa = PBXBuildFile; fileRef = AAEF3B482B3425C800BDE7FE /* COPYING-APPENDIX-DE */; }; @@ -106,8 +108,11 @@ AA3C06292A3AF8B9002BD585 /* ControlBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlBar.swift; sourceTree = ""; }; AA3C062C2A3B03C8002BD585 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; AA3C062F2A3B03C8002BD585 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - AA3C06502A40ED57002BD585 /* VoucherOverviewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoucherOverviewModel.swift; sourceTree = ""; }; AA3C06552A4180DC002BD585 /* MainMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMap.swift; sourceTree = ""; }; + AA4054E92BBEDC4E00370D81 /* VoucherRequirements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoucherRequirements.swift; sourceTree = ""; }; + AA4054EB2BC434BF00370D81 /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = ""; }; + AA4055292BC6891200370D81 /* NoVoucher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoVoucher.swift; sourceTree = ""; }; + AA40552B2BC6895F00370D81 /* Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Events.swift; sourceTree = ""; }; AA45BDA12AE6B420003FCB17 /* LoginStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginStatus.swift; sourceTree = ""; }; AA6C48952A93629800C3633D /* OAuthLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthLoginView.swift; sourceTree = ""; }; AA6C489E2A938EDB00C3633D /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; @@ -147,7 +152,6 @@ AACB768029BF626500A9DB6F /* DataCapturing */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DataCapturing; path = ../DataCapturing; sourceTree = ""; }; AACB768829C0989300A9DB6F /* RFRError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RFRError.swift; sourceTree = ""; }; AACB768B29C1C24B00A9DB6F /* InitializationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitializationView.swift; sourceTree = ""; }; - AAE8E46C2A2A33BB005AA4F1 /* VoucherOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoucherOverview.swift; sourceTree = ""; }; AAE8E46E2A2A33E3005AA4F1 /* VoucherReached.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoucherReached.swift; sourceTree = ""; }; AAE8E4702A2A33F5005AA4F1 /* VoucherEnabled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoucherEnabled.swift; sourceTree = ""; }; AAEF3B482B3425C800BDE7FE /* COPYING-APPENDIX-DE */ = {isa = PBXFileReference; lastKnownFileType = text; path = "COPYING-APPENDIX-DE"; sourceTree = ""; }; @@ -170,8 +174,8 @@ buildActionMask = 2147483647; files = ( AA4C48F02B5812C900164175 /* Sentry in Frameworks */, + AA4054EF2BC56F7C00370D81 /* DataCapturing in Frameworks */, AA6C48992A9365B200C3633D /* AppAuth in Frameworks */, - AAA1AAC429EED663002CBC91 /* HCaptcha in Frameworks */, AACB768329BF639F00A9DB6F /* DataCapturing in Frameworks */, AA6C489B2A9365B200C3633D /* AppAuthCore in Frameworks */, ); @@ -220,12 +224,14 @@ AA3C064B2A3E4621002BD585 /* Voucher */ = { isa = PBXGroup; children = ( - AAE8E46C2A2A33BB005AA4F1 /* VoucherOverview.swift */, AAE8E46E2A2A33E3005AA4F1 /* VoucherReached.swift */, AAE8E4702A2A33F5005AA4F1 /* VoucherEnabled.swift */, AA8FC7642A2CE82B006D52F3 /* Voucher.swift */, AA8FC7682A2DB438006D52F3 /* VoucherViewModel.swift */, - AA3C06502A40ED57002BD585 /* VoucherOverviewModel.swift */, + AA4054E92BBEDC4E00370D81 /* VoucherRequirements.swift */, + AA4054EB2BC434BF00370D81 /* MailView.swift */, + AA4055292BC6891200370D81 /* NoVoucher.swift */, + AA40552B2BC6895F00370D81 /* Events.swift */, ); path = Voucher; sourceTree = ""; @@ -425,10 +431,10 @@ name = RFR; packageProductDependencies = ( AACB768229BF639F00A9DB6F /* DataCapturing */, - AAA1AAC329EED663002CBC91 /* HCaptcha */, AA6C48982A9365B200C3633D /* AppAuth */, AA6C489A2A9365B200C3633D /* AppAuthCore */, AA4C48EF2B5812C900164175 /* Sentry */, + AA4054EE2BC56F7C00370D81 /* DataCapturing */, ); productName = RFR; productReference = AA87D0682982ADE8002F6B3B /* Ready for Robots Development.app */; @@ -492,6 +498,7 @@ AAA1AAC229EED663002CBC91 /* XCRemoteSwiftPackageReference "HCaptcha-ios-sdk" */, AA6C48972A9365B200C3633D /* XCRemoteSwiftPackageReference "AppAuth-iOS" */, AA4C48EE2B5812C800164175 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, + AA4054ED2BC56F7C00370D81 /* XCLocalSwiftPackageReference "../DataCapturing" */, ); productRefGroup = AA87D0692982ADE8002F6B3B /* Products */; projectDirPath = ""; @@ -553,7 +560,6 @@ }; AA2B0B6E2B7530B100756DBD /* Highlight TODO and FIXME as warnings */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -587,7 +593,6 @@ files = ( AAE8E4712A2A33F5005AA4F1 /* VoucherEnabled.swift in Sources */, AA6C489F2A938EDB00C3633D /* LoginViewController.swift in Sources */, - AA3C06512A40ED57002BD585 /* VoucherOverviewModel.swift in Sources */, AAC9D2E82983DEC3003C4CC7 /* LiveViewModel.swift in Sources */, AAA1AAAE29D6FB8D002CBC91 /* OSLog.swift in Sources */, AAC9D2EF29840651003C4CC7 /* LabelledDivider.swift in Sources */, @@ -597,13 +602,15 @@ AA6C48962A93629800C3633D /* OAuthLoginView.swift in Sources */, AA2ACAC92B8CDA33007ADE14 /* AppDelegate.swift in Sources */, AAEF3B4B2B3590AA00BDE7FE /* ErrorTextView.swift in Sources */, + AA40552A2BC6891200370D81 /* NoVoucher.swift in Sources */, + AA40552C2BC6895F00370D81 /* Events.swift in Sources */, AA3C062A2A3AF8B9002BD585 /* ControlBar.swift in Sources */, AA87D0972982B3ED002F6B3B /* MeasurementsView.swift in Sources */, - AAE8E46D2A2A33BB005AA4F1 /* VoucherOverview.swift in Sources */, AAA1AAB429E540D0002CBC91 /* MeasurementsViewModel.swift in Sources */, AA1E405B2B1764C900CACBE6 /* DataProtection.swift in Sources */, AAA1AABE29E8895A002CBC91 /* Formatter.swift in Sources */, AA87D0992982B43F002F6B3B /* StatisticsView.swift in Sources */, + AA4054EC2BC434BF00370D81 /* MailView.swift in Sources */, AA9AF31D2AEA8E9E0003BE54 /* Config.swift in Sources */, AAA1AAB829E6E729002CBC91 /* ErrorView.swift in Sources */, AA1E40592B175C5300CACBE6 /* WebView.swift in Sources */, @@ -626,6 +633,7 @@ AAA1AABA29E6FAE0002CBC91 /* SynchronizationViewModel.swift in Sources */, AA119BBC2BB57022002D1DC8 /* SubmitDataButton.swift in Sources */, AAC9D2FF29895D16003C4CC7 /* KeyValueView.swift in Sources */, + AA4054EA2BBEDC4E00370D81 /* VoucherRequirements.swift in Sources */, AAFB60992A31BFA9008AEC76 /* UIKitMapView.swift in Sources */, AA8FC7692A2DB438006D52F3 /* VoucherViewModel.swift in Sources */, AAC610642B231B070083AED5 /* DeleteAccountConfirmationDialog.swift in Sources */, @@ -746,7 +754,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"RFR/Resources/Preview Content\""; DEVELOPMENT_TEAM = 45GZ5C9N64; @@ -765,7 +773,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.5; + MARKETING_VERSION = 3.2.2; PRODUCT_BUNDLE_IDENTIFIER = de.cyface.RFR; PRODUCT_NAME = "Ready for Robots"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -871,7 +879,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"RFR/Resources/Preview Content\""; DEVELOPMENT_TEAM = 45GZ5C9N64; @@ -890,7 +898,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.5; + MARKETING_VERSION = 3.2.2; PRODUCT_BUNDLE_IDENTIFIER = de.cyface.RFR.staging; PRODUCT_NAME = "Ready for Robots Staging"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -990,7 +998,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"RFR/Resources/Preview Content\""; DEVELOPMENT_TEAM = 45GZ5C9N64; @@ -1009,7 +1017,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.5; + MARKETING_VERSION = 3.2.2; PRODUCT_BUNDLE_IDENTIFIER = de.cyface.RFR.staging; PRODUCT_NAME = "Ready for Robots Staging"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1108,7 +1116,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"RFR/Resources/Preview Content\""; DEVELOPMENT_TEAM = 45GZ5C9N64; @@ -1127,7 +1135,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.5; + MARKETING_VERSION = 3.2.2; PRODUCT_BUNDLE_IDENTIFIER = de.cyface.RFR; PRODUCT_NAME = "Ready for Robots"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1734,7 +1742,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"RFR/Resources/Preview Content\""; DEVELOPMENT_TEAM = 45GZ5C9N64; @@ -1753,7 +1761,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.5; + MARKETING_VERSION = 3.2.2; PRODUCT_BUNDLE_IDENTIFIER = de.cyface.RFR.dev; PRODUCT_NAME = "Ready for Robots Development"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1772,7 +1780,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"RFR/Resources/Preview Content\""; DEVELOPMENT_TEAM = 45GZ5C9N64; @@ -1791,7 +1799,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1.5; + MARKETING_VERSION = 3.2.2; PRODUCT_BUNDLE_IDENTIFIER = de.cyface.RFR.dev; PRODUCT_NAME = "Ready for Robots Development"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1912,6 +1920,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + AA4054ED2BC56F7C00370D81 /* XCLocalSwiftPackageReference "../DataCapturing" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../DataCapturing; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ AA4C48EE2B5812C800164175 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { isa = XCRemoteSwiftPackageReference; @@ -1950,6 +1965,10 @@ package = AA6C48972A9365B200C3633D /* XCRemoteSwiftPackageReference "AppAuth-iOS" */; productName = AppAuth; }; + AA4054EE2BC56F7C00370D81 /* DataCapturing */ = { + isa = XCSwiftPackageProductDependency; + productName = DataCapturing; + }; AA4C48EF2B5812C900164175 /* Sentry */ = { isa = XCSwiftPackageProductDependency; package = AA4C48EE2B5812C800164175 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; @@ -1965,11 +1984,6 @@ package = AA6C48972A9365B200C3633D /* XCRemoteSwiftPackageReference "AppAuth-iOS" */; productName = AppAuthCore; }; - AAA1AAC329EED663002CBC91 /* HCaptcha */ = { - isa = XCSwiftPackageProductDependency; - package = AAA1AAC229EED663002CBC91 /* XCRemoteSwiftPackageReference "HCaptcha-ios-sdk" */; - productName = HCaptcha; - }; AACB768229BF639F00A9DB6F /* DataCapturing */ = { isa = XCSwiftPackageProductDependency; productName = DataCapturing; diff --git a/RFR-App/RFR.xcodeproj/xcshareddata/xcschemes/RFR Production.xcscheme b/RFR-App/RFR.xcodeproj/xcshareddata/xcschemes/RFR Production.xcscheme index eee53fb1..94c1c76b 100644 --- a/RFR-App/RFR.xcodeproj/xcshareddata/xcschemes/RFR Production.xcscheme +++ b/RFR-App/RFR.xcodeproj/xcshareddata/xcschemes/RFR Production.xcscheme @@ -7,7 +7,7 @@ buildImplicitDependencies = "YES"> - - - - - + - - + + - - + + - - + + - + + + + + + version = "1.3"> - - - - - + - - + + - - + + - - - - + + - + + + + + Void, onFailure: @escaping (Error) -> Void) { @@ -55,6 +56,7 @@ class MockAuthenticator: Authenticator { - Author: Klemens Muthmann - Version: 1.0.0 + - Since: 3.2.2 */ class MockDataStoreStack: DataStoreStack { @@ -82,4 +84,25 @@ class MockDataStoreStack: DataStoreStack { } } +/** + A mock for the vouchers interface avoiding actual network communication. + + This should be used during testing and for previews. + + - Author: Klemens Muthmann + - Version: 1.0.0 + - Since: 3.2.2 + */ +struct MockVouchers: Vouchers { + /// The amount of simulated vouchers available. + var count: Int + /// The voucher currently enabled for the active user. + let voucher: Voucher + + /// Simulate requesting a voucher from the server. This will always return the hard coded voucher provided on initialization. + func requestVoucher() async throws -> Voucher { + return voucher + } +} + #endif diff --git a/RFR-App/RFR/RFRApp.swift b/RFR-App/RFR/RFRApp.swift index a39decec..7a0e87b9 100644 --- a/RFR-App/RFR/RFRApp.swift +++ b/RFR-App/RFR/RFRApp.swift @@ -76,7 +76,7 @@ struct RFRApp: App { Those errors are published via the ``error`` property of this class. - Author: Klemens Muthmann - - Version: 1.0.1 + - Version: 1.0.2 - Since: 3.1.2 */ class AppModel: ObservableObject { @@ -90,7 +90,7 @@ class AppModel: ObservableObject { /// View model used to manage information about the complete collection of local measurements. let measurementsViewModel: MeasurementsViewModel /// View model used to manage voucher progress and download vouchers from a voucher server. - let voucherViewModel: VoucherViewModel + var voucherViewModel: VoucherViewModel /// The authenticator used by this application to communicate with the Cyface Data Collector and the voucher server. let authenticator: Authenticator /// Tells the view about errors occuring during initialization. @@ -145,11 +145,16 @@ class AppModel: ObservableObject { uploadProcessBuilder: uploadProcessBuilder, measurementsViewModel: measurementsViewModel ) - voucherViewModel = VoucherViewModel( - authenticator: authenticator, - url: incentivesUrl, + let voucherRequirements = VoucherRequirements( dataStoreStack: dataStoreStack ) + voucherViewModel = VoucherViewModel( + vouchers: VouchersApi( + authenticator: authenticator, + url: incentivesUrl + ), + voucherRequirements: voucherRequirements + ) Task { do { @@ -167,6 +172,7 @@ class AppModel: ObservableObject { try context.save() } try await measurementsViewModel.setup() + initialized = true } catch { self.error = error diff --git a/RFR-App/RFR/Resources/de.lproj/Localizable.strings b/RFR-App/RFR/Resources/de.lproj/Localizable.strings index cba3ab96..35b2bd32 100644 --- a/RFR-App/RFR/Resources/de.lproj/Localizable.strings +++ b/RFR-App/RFR/Resources/de.lproj/Localizable.strings @@ -46,33 +46,6 @@ /* No comment provided by engineer. */ "Datenschutzbestimmungen" = "Datenschutzbestimmungen"; -/* Tell the user, that the OAuth discovery failed for some reason. -The actual reason is provided as a String message, as the first argument. */ -"de.cyface.error.oauthauthenticationerror.discoveryFailed" = "Es war nicht möglich die Einstellungen für die Authentifizierung vom Identitätsanbieter zu laden. Dies wurde durch %s verursacht. Dies kann auftreten, wenn der Identitätsanbieter gewartet wird oder derzeit nicht erreichbar ist, weil Ihr Telefon zum Beispiel keine funktionsfähige Internetverbindung hat. Bitte stellen Sie sicher, dass sie mit dem Internet verbunden sind und wiederholen Sie den Vorgang später. Falls das Problem auch nach einiger Zeit noch auftritt, wenden Sie sich an den Anbieter dieser Anwendung."; - -/* Tell the user, that the wrong HTTP status code was recieved. It should be 200. The actual value is provided as the first argument. */ -"de.cyface.error.oauthauthenticationerror.errorResponse" = "HTTP Status Code %d empfangen. Erwartet war 200 OK. Dieser Fehler kann zum Beispiel durch eine Serverwartung auftreten. Bitte wiederholen Sie den Vorgang zu einem späteren Zeitpunkt. Falls der Fehler über längere Zeit auftritt, wenden Sie sich bitte an den Anbieter dieser Anwendung."; - -/* Tell the user, that the response was not an HTTP response. This should not happen unless there is some serious implemenetation error. */ -"de.cyface.error.oauthauthenticationerror.invalidResponse" = "Die Antwort war keine gültige HTTP Antwort. Dieser Fehler sollte normalerweise nicht passieren. Es kann sein, dass Sie eine fehlerbehaftete Zwischenversion verwenden. Bitte aktualisieren Sie die Anwendung oder warten Sie auf die neueste Version."; - -/* Tell the user that an invalid JWT token was encountered! */ -"de.cyface.error.oauthauthenticationerror.invalidtoken" = "Es wurde eine falsch formatierte Authentifizierung empfangen. Dies lässt sich meistens auf einen Fehler bei der Einstellung des Identitätsanbieters zurückführen. Bitte wenden Sie sich an den Anbieter dieser Anwendung, um weitere Hinweise zu erhalten."; - -/* Tell the user, that the internal auth state did not exist. -Since it is gracefully initialized before used for the first time, this is an error, that should not happen in production. -The cause of this error is provided as the first parameter. */ -"de.cyface.error.oauthauthenticationerror.missingAuthState" = "Die Anwendung konnte den Stand der Authentifizierung nicht ermitteln. Dies deutet auf einen ernsthaften Fehler in der Umsetzung der Anwendung hin. Es könnte sein, dass Sie eine fehlerbehaftete Zwischenversion haben. Bitte aktualisieren Sie die Anwendung oder warten Sie auf die nächste Aktualisierung. Falls Sie Hilfe brauchen, wenden Sie sich bitte an den Anbieter dieser Anwendung."; - -/* Tell the user, that OAuth was called in a wrong state. Namely there was no ViewController provided to return to, after successful authentication. */ -"de.cyface.error.oauthauthenticationerror.missingCallackController" = "Authentifizierung schlug fehl, weil die Anwendung nicht weiß, welchen Bildschirm sie nach Abschluss der Anmeldung anzeigen soll. Dies sollte nur auftreten, wenn Sie eine fehlerbehaftete Zwischenversion verwenden. Bitte aktualisieren Sie die Anwendung. Falls Sie Hilfe benötigen, wenden Sie sich bitte an den Anbieter dieser Anwendung."; - -/* This error should not happen on a properly developed system. Tell the user to call for support! */ -"de.cyface.error.oauthauthenticationerror.missingresponse" = "Authentifizierungsanfrage wurde ohne Antwort aber auch ohne Fehler abgebrochen. Es ist unmöglich mit einem der beiden Fälle fortzufahren. Dies deutet auf eine fehlerhafte Zwischenversion der Anwendung hin. Bitte aktualisieren Sie die Anwendung und wenden Sie sich an den Anbieter dieser Anwendung, wenn Sie Unterstützung benötigen."; - -/* Tell the user that you received no valid auth token on a refresh request. This should actually not happen and points to some serious implementation mistakes. */ -"de.cyface.error.oauthauthenticatorerror.tokenMissing" = "Die Authentifizierung konnte nicht erneuert werden."; - /* Tell the user that the e-mail address used for registration is not available anymore. In such cases the user either uses an e-mail address not belonging to her/him/them or tries to reregister, which is not permitted. To reset the password, the user needs to contact the Cyface support. */ @@ -125,15 +98,45 @@ The returned status code is provided as an Int as the first parameter! */ /* Explain that no valid voucher information was found for the current user. */ "de.cyface.error.rfrerror.missingVoucher" = "Ihr Gutschein konnte nicht geladen werden."; -/* The system was unable to get a valid authentication token from the server. Either the server is not available or the user used invalid Credentials. */ -"de.cyface.error.rfrerror.unableToAuthenticate" = "Ihr Benutzerkonto konnte nicht authentifiziert werden. Möglicherweise ist der Authentifizierungsserver ausgefallen oder Sie haben ungültige Anmeldeinformationen angegeben. Bitte melden Sie sich ab und wieder an."; - /* Tell the user that a measurement could not be loaded. The device wide unique identifier of the measurement is provided as the first parameter. */ "de.cyface.error.rfrerror.unableToLoadMeasurement" = "Messung %@ konnte nicht geladen werden!"; /* Explain to the user, that the system was unable to load the voucher overview. */ "de.cyface.error.rfrerror.voucherOverviewFailed" = "Gutscheinübersicht konnte nicht geladen werden."; +/* The subject of the participation E-Mail when sending a voucher. */ +"de.cyface.rfr.label.VoucherViewModel.mail_subject" = "Gewinnlos: \\(voucher.code)"; + +/* Label telling the user when the next event happens. The date is provided as the first arguemnt. */ +"de.cyface.rfr.text.NoVoucher.next_event" = "Nächste Gewinnaktion ab %@"; + +/* Label telling the user that there are no events at the moment. */ +"de.cyface.rfr.text.NoVoucher.no_events" = "Derzeit sind keine Gewinnaktionen verfügbar"; + +/* Label telling the user that there are no events planned. */ +"de.cyface.rfr.text.NoVoucher.nothing_planned" = "Derzeit sind keine Gewinnaktionen geplant"; + +/* A label containing the active voucher code, which is provided as the first argument. */ +"de.cyface.rfr.text.VoucherEnabled.code" = "Gutscheincode: %@"; + +/* Tell the where to send a voucher code and what happens after sending it there. */ +"de.cyface.rfr.text.VoucherEnabled.game_explanation" = "Diesen Code an gewinnspiel@ready-for-robots.de schicken und an der Verlosung teilnehmen."; + +/* Button label for sending an E-Mail. */ +"de.cyface.rfr.text.VoucherEnabled.send_mail" = "E-Mail Senden"; + +/* No comment provided by engineer. */ +"de.cyface.rfr.text.VoucherReached.show_voucher" = "Gewinnspiellos anzeigen"; + +/* Tell the user that they achieved all goals to get the next voucher */ +"de.cyface.rfr.text.VoucherReached.voucher_active" = "Gewinnspiellos freigeschaltet"; + +/* Tell the user how often they should pass town hall. The number is provided as the first argument */ +"de.cyface.rfr.text.VoucherRequirements.condition_town_hall" = "Bitte fahren Sie %d mal am Rathaus vorbei, um ein Gewinnlos zu erhalten!"; + +/* Tell the user how many of their measurements they are still required to upload. The number of uploads required is provided as the first parameter. The second parameter are the uploads required alltogether. */ +"de.cyface.rfr.text.VoucherRequirements.condition_upload" = "Laden Sie bitte noch %d von %d Messungen hoch, um ein Gewinnlos zu erhalten!"; + /* No comment provided by engineer. */ "E-Mail Adresse" = "E-Mail Adresse"; diff --git a/RFR-App/RFR/Resources/en.lproj/Localizable.strings b/RFR-App/RFR/Resources/en.lproj/Localizable.strings index 2c775f69..b5ea2949 100644 --- a/RFR-App/RFR/Resources/en.lproj/Localizable.strings +++ b/RFR-App/RFR/Resources/en.lproj/Localizable.strings @@ -92,6 +92,39 @@ The returned status code is provided as an Int as the first parameter! */ /* Tell the user that a measurement could not be loaded. The device wide unique identifier of the measurement is provided as the first parameter. */ "de.cyface.error.rfrerror.unableToLoadMeasurement" = "Measurement %@ could not be loaded!"; +/* The subject of the participation E-Mail when sending a voucher. */ +"de.cyface.rfr.label.VoucherViewModel.mail_subject" = "Raffle Ticket: %@"; + +/* Label telling the user when the next event happens. The date is provided as the first arguemnt. */ +"de.cyface.rfr.text.NoVoucher.next_event" = "Next giveaway starting at %s"; + +/* Label telling the user that there are no events at the moment. */ +"de.cyface.rfr.text.NoVoucher.no_events" = "Currently, there are no giveaways available"; + +/* Label telling the user that there are no events planned. */ +"de.cyface.rfr.text.NoVoucher.nothing_planned" = "Currently, there are no giveaways planned"; + +/* A label containing the active voucher code, which is provided as the first argument. */ +"de.cyface.rfr.text.VoucherEnabled.code" = "Raffle Ticket: %@"; + +/* Tell the where to send a voucher code and what happens after sending it there. */ +"de.cyface.rfr.text.VoucherEnabled.game_explanation" = "Send this Code to gewinnspiel@ready-for-robots.de for a chance to win!"; + +/* Button label for sending an E-Mail. */ +"de.cyface.rfr.text.VoucherEnabled.send_mail" = "Send E-Mail"; + +/* No comment provided by engineer. */ +"de.cyface.rfr.text.VoucherReached.show_voucher" = "Show Raffle Ticket"; + +/* Tell the user that they achieved all goals to get the next voucher */ +"de.cyface.rfr.text.VoucherReached.voucher_active" = "Raffle Ticket Unlocked"; + +/* Tell the user how often they should pass town hall. The number is provided as the first argument */ +"de.cyface.rfr.text.VoucherRequirements.condition_town_hall" = "Please cycle on %d days near the town hall to unlock the raffle ticket"; + +/* Tell the user how many of their measurements they are still required to upload. The number of uploads required is provided as the first parameter. The second parameter are the uploads required alltogether. */ +"de.cyface.rfr.text.VoucherRequirements.condition_upload" = "Please upload %d of %d measurements, for a chance to a raffle ticket!"; + /* No comment provided by engineer. */ "E-Mail Adresse" = "E-Mail Address"; diff --git a/RFR-App/RFR/Voucher/Events.swift b/RFR-App/RFR/Voucher/Events.swift new file mode 100644 index 00000000..8db38aa4 --- /dev/null +++ b/RFR-App/RFR/Voucher/Events.swift @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Ready for Robots iOS App. + * + * The Ready for Robots iOS App is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Ready for Robots iOS App is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Ready for Robots iOS App. If not, see . + */ +import Foundation + +/// Create the list of event ranges. +func createEvents() -> [ClosedRange] { + var start = DateComponents() + start.year = 2024 + start.month = 5 + start.day = 1 + start.timeZone = TimeZone(abbreviation: "CEST") // Japan Standard Time + start.hour = 0 + start.minute = 0 + + var end = DateComponents() + end.year = 2024 + end.month = 5 + end.day = 31 + end.timeZone = TimeZone(abbreviation: "CEST") + end.hour = 23 + end.minute = 59 + end.second = 59 + + return [Calendar.current.date(from: start)!...Calendar.current.date(from: end)!] +} + +/// `true` if there is a current event; `false`otherwise. +func thereIsCurrentEvent() -> Bool { + return events.map { event in event.contains(Date.now) }.reduce(false) { $0 || $1} +} + +/// Provide the next event range. +func nextEvent() -> ClosedRange? { + return events.sorted { $0.lowerBound < $1.lowerBound }.first { $0.lowerBound > Date.now} +} + +/// The list of events in the system. +let events = createEvents() diff --git a/RFR-App/RFR/Voucher/MailView.swift b/RFR-App/RFR/Voucher/MailView.swift new file mode 100644 index 00000000..b2cc2c6d --- /dev/null +++ b/RFR-App/RFR/Voucher/MailView.swift @@ -0,0 +1,127 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Ready for Robots iOS App. + * + * The Ready for Robots iOS App is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Ready for Robots iOS App is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Ready for Robots iOS App. If not, see . + */ +import SwiftUI +import UIKit +import MessageUI + +/// The type of callbacks used when calling the systems E-Mail application +typealias MailViewCallback = ((Result) -> Void)? + +/** + The view showng when the user wants to send its voucher via E-Mail. + + Since this is currently not supported by SwiftUI, it is implemented as a UIViewController using `UIViewControllerRepresentable`. + + - Author: Klemens Muthmann + - Version: 1.0.0 + - Since: 3.2.2 + */ +struct MailView: UIViewControllerRepresentable { + + /// Specifies whether to show this view or not. + @Environment(\.presentationMode) var presentation + /// The data of the mail to send. + var data: ComposeMailData + /// Called when the mail was sent. + let callback: MailViewCallback + + /** + The UIKit Coordinator for this representable. + + - Author: Klemens Muthmann + - Version: 1.0.0 + - Since: 3.2.2 + */ + class Coordinator: NSObject, MFMailComposeViewControllerDelegate { + @Binding var presentation: PresentationMode + var data: ComposeMailData + let callback: MailViewCallback + + init(presentation: Binding, + data: ComposeMailData, + callback: MailViewCallback) { + _presentation = presentation + self.data = data + self.callback = callback + } + + func mailComposeController(_ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: Error?) { + if let error = error { + callback?(.failure(error)) + } else { + callback?(.success(result)) + } + $presentation.wrappedValue.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(presentation: presentation, data: data, callback: callback) + } + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> MFMailComposeViewController { + let vc = MFMailComposeViewController() + vc.mailComposeDelegate = context.coordinator + vc.setSubject(data.subject) + vc.setToRecipients(data.recipients) + vc.setMessageBody(data.message, isHTML: false) + data.attachments?.forEach { + vc.addAttachmentData($0.data, mimeType: $0.mimeType, fileName: $0.fileName) + } + vc.accessibilityElementDidLoseFocus() + return vc + } + + func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: UIViewControllerRepresentableContext) { + } + + /// `true` if mail sending is allowed, `false` otherwise. + static var canSendMail: Bool { + MFMailComposeViewController.canSendMail() + } +} + +/** + The data used to create the mail + + - Author: Klemens Muthmann + - Version: 1.0.0 + - Since: 3.2.2 + */ +struct ComposeMailData { + let subject: String + let recipients: [String]? + let message: String + let attachments: [AttachmentData]? +} + +/** + Data attached to the E-Mail. + + - Author: Klemens Muthmann + - Version: 1.0.0 + - Since: 3.2.2 + */ +struct AttachmentData { + let data: Data + let mimeType: String + let fileName: String +} diff --git a/RFR-App/RFR/Voucher/NoVoucher.swift b/RFR-App/RFR/Voucher/NoVoucher.swift new file mode 100644 index 00000000..3572ed0d --- /dev/null +++ b/RFR-App/RFR/Voucher/NoVoucher.swift @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Ready for Robots App. + * + * The Ready for Robots App is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Ready for Robots App is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Ready for Robots App. If not, see . + */ + +import SwiftUI + +/** +The view shown if no voucher has been enabled. + + - Author: Klemens Muthmann + - Version: 1.0.0 + - Since: 3.2.2 + */ +struct NoVoucher: View { + /// `true` if a voucher may be redeemed; `false` otherwise. + let voucherRedeemable: Bool + /// Formatter used to display dates in this view. + var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() + + var body: some View { + if let nextEvent = nextEvent() { + Text( + String.localizedStringWithFormat( + NSLocalizedString( + "de.cyface.rfr.text.NoVoucher.next_event", + comment: "Label telling the user when the next event happens. The date is provided as the first arguemnt." + ), dateFormatter.string(from: nextEvent.lowerBound) + ) + ) // "Nächste Gewinnaktion ab \(dateFormatter.string(from: nextEvent.lowerBound))" + } else if voucherRedeemable { + Text("de.cyface.rfr.text.NoVoucher.no_events", comment: "Label telling the user that there are no events at the moment.") //Derzeit sind keine Gewinnaktionen verfügbar + } else { + Text("de.cyface.rfr.text.NoVoucher.nothing_planned", comment: "Label telling the user that there are no events planned.") // Derzeit sind keine Gewinnaktionen geplant + } + } +} + +#Preview { + NoVoucher(voucherRedeemable: true) +} + +#Preview { + NoVoucher(voucherRedeemable: false) +} diff --git a/RFR-App/RFR/Voucher/Voucher.swift b/RFR-App/RFR/Voucher/Voucher.swift index 79879c1e..dad93026 100644 --- a/RFR-App/RFR/Voucher/Voucher.swift +++ b/RFR-App/RFR/Voucher/Voucher.swift @@ -1,5 +1,5 @@ /* - * Copyright 2023 Cyface GmbH + * Copyright 2023-2024 Cyface GmbH * * This file is part of the Ready for Robots App. * @@ -20,15 +20,25 @@ import Foundation import DataCapturing /** -Model object representing the collection of vouchers on the server. + Model object representing the collection of vouchers on the server. + - Author: Klemens Muthmann + - Version: 1.0.1 + - Since: 3.2.2 + */ +protocol Vouchers { + var count: Int { get async throws } + func requestVoucher() async throws -> Voucher +} +/** This class is responsible for creating the connection to the voucher API, retrieving vouchers and that state of the collection of vouchers. - Author: Klemens Muthmann - - Version: 1.0.0 + - Version: 1.0.1 - Since: 3.1.2 */ -class Vouchers { +class VouchersApi: Vouchers { + // MARK: - Static Properties /// Used to decode JSON server responses. private static let decoder = JSONDecoder() @@ -86,7 +96,7 @@ class Vouchers { throw VoucherRequestError.requestFailed(statusCode: response.statusCode) } - let voucher = try Vouchers.decoder.decode(Voucher.self, from: data) + let voucher = try VouchersApi.decoder.decode(Voucher.self, from: data) self._voucher = voucher @@ -119,7 +129,7 @@ class Vouchers { throw VoucherRequestError.requestFailed(statusCode: response.statusCode) } - let voucherCount = try Vouchers.decoder.decode(Count.self, from: data) + let voucherCount = try VouchersApi.decoder.decode(Count.self, from: data) self._count = voucherCount.vouchers return voucherCount.vouchers @@ -139,7 +149,6 @@ class Vouchers { */ struct Voucher: Codable { let code: String - let until: String } /** diff --git a/RFR-App/RFR/Voucher/VoucherEnabled.swift b/RFR-App/RFR/Voucher/VoucherEnabled.swift index 5fb1c5e5..be99f2f7 100644 --- a/RFR-App/RFR/Voucher/VoucherEnabled.swift +++ b/RFR-App/RFR/Voucher/VoucherEnabled.swift @@ -25,7 +25,7 @@ import DataCapturing This view shows the actual voucher code. - Author: Klemens Muthmann - - Version: 1.0.0 + - Version: 1.0.1 - Since: 3.1.2 */ struct VoucherEnabled: View { @@ -36,12 +36,32 @@ struct VoucherEnabled: View { if let voucher = viewModel.voucher { VStack { - Divider() - Text("Gutscheincode: \(voucher.code)") + Text( + String.localizedStringWithFormat( + NSLocalizedString( + "de.cyface.rfr.text.VoucherEnabled.code", + comment: "A label containing the active voucher code, which is provided as the first argument." + ), voucher.code + ) + ).padding() + Rectangle().frame(height: 1, alignment: .center).padding([.leading, .trailing]).foregroundColor(.gray) + Text( + "de.cyface.rfr.text.VoucherEnabled.game_explanation", + comment: "Tell the where to send a voucher code and what happens after sending it there." + ) .padding() - Text("1x 15 Freiminuten auf die nächste Ausleihe in Schkeuditz - nextbike Nordsachsen") - .padding() - //Text("gültig bis: \(dateFormatter.string(from: voucher.until))") + Button(action: { + viewModel.onSendEMailButtonPressed() + }, label: { + Text("de.cyface.rfr.text.VoucherEnabled.send_mail", comment: "Button label for sending an E-Mail.") // E-Mail Senden + }) + .sheet(isPresented: $viewModel.showMailView, content: { + if let mailData = viewModel.mailData { + MailView(data: mailData) {_ in + viewModel.showMailView.toggle() + } + } + }) } } else { // TODO: Better make this an error alert @@ -53,13 +73,13 @@ struct VoucherEnabled: View { #if DEBUG var previewVoucherViewModel: VoucherViewModel { let ret = VoucherViewModel( - authenticator: MockAuthenticator(), - url: try! ConfigLoader.load().getIncentivesUrl(), - dataStoreStack: MockDataStoreStack() + vouchers: MockVouchers(count: 2, voucher: Voucher(code: "test-voucher")), + voucherRequirements: VoucherRequirements( + dataStoreStack: MockDataStoreStack() + ) ) ret.voucher = Voucher( - code: "abcdefg", - until: "2023-12-31T23:59:59Z" + code: "abcdefg" ) return ret diff --git a/RFR-App/RFR/Voucher/VoucherOverview.swift b/RFR-App/RFR/Voucher/VoucherOverview.swift deleted file mode 100644 index d5b2383c..00000000 --- a/RFR-App/RFR/Voucher/VoucherOverview.swift +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2023 Cyface GmbH - * - * This file is part of the Ready for Robots App. - * - * The Ready for Robots App is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * The Ready for Robots App is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with the Ready for Robots App. If not, see . - */ -import SwiftUI - -/** - A view displaying an overview of the state of reaching a NextBike Voucher. - - - Author: Klemens Muthmann - - Version: 1.0.0 - - Since: 3.1.2 - */ -struct VoucherOverview: View { - /// The underlying view model containing the current voucher progress and connection to the voucher server. - let viewModel: VoucherOverviewModel - /// An error if one occurred `nil` otherwise. - @State var error: Error? - - var body: some View { - if - let accumulatedKilometers = try? viewModel.accumulatedKilometersLabel(), - let kilometersToAcquire = try? viewModel.kilometersToAcquireLabel() { - VStack { - Divider() - HStack { - Image(systemName: "rosette") - Text("Noch \(accumulatedKilometers) von \(kilometersToAcquire) km bis zum Gutschein").padding() - } - Divider() - Text("\(viewModel.voucherCount) x 15 Minuten nextbike Gutscheine übrig").padding() - } - } else { - // TODO: Would be better to make an alert from this. - ErrorView(error: RFRError.voucherOverviewFailed) - } - } -} - -#if DEBUG -#Preview { - VoucherOverview( - viewModel: VoucherOverviewModel( - accumulatedKilometers: 15.0, - kilometersToAcquire: 15.0, - voucherCount: 25 - ) - ) -} -#endif diff --git a/RFR-App/RFR/Voucher/VoucherOverviewModel.swift b/RFR-App/RFR/Voucher/VoucherOverviewModel.swift deleted file mode 100644 index 23610efd..00000000 --- a/RFR-App/RFR/Voucher/VoucherOverviewModel.swift +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2023 Cyface GmbH - * - * This file is part of the Ready for Robots App. - * - * The Ready for Robots App is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * The Ready for Robots App is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with the Ready for Robots App. If not, see . - */ -import Foundation - -/** -The view model used by the view shown before a voucher has been acquired. - - - Author: Klemens Muthmann - - Version: 1.0.0 - - Since: 3.1.2 - */ -class VoucherOverviewModel { - // MARK: - Properties - /// The number of already accumulated kilometers towards acquiring a voucher - let accumulatedKilometers: Double - /// The number of kilometers to acquire before getting the opportunity to aqcuire a voucher. - let kilometersToAcquire: Double - /// The count of still available vouchers. - let voucherCount: Int - - // MARK: - Initializers - /// Create a new object with this class setting its complete initial state. - init(accumulatedKilometers: Double, kilometersToAcquire: Double, voucherCount: Int) { - self.accumulatedKilometers = accumulatedKilometers - self.kilometersToAcquire = kilometersToAcquire - self.voucherCount = voucherCount - } - - // MARK: - Methods - /// Transform the number of accumulated kilometers to a correctly localized text representation. - /// - Throws: If the number was not convertible, which should not happen under normal circumstances. - func accumulatedKilometersLabel() throws -> String { - guard let formattedAccumulatedKilometers = countFormatter.string(from: accumulatedKilometers as NSNumber) else { - throw RFRError.formattingFailed(number: accumulatedKilometers as NSNumber) - } - - return formattedAccumulatedKilometers - } - - /// Transform the number of kilometers to acquire into a correctly localized text representation. - /// - Throws If the number was not convertible, which should not happen under normal circumstances. - func kilometersToAcquireLabel() throws -> String { - guard let formattedKilometersToAcquire = countFormatter.string(from: kilometersToAcquire as NSNumber) else { - throw RFRError.formattingFailed(number: kilometersToAcquire as NSNumber) - } - - return formattedKilometersToAcquire - } -} diff --git a/RFR-App/RFR/Voucher/VoucherReached.swift b/RFR-App/RFR/Voucher/VoucherReached.swift index 9055d6bf..e8f726d8 100644 --- a/RFR-App/RFR/Voucher/VoucherReached.swift +++ b/RFR-App/RFR/Voucher/VoucherReached.swift @@ -25,7 +25,7 @@ import DataCapturing It allows the user to actually claim one of the vouchers, if available. - Author: Klemens Muthmann - - Version: 1.0.0 + - Version: 1.0.1 - Since: 3.1.2 */ struct VoucherReached: View { @@ -43,8 +43,11 @@ struct VoucherReached: View { Divider() HStack { Image(systemName: "checkmark.seal.fill") - Text("nextbike Gutschein freigeschaltet") - .padding() + Text( + "de.cyface.rfr.text.VoucherReached.voucher_active", + comment: "Tell the user that they achieved all goals to get the next voucher" + ) + .padding([.top, .bottom]) } Button(action: { Task { @@ -55,11 +58,11 @@ struct VoucherReached: View { } } }, label: { - Text("Gutschein anzeigen") + Text(String(localized: "de.cyface.rfr.text.VoucherReached.show_voucher").uppercased(with: .autoupdatingCurrent)) .frame(maxWidth: .infinity) .foregroundColor(Color("ButtonText")) - } + } ) } .buttonStyle(.borderedProminent) @@ -72,9 +75,11 @@ struct VoucherReached: View { #Preview { VoucherReached( viewModel: VoucherViewModel( - authenticator: MockAuthenticator(), - url: try! ConfigLoader.load().getIncentivesUrl(), - dataStoreStack: MockDataStoreStack() + vouchers: MockVouchers(count: 4, voucher: Voucher(code: "test-voucher")), + voucherRequirements: VoucherRequirements( + dataStoreStack: MockDataStoreStack(), + daysInSpecialRegionFullFilled: 3 + ) ) ) } diff --git a/RFR-App/RFR/Voucher/VoucherRequirements.swift b/RFR-App/RFR/Voucher/VoucherRequirements.swift new file mode 100644 index 00000000..cbf82326 --- /dev/null +++ b/RFR-App/RFR/Voucher/VoucherRequirements.swift @@ -0,0 +1,188 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Ready for Robots App. + * + * The Ready for Robots App is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Ready for Robots App is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Ready for Robots App. If not, see . + */ + +import CoreLocation +import SwiftUI +import DataCapturing + +/** + A wrapper for the requirements for getting a voucher. + + - Author: Klemens Muthmann + - Version: 1.0.0 + - Since: 3.2.2 + */ +struct VoucherRequirements { + // MARK: - Properties + /// The number of days to have at least one measurement in the special region + let daysInSpecialRegion = 3 + /// The coordinates of the special region. + let specialRegion = CLCircularRegion( + center: CLLocationCoordinate2D(latitude: 12.220760571276312, longitude: 51.395503403504705), + radius: CLLocationDistance(150), + identifier: "Schkeuditz Town Hall" + ) + /// Access to the apps data storage to store load progress from. + let dataStoreStack: DataStoreStack + /// The number of days the user already did drive through the special region. + var daysInSpecialRegionFullFilled = 0 + /// The number of valid measurements already uploaded. + var uploaded = 0 + + // MARK: - Methods + /// The view showing the progress towards the challenge goal. + @ViewBuilder + func progressView(voucherCount: Int) -> some View { + VStack { + HStack { + Image(systemName: "rosette") + VStack { + if daysInSpecialRegionFullFilled < daysInSpecialRegion { + Text( + String.localizedStringWithFormat( + NSLocalizedString( + "de.cyface.rfr.text.VoucherRequirements.condition_town_hall", + comment: "Tell the user how often they should pass town hall. The number is provided as the first argument" + ), + daysInSpecialRegion - daysInSpecialRegionFullFilled + ) + //"Bitte fahren Sie \() mal am Rathaus vorbei, um ein Gewinnlos zu erhalten!" + ) + } else { + Text( + String.localizedStringWithFormat( + NSLocalizedString( + "de.cyface.rfr.text.VoucherRequirements.condition_upload", + comment: """ +Tell the user how many of their measurements they are still required to upload. The number of uploads required is provided as the first parameter. The second parameter are the uploads required alltogether. +""" + ), + daysInSpecialRegion - uploaded, + daysInSpecialRegionFullFilled + ) + //"Laden Sie bitte noch \(daysInSpecialRegion - uploaded) von \(daysInSpecialRegionFullFilled) Messungen hoch, um ein Gewinnlos zu erhalten!" + ) + } + } + } + } + } + + /// This is `true` if the user is qualified to recieve a new voucher. + func isQualifiedForVoucher() -> Bool { + return daysInSpecialRegionFullFilled >= daysInSpecialRegion + } + + /// Refresh the progress from the measurements currently stored on the device. + mutating func refreshProgress() async throws { + try await withCheckedThrowingContinuation { continuation in + do { + try dataStoreStack.wrapInContext { context in + let fetchedMeasurements = try MeasurementMO.fetchRequest().execute() + + let fullFilledDates = fetchedMeasurements.filter { + // At least one track of this measurement fullfills the embedded condition + $0.typedTracks() + .filter { + // 10 or more locations are around the Schkeuditz Town Hall. + $0.typedLocations() + .filter { + specialRegion.contains(CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon)) + }.count >= 10 + }.count > 0 + } + // Map measurements to capturing time and count days without duplicates + .compactMap { if let time = $0.time { return (time, $0.synchronized) } else { return nil } } + .map { (timeTuple: (Date, Bool)) in + let day = Calendar.current.component(.day, from: timeTuple.0) + let month = Calendar.current.component(.month, from: timeTuple.0) + let year = Calendar.current.component(.year, from: timeTuple.0) + return DateWithOutTime(day: day, month: month, year: year, synchronized: timeTuple.1) + } + + let synchronizedDates = Set(fullFilledDates.filter { $0.synchronized }) + let unsychronizedDates = Set(fullFilledDates.filter { !$0.synchronized }) + daysInSpecialRegionFullFilled = synchronizedDates.union(unsychronizedDates).count + uploaded = synchronizedDates.count + } + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + + // MARK: - Private Methods + /// Calculate the covered distance for one single track. + private func toDistance(track: TrackMO) -> Double { + return toDistance( + locations: track.typedLocations().sorted { + $0.time! < $1.time! + } + ) + } + + /// Calculate the distance between an array of geo locations ordered by time. + private func toDistance(locations: [GeoLocationMO]) -> Double { + var previousLocation: GeoLocationMO? = nil + var accumulatedDistance = 0.0 + locations.forEach { location in + if let previousLocation = previousLocation { + accumulatedDistance += previousLocation.distance(to: location) + } + previousLocation = location + } + return accumulatedDistance + } + + /// Calculate the distance covered by a measurement. + private func toDistance(measurement: MeasurementMO) -> Double { + return measurement.typedTracks().map { track in + toDistance(track: track) + }.reduce(0.0) { $0 + $1 } + } + + /** + Since the natie date structure always requires a time component (and that time component will change the date based on the users time zone), this struct provides us the possibility to only store a date. + + - Author: Klemens Muthmann + - Version: 1.0.0 + - Since: 3.2.2 + */ + private struct DateWithOutTime: Equatable, Hashable { + /// The day in the month. + let day: Int + /// The month in the year. + let month: Int + /// The year AD. + let year: Int + /// Whether the measurement with that date was synchronized. + let synchronized: Bool + + static func == (lhs: DateWithOutTime, rhs: DateWithOutTime) -> Bool { + return lhs.day == rhs.day && lhs.month == rhs.month && lhs.year == rhs.year + } + + func hash(into hasher: inout Hasher) { + hasher.combine(day) + hasher.combine(month) + hasher.combine(year) + } + } +} diff --git a/RFR-App/RFR/Voucher/VoucherViewModel.swift b/RFR-App/RFR/Voucher/VoucherViewModel.swift index 06901835..2e72c4a8 100644 --- a/RFR-App/RFR/Voucher/VoucherViewModel.swift +++ b/RFR-App/RFR/Voucher/VoucherViewModel.swift @@ -1,5 +1,5 @@ /* - * Copyright 2023 Cyface GmbH + * Copyright 2023-2024 Cyface GmbH * * This file is part of the Ready for Robots App. * @@ -19,42 +19,51 @@ import Foundation import DataCapturing import SwiftUI +import MessageUI /** View model used for the view showing the voucher. - Author: Klemens Muthmann - - Version: 1.0.0 + - Version: 2.0.0 - Since: 3.1.2 */ class VoucherViewModel: ObservableObject { // MARK: - Private Properties /// A retrieved voucher is stored to user defaults under this key. private static let userDefaultsKey = "de.cyface.rfr.voucher" - /// This is the amount of kilometers the participant must have driven, to acquire a voucher. - static let requiredKilometers = 15.0 // MARK: - Properties - /// The currently accumulated kilometers by the participant. - @Published var accumulatedKilometers = 0.0 /// The acquired voucher or `nil` if no voucher has been acquired yet. @Published var voucher: Voucher? /// The number of available fouchers, shown as long as the current user did not acquire a voucher already. @Published var voucherCount: Int = 0 - /// The authenticator to authenticate with the Ready for Robots identity provider. - private let authenticator: Authenticator - /// The internet address of the root of the voucher API used by this application. - private let url: URL - /// Stack to a data store to retrieve measurement information for calculating the covered distance by this user. - private let dataStoreStack: DataStoreStack + /// `true` if the view to send the acquired voucher via E-Mail should display; `false` otherwise. + @Published var showMailView: Bool = false + /// The information making up an E-Mail. + @Published var mailData: ComposeMailData? + /// A handle to the `Vouchers` API, for retrieving voucher information from the server. + private let vouchers: Vouchers + /// An algorithm to calculate whether a user is eligleble for a voucher or not. + private var voucherRequirements: VoucherRequirements + /// `true` if a voucher is redeemable at the moment; `false` if the competition period is over. + private var voucherRedeemable: Bool { + var redeemDate = DateComponents() + redeemDate.year = 2024 + redeemDate.month = 6 + redeemDate.day = 1 + redeemDate.timeZone = TimeZone(abbreviation: "CEST") + redeemDate.hour = 23 + redeemDate.minute = 59 + redeemDate.second = 59 - // MARK: - Initializers - /// Create a new object of this class, communicating with the voucher API at the provided `url`, authenticating with the provided `authenticator`. - /// - Parameter dataStoreStack: Used to retrieve the covered distance by this user. - init(authenticator: Authenticator, url: URL, dataStoreStack: DataStoreStack) { + return Date.now <= Calendar.current.date(from: redeemDate)! + } - self.authenticator = authenticator - self.url = url - self.dataStoreStack = dataStoreStack + // MARK: - Initializers + /// Create a new object of this class, communicating with the voucher API via the provided `Vouchers` instance`. + init(vouchers: Vouchers, voucherRequirements: VoucherRequirements) { + self.vouchers = vouchers + self.voucherRequirements = voucherRequirements let decoder = JSONDecoder() if let voucherData = UserDefaults.standard.data(forKey: VoucherViewModel.userDefaultsKey) { DispatchQueue.main.async { [weak self] in @@ -73,7 +82,6 @@ class VoucherViewModel: ObservableObject { /// - Throws: If communication with the server fails or no vouchers are available anymore. /// Please have a look a the voucher API documentation and ``VoucherRequestError`` to get information about the meaning of the different HTTP Status codes returned. func onPressLoadVoucherButton() async throws { - let vouchers = Vouchers(authenticator: authenticator, url: url) let voucher = try await vouchers.requestVoucher() let encoder = JSONEncoder() @@ -85,6 +93,52 @@ class VoucherViewModel: ObservableObject { } } + // The following send E-Mail functionality is based on code from the following StackOverflowThread: https://stackoverflow.com/questions/25981422/how-to-open-mail-app-from-swift + /// This function is called if the user presses the send E-Mail button. + func onSendEMailButtonPressed() { + guard let voucher = voucher else { + return + } + + // Modify following variables with your text / recipient + let recipientEmail = "gewinnspiel@ready-for-robots.de" + let subject = String(format: NSLocalizedString("de.cyface.rfr.label.VoucherViewModel.mail_subject", comment: "The subject of the participation E-Mail when sending a voucher."), voucher.code) //"Gewinnlos: \(voucher.code)" + let body = "" + + if MFMailComposeViewController.canSendMail() { + mailData = ComposeMailData(subject: subject, recipients: [recipientEmail], message: body, attachments: []) + self.showMailView.toggle() + } else if let emailUrl = createEmailUrl(to: recipientEmail, subject: subject, body: body) { + UIApplication.shared.open(emailUrl) + } + } + + /// Create a URL to send the E-Mail if the native mail application is not available. + /// + /// This tries to start GMail, Outlook, YahooMail, Spark or the default program registered for the mailto scheme. + private func createEmailUrl(to: String, subject: String, body: String) -> URL? { + let subjectEncoded = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + let bodyEncoded = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + + let gmailUrl = URL(string: "googlegmail://co?to=\(to)&subject=\(subjectEncoded)&body=\(bodyEncoded)") + let outlookUrl = URL(string: "ms-outlook://compose?to=\(to)&subject=\(subjectEncoded)&body=\(bodyEncoded)") + let yahooMail = URL(string: "ymail://mail/compose?to=\(to)&subject=\(subjectEncoded)&body=\(bodyEncoded)") + let sparkUrl = URL(string: "readdle-spark://compose?recipient=\(to)&subject=\(subjectEncoded)&body=\(bodyEncoded)") + let defaultUrl = URL(string: "mailto:\(to)?subject=\(subjectEncoded)&body=\(bodyEncoded)") + + if let gmailUrl = gmailUrl, UIApplication.shared.canOpenURL(gmailUrl) { + return gmailUrl + } else if let outlookUrl = outlookUrl, UIApplication.shared.canOpenURL(outlookUrl) { + return outlookUrl + } else if let yahooMail = yahooMail, UIApplication.shared.canOpenURL(yahooMail) { + return yahooMail + } else if let sparkUrl = sparkUrl, UIApplication.shared.canOpenURL(sparkUrl) { + return sparkUrl + } + + return defaultUrl + } + /// Refresh the state from user defaults and the local database. /// /// - Throws: If the local storage was not available. @@ -92,24 +146,15 @@ class VoucherViewModel: ObservableObject { /// It is usually not possible to recover from such an error. @MainActor func refreshModel() async throws { - if accumulatedKilometers >= VoucherViewModel.requiredKilometers { + if voucherRequirements.isQualifiedForVoucher() { if let data = UserDefaults.standard.data(forKey: VoucherViewModel.userDefaultsKey) { let decoder = JSONDecoder() voucher = try? decoder.decode(Voucher.self, from: data) } } else { - try dataStoreStack.wrapInContext { context in - let request = MeasurementMO.fetchRequest() - try request.execute().forEach { measurement in - let distanceInMeters = Statistics.coveredDistance(tracks: measurement.typedTracks()) - let distanceInKilometers = distanceInMeters / 1_000 - accumulatedKilometers += distanceInKilometers - } - } + try await voucherRequirements.refreshProgress() } - let vouchers = Vouchers(authenticator: authenticator, url: url) - Task { self.voucherCount = (try? await vouchers.count) ?? 0 } @@ -120,21 +165,16 @@ class VoucherViewModel: ObservableObject { /// Thereafter show a button to acquire a voucher and finally show the voucher itself if one was still available. @ViewBuilder func view() -> some View { - if voucherCount > 0 && accumulatedKilometers < VoucherViewModel.requiredKilometers { - Spacer() - VoucherOverview( - viewModel: VoucherOverviewModel( - accumulatedKilometers: accumulatedKilometers, - kilometersToAcquire: VoucherViewModel.requiredKilometers, - voucherCount: voucherCount - ) - ) - } else if voucherCount > 0 && accumulatedKilometers >= VoucherViewModel.requiredKilometers && voucher == nil { - Spacer() - VoucherReached(viewModel: self) - } else if voucher != nil { - Spacer() - VoucherEnabled(viewModel: self) + if !thereIsCurrentEvent() { + NoVoucher(voucherRedeemable: voucherRedeemable) + } else if voucherCount > 0 && !voucherRequirements.isQualifiedForVoucher() { + voucherRequirements.progressView(voucherCount: voucherCount).padding([.top, .bottom]) + } else if voucherCount > 0 && voucherRequirements.isQualifiedForVoucher() && voucher == nil { + VoucherReached(viewModel: self).padding([.top, .bottom]) + } else if voucherCount == 0 && voucher == nil { + NoVoucher(voucherRedeemable: voucherRedeemable).padding([.top, .bottom]) + } else if voucher != nil && voucherRedeemable { + VoucherEnabled(viewModel: self).padding([.top, .bottom]) } else { EmptyView() }