diff --git a/firka/.gitignore b/firka/.gitignore index 2ce9e99..3a0f570 100644 --- a/firka/.gitignore +++ b/firka/.gitignore @@ -12,6 +12,11 @@ .swiftpm/ migrate_working_dir/ +# Environment variables +.env +.env.local +.env.*.local + # IntelliJ related *.iml *.ipr diff --git a/firka/ios/Runner.xcodeproj/project.pbxproj b/firka/ios/Runner.xcodeproj/project.pbxproj index 6a140f3..dc1531f 100644 --- a/firka/ios/Runner.xcodeproj/project.pbxproj +++ b/firka/ios/Runner.xcodeproj/project.pbxproj @@ -8,14 +8,19 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2D1A72FA250BC71FB05757CE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E57B0CD5BC5E83062121FE65 /* Pods_Runner.framework */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 37C86267DDB4FB6D51FC4BED /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99C2C99118BD4E62FB6B81AA /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */; }; + 4F30C7672E8FBF9D008BB46C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */; }; + 4F30C7692E8FBF9D008BB46C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */; }; + 4F30C7782E8FBF9F008BB46C /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4F30C7652E8FBF9D008BB46C /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 4F30C79A2E8FC427008BB46C /* TimetableActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F30C7992E8FC427008BB46C /* TimetableActivityAttributes.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7762B298B1A9C855D1874A96 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6436F72EE81AA1892BF629F8 /* Pods_RunnerTests.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 9D0EDC5035EF085D49750129 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9F6D796A0260E2096537057 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -26,9 +31,27 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + 4F30C7762E8FBF9F008BB46C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4F30C7642E8FBF9D008BB46C; + remoteInfo = TimetableWidgetExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 4F30C77E2E8FBF9F008BB46C /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 4F30C7782E8FBF9F008BB46C /* TimetableWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -42,17 +65,26 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0EE927DD3F0F54BDE10EFE01 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2224F577A1AE7BBF50F1FA78 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 5E8A2CFCDBA25609A95B71B6 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 4F25FCBE2EB17D810060DAAA /* TimetableWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TimetableWidgetExtension.entitlements; sourceTree = ""; }; + 4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityMethodChannelManager.swift; sourceTree = ""; }; + 4F30C7652E8FBF9D008BB46C /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 4F30C7992E8FC427008BB46C /* TimetableActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimetableActivityAttributes.swift; sourceTree = ""; }; + 6436F72EE81AA1892BF629F8 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7673DE33F16FE6D0BCB75811 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 76B2553ECF760C8F6A043E50 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 7EB4BEB346100242307F71B4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 8942A0281805655C04A33144 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -60,19 +92,51 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 99C2C99118BD4E62FB6B81AA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - DC40DFA44F4D7F8C7BB27167 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - DC620A0EF087A81896E37FBE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - E9F6D796A0260E2096537057 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F3B61A640990850D355DCCB0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + B222D922BB8257D2341337A4 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + BDDC8A00836B054E202CC327 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + E57B0CD5BC5E83062121FE65 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "TimetableWidget" folder in "TimetableWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4F30C7642E8FBF9D008BB46C /* TimetableWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 4F30C76A2E8FBF9D008BB46C /* TimetableWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4F6C1D3E2ECD3FBD00F819D7 /* Exceptions for "TimetableWidget" folder in "TimetableWidgetExtension" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = TimetableWidget; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ + 4F30C7622E8FBF9D008BB46C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4F30C7692E8FBF9D008BB46C /* SwiftUI.framework in Frameworks */, + 4F30C7672E8FBF9D008BB46C /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 37C86267DDB4FB6D51FC4BED /* Pods_Runner.framework in Frameworks */, + 2D1A72FA250BC71FB05757CE /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -80,7 +144,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9D0EDC5035EF085D49750129 /* Pods_RunnerTests.framework in Frameworks */, + 7762B298B1A9C855D1874A96 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -98,8 +162,10 @@ 427F5F07FF5CBAA039F178F5 /* Frameworks */ = { isa = PBXGroup; children = ( - 99C2C99118BD4E62FB6B81AA /* Pods_Runner.framework */, - E9F6D796A0260E2096537057 /* Pods_RunnerTests.framework */, + 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */, + 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */, + E57B0CD5BC5E83062121FE65 /* Pods_Runner.framework */, + 6436F72EE81AA1892BF629F8 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -107,12 +173,12 @@ 52B477EA0F4B63DC7CE4BA83 /* Pods */ = { isa = PBXGroup; children = ( - DC620A0EF087A81896E37FBE /* Pods-Runner.debug.xcconfig */, - DC40DFA44F4D7F8C7BB27167 /* Pods-Runner.release.xcconfig */, - F3B61A640990850D355DCCB0 /* Pods-Runner.profile.xcconfig */, - 8942A0281805655C04A33144 /* Pods-RunnerTests.debug.xcconfig */, - 5E8A2CFCDBA25609A95B71B6 /* Pods-RunnerTests.release.xcconfig */, - 7EB4BEB346100242307F71B4 /* Pods-RunnerTests.profile.xcconfig */, + 76B2553ECF760C8F6A043E50 /* Pods-Runner.debug.xcconfig */, + 7673DE33F16FE6D0BCB75811 /* Pods-Runner.release.xcconfig */, + 2224F577A1AE7BBF50F1FA78 /* Pods-Runner.profile.xcconfig */, + 0EE927DD3F0F54BDE10EFE01 /* Pods-RunnerTests.debug.xcconfig */, + B222D922BB8257D2341337A4 /* Pods-RunnerTests.release.xcconfig */, + BDDC8A00836B054E202CC327 /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -131,8 +197,10 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + 4F25FCBE2EB17D810060DAAA /* TimetableWidgetExtension.entitlements */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + 4F30C76A2E8FBF9D008BB46C /* TimetableWidget */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 52B477EA0F4B63DC7CE4BA83 /* Pods */, @@ -145,6 +213,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 4F30C7652E8FBF9D008BB46C /* TimetableWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -152,6 +221,8 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */, + 4F30C7992E8FC427008BB46C /* TimetableActivityAttributes.swift */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -160,6 +231,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */, ); path = Runner; sourceTree = ""; @@ -171,7 +243,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - D5FE4F07AA63463070556C13 /* [CP] Check Pods Manifest.lock */, + A860FB1CB44F70AAB3A8ECC8 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, C45E0924473D697285AAFD0B /* Frameworks */, @@ -186,24 +258,46 @@ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 97C146ED1CF9000F007C117D /* Runner */ = { + 4F30C7642E8FBF9D008BB46C /* TimetableWidgetExtension */ = { isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildConfigurationList = 4F30C77A2E8FBF9F008BB46C /* Build configuration list for PBXNativeTarget "TimetableWidgetExtension" */; buildPhases = ( - 3ECBF24C04014EEB9AB0183F /* [CP] Check Pods Manifest.lock */, - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 71E0389386174DB9C215914D /* [CP] Embed Pods Frameworks */, - E5F773809B6D19FE893E52BC /* [CP] Copy Pods Resources */, + 4F30C7612E8FBF9D008BB46C /* Sources */, + 4F30C7622E8FBF9D008BB46C /* Frameworks */, + 4F30C7632E8FBF9D008BB46C /* Resources */, ); buildRules = ( ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 4F30C76A2E8FBF9D008BB46C /* TimetableWidget */, + ); + name = TimetableWidgetExtension; + productName = TimetableWidgetExtension; + productReference = 4F30C7652E8FBF9D008BB46C /* TimetableWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 5D9E2A8A05449E9C8B9A2400 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 4F30C77E2E8FBF9F008BB46C /* Embed Foundation Extensions */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + EA27BF75630C9CBFEB0A3BF3 /* [CP] Embed Pods Frameworks */, + FCB81B57CFF4555354FC425C /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + 4F30C7772E8FBF9F008BB46C /* PBXTargetDependency */, + ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; @@ -216,6 +310,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -223,6 +318,9 @@ CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; }; + 4F30C7642E8FBF9D008BB46C = { + CreatedOnToolsVersion = 26.0; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; @@ -244,6 +342,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + 4F30C7642E8FBF9D008BB46C /* TimetableWidgetExtension */, ); }; /* End PBXProject section */ @@ -256,6 +355,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4F30C7632E8FBF9D008BB46C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -286,7 +392,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 3ECBF24C04014EEB9AB0183F /* [CP] Check Pods Manifest.lock */ = { + 5D9E2A8A05449E9C8B9A2400 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -308,23 +414,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 71E0389386174DB9C215914D /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -340,7 +429,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - D5FE4F07AA63463070556C13 /* [CP] Check Pods Manifest.lock */ = { + A860FB1CB44F70AAB3A8ECC8 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -362,7 +451,24 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - E5F773809B6D19FE893E52BC /* [CP] Copy Pods Resources */ = { + EA27BF75630C9CBFEB0A3BF3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + FCB81B57CFF4555354FC425C /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -390,12 +496,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4F30C7612E8FBF9D008BB46C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 4F30C79A2E8FC427008BB46C /* TimetableActivityAttributes.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -407,6 +522,11 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + 4F30C7772E8FBF9F008BB46C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4F30C7642E8FBF9D008BB46C /* TimetableWidgetExtension */; + targetProxy = 4F30C7762E8FBF9F008BB46C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -472,6 +592,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; @@ -487,8 +608,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1021; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Firka; @@ -497,8 +621,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -512,7 +637,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8942A0281805655C04A33144 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 0EE927DD3F0F54BDE10EFE01 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -530,7 +655,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5E8A2CFCDBA25609A95B71B6 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = B222D922BB8257D2341337A4 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -546,7 +671,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7EB4BEB346100242307F71B4 /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = BDDC8A00836B054E202CC327 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -560,6 +685,147 @@ }; name = Profile; }; + 4F30C77B2E8FBF9F008BB46C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = TimetableWidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimetableWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TimetableWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.TimetableWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4F30C77C2E8FBF9F008BB46C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = TimetableWidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimetableWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TimetableWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.TimetableWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 4F30C77D2E8FBF9F008BB46C /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = TimetableWidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UT7MSP4GWZ; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimetableWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TimetableWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.TimetableWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -609,6 +875,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; @@ -660,6 +927,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; @@ -677,8 +945,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1021; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Firka; @@ -687,8 +958,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -707,8 +979,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1021; - DEVELOPMENT_TEAM = R9PZGUCNJ3; + DEVELOPMENT_TEAM = UT7MSP4GWZ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Firka; @@ -717,8 +992,9 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -743,6 +1019,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4F30C77A2E8FBF9F008BB46C /* Build configuration list for PBXNativeTarget "TimetableWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4F30C77B2E8FBF9F008BB46C /* Debug */, + 4F30C77C2E8FBF9F008BB46C /* Release */, + 4F30C77D2E8FBF9F008BB46C /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/firka/ios/Runner/AppDelegate.swift b/firka/ios/Runner/AppDelegate.swift index 6266644..40122f8 100644 --- a/firka/ios/Runner/AppDelegate.swift +++ b/firka/ios/Runner/AppDelegate.swift @@ -1,13 +1,124 @@ import Flutter import UIKit +import ActivityKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate { + private var liveActivityManager: Any? + private var fallbackChannel: FlutterMethodChannel? + private var deviceTokenString: String? + private var notificationChannel: FlutterMethodChannel? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + + let controller = window?.rootViewController as! FlutterViewController + + notificationChannel = FlutterMethodChannel(name: "firka.app/notifications", binaryMessenger: controller.binaryMessenger) + + UNUserNotificationCenter.current().delegate = self + + if #available(iOS 16.2, *) { + liveActivityManager = LiveActivityMethodChannelManager(controller: controller) + } else { + let channel = FlutterMethodChannel(name: "firka.app/live_activity", binaryMessenger: controller.binaryMessenger) + self.fallbackChannel = channel + channel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in + switch call.method { + case "registerForPushNotifications": + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if granted { + DispatchQueue.main.async { + application.registerForRemoteNotifications() + } + result(self?.deviceTokenString) + } else { + result(FlutterError(code: "PERMISSION_DENIED", message: "Push notification permission denied", details: error?.localizedDescription)) + } + } + case "getDeviceToken": + if let token = self?.deviceTokenString { + result(token) + } else { + result(FlutterError(code: "NO_TOKEN", message: "Device token not available", details: nil)) + } + default: + result(FlutterMethodNotImplemented) + } + } + } + + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if granted { + DispatchQueue.main.async { + application.registerForRemoteNotifications() + } + } + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + self.deviceTokenString = tokenString + if #available(iOS 16.2, *) { + (liveActivityManager as? LiveActivityMethodChannelManager)?.setDeviceToken(tokenString) + } + } + + override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("Failed to register for remote notifications: \(error)") + } + + + override func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + let userInfo = response.notification.request.content.userInfo + + UIApplication.shared.applicationIconBadgeNumber = 0 + + var action = userInfo["action"] as? String + if action == nil, let aps = userInfo["aps"] as? [String: Any] { + action = aps["action"] as? String + } + + if let action = action { + notificationChannel?.invokeMethod("onNotificationTapped", arguments: [ + "action": action, + "data": userInfo + ]) + } else if let route = userInfo["route"] as? String { + notificationChannel?.invokeMethod("onNotificationTapped", arguments: [ + "route": route, + "action": userInfo["action"] as? String ?? "", + "data": userInfo + ]) + } + + completionHandler() + } + + override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + if #available(iOS 14.0, *) { + completionHandler([.banner, .sound, .badge]) + } else { + completionHandler([.alert, .sound, .badge]) + } + } + + override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + if let aps = userInfo["aps"] as? [AnyHashable: Any] { + if let contentState = aps["content-state"] { + if let contentStateDict = contentState as? [AnyHashable: Any] { + // iOS automatically handles the Live Activity update + } + } + } + + completionHandler(.newData) + } } diff --git a/firka/ios/Runner/Info.plist b/firka/ios/Runner/Info.plist index d9fb2ec..2dfa6de 100644 --- a/firka/ios/Runner/Info.plist +++ b/firka/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,22 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + processing + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +59,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/firka/ios/Runner/LiveActivityMethodChannelManager.swift b/firka/ios/Runner/LiveActivityMethodChannelManager.swift new file mode 100644 index 0000000..2ee06a9 --- /dev/null +++ b/firka/ios/Runner/LiveActivityMethodChannelManager.swift @@ -0,0 +1,249 @@ +import Flutter +import ActivityKit +import Foundation + +@available(iOS 16.2, *) +class LiveActivityMethodChannelManager: NSObject { + private let channel: FlutterMethodChannel + private var deviceToken: String? + + init(controller: FlutterViewController) { + self.channel = FlutterMethodChannel( + name: "firka.app/live_activity", + binaryMessenger: controller.binaryMessenger + ) + + super.init() + + channel.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in + self?.handleMethodCall(call, result: result) + } + } + + func setDeviceToken(_ token: String) { + self.deviceToken = token + } + + private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialize": + initialize(result: result) + + case "getDeviceToken": + getDeviceToken(result: result) + + case "registerForPushNotifications": + registerForPushNotifications(result: result) + + case "startActivity": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil)) + return + } + startActivity(args: args, result: result) + + case "updateActivity": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil)) + return + } + updateActivity(args: args, result: result) + + case "endActivity": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil)) + return + } + endActivity(args: args, result: result) + + case "getActiveActivities": + getActiveActivities(result: result) + + case "endAllActivities": + endAllActivities(result: result) + + default: + result(FlutterMethodNotImplemented) + } + } + + private func initialize(result: @escaping FlutterResult) { + if #available(iOS 16.2, *) { + if ActivityAuthorizationInfo().areActivitiesEnabled { + result(true) + } else { + result(FlutterError( + code: "NOT_SUPPORTED", + message: "Live Activities are not enabled on this device", + details: nil + )) + } + } else { + result(FlutterError( + code: "NOT_SUPPORTED", + message: "Live Activities require iOS 16.2 or later", + details: nil + )) + } + } + + private func getDeviceToken(result: @escaping FlutterResult) { + if let token = deviceToken { + result(token) + } else { + result(FlutterError( + code: "NO_TOKEN", + message: "Device token not available", + details: nil + )) + } + } + + private func registerForPushNotifications(result: @escaping FlutterResult) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + if let token = self?.deviceToken { + result(token) + } else { + result(FlutterError( + code: "NO_TOKEN", + message: "Failed to get device token", + details: nil + )) + } + } + } else { + result(FlutterError( + code: "PERMISSION_DENIED", + message: "Push notification permission denied", + details: error?.localizedDescription + )) + } + } + } + + private func startActivity(args: [String: Any], result: @escaping FlutterResult) { + guard let attributesJson = args["attributes"] as? String, + let contentStateJson = args["contentState"] as? String, + let attributesData = attributesJson.data(using: .utf8), + let contentStateData = contentStateJson.data(using: .utf8) else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments for startActivity", details: nil)) + return + } + + Task { + do { + let attributes = try JSONDecoder().decode([String: String].self, from: attributesData) + let contentState = try JSONDecoder().decode(TimetableActivityAttributes.ContentState.self, from: contentStateData) + + if let existingActivity = Activity.activities.first { + await existingActivity.update(ActivityContent(state: contentState, staleDate: nil)) + result(existingActivity.id) + return + } + + guard let studentName = attributes["studentName"], + let schoolName = attributes["schoolName"] else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing student or school name", details: nil)) + return + } + + let activityAttributes = TimetableActivityAttributes( + studentName: studentName, + schoolName: schoolName + ) + + let newActivity = try Activity.request( + attributes: activityAttributes, + content: .init(state: contentState, staleDate: nil), + pushType: .token + ) + + let activityId = newActivity.id + + Task { + for await pushToken in newActivity.pushTokenUpdates { + let token = pushToken.map { String(format: "%02x", $0) }.joined() + DispatchQueue.main.async { [weak self] in + self?.channel.invokeMethod("onPushTokenReceived", arguments: [ + "activityId": activityId, + "pushToken": token + ]) + } + } + } + result(activityId) + } catch { + result(FlutterError( + code: "START_FAILED", + message: "Failed to decode or start Live Activity: \(error.localizedDescription)", + details: nil + )) + } + } + } + + private func updateActivity(args: [String: Any], result: @escaping FlutterResult) { + guard let activityId = args["activityId"] as? String, + let contentStateJson = args["contentState"] as? String, + let contentStateData = contentStateJson.data(using: .utf8) else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments for updateActivity", details: nil)) + return + } + + Task { + guard let activity = Activity.activities.first(where: { $0.id == activityId }) else { + result(FlutterError(code: "NOT_FOUND", message: "Activity with specified ID not found for update.", details: nil)) + return + } + + do { + let contentState = try JSONDecoder().decode(TimetableActivityAttributes.ContentState.self, from: contentStateData) + await activity.update(ActivityContent(state: contentState, staleDate: nil)) + result(true) + } catch { + result(FlutterError(code: "UPDATE_FAILED", message: "Failed to decode or update Live Activity", details: error.localizedDescription)) + } + } + } + + private func endActivity(args: [String: Any], result: @escaping FlutterResult) { + guard let activityId = args["activityId"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments for endActivity", details: nil)) + return + } + + Task { + guard let activity = Activity.activities.first(where: { $0.id == activityId }) else { + result(true) + return + } + await activity.end(nil, dismissalPolicy: .immediate) + result(true) + } + } + + private func getActiveActivities(result: @escaping FlutterResult) { + if #available(iOS 16.2, *) { + let activityIds = Activity.activities.map { $0.id } + result(activityIds) + } else { + result([]) + } + } + + private func endAllActivities(result: @escaping FlutterResult) { + Task { + let activities = Activity.activities + for activity in activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + result(true) + } + } +} \ No newline at end of file diff --git a/firka/ios/Runner/Runner.entitlements b/firka/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..f096a92 --- /dev/null +++ b/firka/ios/Runner/Runner.entitlements @@ -0,0 +1,14 @@ + + + + + aps-environment + development + com.apple.developer.usernotifications.time-sensitive + + com.apple.security.application-groups + + group.app.firka.firkaa + + + diff --git a/firka/ios/Runner/TimetableActivityAttributes.swift b/firka/ios/Runner/TimetableActivityAttributes.swift new file mode 100644 index 0000000..6536505 --- /dev/null +++ b/firka/ios/Runner/TimetableActivityAttributes.swift @@ -0,0 +1,635 @@ +import ActivityKit +import Foundation + +struct TimetableActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var isBreak: Bool + var lessonName: String + var lessonTheme: String? + var roomName: String? + var teacherName: String? + var startTime: Date + var endTime: Date + var lessonNumber: Int? + + var mode: String? // "lesson" | "break" | "seasonalBreak" | "xmas" | "newYear" + var message: String? + var season: String? + + var nextLessonName: String? + var nextRoomName: String? + var nextStartTime: Date? + + var isSubstitution: Bool? + + var isCancelled: Bool? + + var substituteTeacher: String? + + var currentTime: Date + + enum CodingKeys: String, CodingKey { + + case isBreak + + case lessonName + + case lessonTheme + + case roomName + + case teacherName + + case startTime + + case endTime + + case lessonNumber + + case mode + + case message + + case season + + case nextLessonName + + case nextRoomName + + case nextStartTime + + case isSubstitution + + case isCancelled + + case substituteTeacher + + case currentTime + + } + + init(isBreak: Bool, lessonName: String, lessonTheme: String?, roomName: String?, teacherName: String?, startTime: Date, endTime: Date, lessonNumber: Int?, mode: String?, message: String?, season: String?, nextLessonName: String?, nextRoomName: String?, nextStartTime: Date?, isSubstitution: Bool?, isCancelled: Bool?, substituteTeacher: String?, currentTime: Date) { + + self.isBreak = isBreak + + self.lessonName = lessonName + + self.lessonTheme = lessonTheme + + self.roomName = roomName + + self.teacherName = teacherName + + self.startTime = startTime + + self.endTime = endTime + + self.lessonNumber = lessonNumber + + self.mode = mode + + self.message = message + + self.season = season + + self.nextLessonName = nextLessonName + + self.nextRoomName = nextRoomName + + self.nextStartTime = nextStartTime + + self.isSubstitution = isSubstitution + + self.isCancelled = isCancelled + + self.substituteTeacher = substituteTeacher + + self.currentTime = currentTime + + } + + + + init(from decoder: Decoder) throws { + + let container = try decoder.container(keyedBy: CodingKeys.self) + + + + let isoFormatter = ISO8601DateFormatter() + + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + + + isBreak = try container.decode(Bool.self, forKey: .isBreak) + + lessonName = try container.decode(String.self, forKey: .lessonName) + + lessonTheme = try container.decodeIfPresent(String.self, forKey: .lessonTheme) + + roomName = try container.decodeIfPresent(String.self, forKey: .roomName) + + teacherName = try container.decodeIfPresent(String.self, forKey: .teacherName) + + + + let startTimeStr = try container.decode(String.self, forKey: .startTime) + + guard let startTimeDate = isoFormatter.date(from: startTimeStr) else { + + throw DecodingError.dataCorruptedError(forKey: .startTime, in: container, debugDescription: "Invalid startTime format: \(startTimeStr)") + + } + + startTime = startTimeDate + + + + let endTimeStr = try container.decode(String.self, forKey: .endTime) + + guard let endTimeDate = isoFormatter.date(from: endTimeStr) else { + + throw DecodingError.dataCorruptedError(forKey: .endTime, in: container, debugDescription: "Invalid endTime format: \(endTimeStr)") + + } + + endTime = endTimeDate + + + + lessonNumber = try container.decodeIfPresent(Int.self, forKey: .lessonNumber) + + mode = try container.decodeIfPresent(String.self, forKey: .mode) + + message = try container.decodeIfPresent(String.self, forKey: .message) + + season = try container.decodeIfPresent(String.self, forKey: .season) + + nextLessonName = try container.decodeIfPresent(String.self, forKey: .nextLessonName) + + nextRoomName = try container.decodeIfPresent(String.self, forKey: .nextRoomName) + + + + if let nextStartTimeStr = try container.decodeIfPresent(String.self, forKey: .nextStartTime) { + + nextStartTime = isoFormatter.date(from: nextStartTimeStr) + + } else { + + nextStartTime = nil + + } + + + + isSubstitution = try container.decodeIfPresent(Bool.self, forKey: .isSubstitution) + + isCancelled = try container.decodeIfPresent(Bool.self, forKey: .isCancelled) + + substituteTeacher = try container.decodeIfPresent(String.self, forKey: .substituteTeacher) + + + + let currentTimeStr = try container.decode(String.self, forKey: .currentTime) + + guard let currentTimeDate = isoFormatter.date(from: currentTimeStr) else { + + throw DecodingError.dataCorruptedError(forKey: .currentTime, in: container, debugDescription: "Invalid currentTime format: \(currentTimeStr)") + + } + + currentTime = currentTimeDate + + } + + + + func encode(to encoder: Encoder) throws { + + var container = encoder.container(keyedBy: CodingKeys.self) + + + + let isoFormatter = ISO8601DateFormatter() + + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + + + try container.encode(isBreak, forKey: .isBreak) + + try container.encode(lessonName, forKey: .lessonName) + + try container.encodeIfPresent(lessonTheme, forKey: .lessonTheme) + + try container.encodeIfPresent(roomName, forKey: .roomName) + + try container.encodeIfPresent(teacherName, forKey: .teacherName) + + + + try container.encode(isoFormatter.string(from: startTime), forKey: .startTime) + + try container.encode(isoFormatter.string(from: endTime), forKey: .endTime) + + + + try container.encodeIfPresent(lessonNumber, forKey: .lessonNumber) + + try container.encodeIfPresent(mode, forKey: .mode) + + try container.encodeIfPresent(message, forKey: .message) + + try container.encodeIfPresent(season, forKey: .season) + + try container.encodeIfPresent(nextLessonName, forKey: .nextLessonName) + + try container.encodeIfPresent(nextRoomName, forKey: .nextRoomName) + + + + if let nextStartTime = nextStartTime { + + try container.encode(isoFormatter.string(from: nextStartTime), forKey: .nextStartTime) + + } + + + + try container.encodeIfPresent(isSubstitution, forKey: .isSubstitution) + + try container.encodeIfPresent(isCancelled, forKey: .isCancelled) + + try container.encodeIfPresent(substituteTeacher, forKey: .substituteTeacher) + + + + try container.encode(isoFormatter.string(from: currentTime), forKey: .currentTime) + + } + + } + + + + var studentName: String + + var schoolName: String + + } + + + + extension TimetableActivityAttributes.ContentState { + + var timeRemaining: TimeInterval { + + return endTime.timeIntervalSince(currentTime) + + } + + + + var isBeforeSchool: Bool { + + return currentTime < startTime && !isBreak + + } + + + + var formattedStartTime: String { + + let formatter = DateFormatter() + + formatter.dateFormat = "HH:mm" + + formatter.timeZone = TimeZone(identifier: "UTC") + + + let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: startTime) ?? startTime + + return formatter.string(from: adjustedDate) + + } + + + + var formattedEndTime: String { + + let formatter = DateFormatter() + + formatter.dateFormat = "HH:mm" + + + formatter.timeZone = TimeZone(identifier: "UTC") + + let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: endTime) ?? endTime + + return formatter.string(from: adjustedDate) + + } + + + + var formattedNextStartTime: String { + + guard let nextStartTime = nextStartTime else { return "" } + + let formatter = DateFormatter() + + formatter.dateFormat = "HH:mm" + + + formatter.timeZone = TimeZone(identifier: "UTC") + + let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: nextStartTime) ?? nextStartTime + + return formatter.string(from: adjustedDate) + + } + + + + var timeRemainingText: String { + + let remaining = timeRemaining + + + + if remaining < 0 { + + return "0:00" + + } + + + + let hours = Int(remaining) / 3600 + + let minutes = (Int(remaining) % 3600) / 60 + + let seconds = Int(remaining) % 60 + + + + if hours > 0 { + + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + + } else if minutes > 0 { + + return String(format: "%d:%02d", minutes, seconds) + + } else { + + return String(format: "0:%02d", seconds) + + } + + } + + + + + var seasonalRemainingText: String { + + let remaining = max(0, timeRemaining) + + let hours = Int(remaining) / 3600 + + if hours >= 24 { + + let days = hours / 24 + + return "Szünetből hátralévő idő: \(days) nap" + + } + + return "Szünetből hátralévő idő: \(hours) óra" + + } + + + + var seasonalDisplayValue: String { + + let remaining = max(0, timeRemaining) + + let hours = Int(remaining) / 3600 + + if hours >= 24 { + + let days = hours / 24 + + return "\(days) nap" + + } + + return "\(hours) óra" + + } + + } + + + + + extension TimetableActivityAttributes.ContentState { + + func toJSON() -> [String: Any] { + + var json: [String: Any] = [ + + "isBreak": isBreak, + + "lessonName": lessonName, + + "startTime": ISO8601DateFormatter().string(from: startTime), + + "endTime": ISO8601DateFormatter().string(from: endTime), + + "currentTime": ISO8601DateFormatter().string(from: currentTime) + + ] + + + + if let isSubstitution = isSubstitution { + + json["isSubstitution"] = isSubstitution + + } + + if let isCancelled = isCancelled { + + json["isCancelled"] = isCancelled + + } + + if let lessonTheme = lessonTheme { + + json["lessonTheme"] = lessonTheme + + } + + if let roomName = roomName { + + json["roomName"] = roomName + + } + + if let teacherName = teacherName { + + json["teacherName"] = teacherName + + } + + if let lessonNumber = lessonNumber { + + json["lessonNumber"] = lessonNumber + + } + + if let nextLessonName = nextLessonName { + + json["nextLessonName"] = nextLessonName + + } + + if let nextRoomName = nextRoomName { + + json["nextRoomName"] = nextRoomName + + } + + if let nextStartTime = nextStartTime { + + json["nextStartTime"] = ISO8601DateFormatter().string(from: nextStartTime) + + } + + if let substituteTeacher = substituteTeacher { + + json["substituteTeacher"] = substituteTeacher + + } + + if let mode = mode { + + json["mode"] = mode + + } + + if let message = message { + + json["message"] = message + + } + + if let season = season { + + json["season"] = season + + } + + + + return json + + } + + + + static func fromJSON(_ json: [String: Any]) -> TimetableActivityAttributes.ContentState? { + + let isoFormatter = ISO8601DateFormatter() + + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + + + guard let isBreak = json["isBreak"] as? Bool, + + let lessonName = json["lessonName"] as? String, + + let startTimeStr = json["startTime"] as? String, + + let endTimeStr = json["endTime"] as? String, + + let startTime = isoFormatter.date(from: startTimeStr), + + let endTime = isoFormatter.date(from: endTimeStr) else { + + return nil + + } + + + let currentTimeStr = json["currentTime"] as? String + + let currentTime = currentTimeStr.flatMap { isoFormatter.date(from: $0) } ?? Date() + + + + let nextStartTime: Date? + + if let nextStartTimeStr = json["nextStartTime"] as? String { + + nextStartTime = isoFormatter.date(from: nextStartTimeStr) + + } else { + + nextStartTime = nil + + } + + + + return TimetableActivityAttributes.ContentState( + + isBreak: isBreak, + + lessonName: lessonName, + + lessonTheme: json["lessonTheme"] as? String, + + roomName: json["roomName"] as? String, + + teacherName: json["teacherName"] as? String, + + startTime: startTime, + + endTime: endTime, + + lessonNumber: json["lessonNumber"] as? Int, + + mode: json["mode"] as? String, + + message: json["message"] as? String, + + season: json["season"] as? String, + + nextLessonName: json["nextLessonName"] as? String, + + nextRoomName: json["nextRoomName"] as? String, + + nextStartTime: nextStartTime, + + isSubstitution: json["isSubstitution"] as? Bool, + + isCancelled: json["isCancelled"] as? Bool, + + substituteTeacher: json["substituteTeacher"] as? String, + + currentTime: currentTime + + ) + + } + + } + + \ No newline at end of file diff --git a/firka/ios/TimetableWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/firka/ios/TimetableWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/firka/ios/TimetableWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/TimetableWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/firka/ios/TimetableWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/firka/ios/TimetableWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/TimetableWidget/Assets.xcassets/Contents.json b/firka/ios/TimetableWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/firka/ios/TimetableWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/TimetableWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/firka/ios/TimetableWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/firka/ios/TimetableWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/firka/ios/TimetableWidget/Info.plist b/firka/ios/TimetableWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/firka/ios/TimetableWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/firka/ios/TimetableWidget/SeasonalIconHelper.swift b/firka/ios/TimetableWidget/SeasonalIconHelper.swift new file mode 100644 index 0000000..dedbccb --- /dev/null +++ b/firka/ios/TimetableWidget/SeasonalIconHelper.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct SeasonalIconHelper { + static func iconName(for mode: String?, season: String?) -> String { + guard let mode = mode else { + return "book.fill" + } + + switch mode { + case "xmas": + return "gift.fill" + case "newYearEve": + return "party.popper.fill" + case "newYearDay": + return "sparkles" + case "beforeSchool": + return "sun.horizon.fill" + case "seasonalBreak": + guard let season = season else { + return "snowflake" + } + switch season { + case "spring": + return "flower.fill" + case "summer": + return "sun.max.fill" + case "autumn": + return "leaf.fill" + case "winter": + return "snowflake" + default: + return "snowflake" + } + default: + return "book.fill" + } + } + + static func iconColor(for mode: String?) -> Color { + guard let mode = mode else { + return .green + } + + switch mode { + case "beforeSchool": + return .orange + case "xmas", "newYearEve", "newYearDay", "seasonalBreak": + return .green + default: + return .green + } + } + + static func isSeasonalMode(_ mode: String?) -> Bool { + guard let mode = mode else { + return false + } + return mode == "seasonalBreak" || mode == "xmas" || mode == "newYearEve" || mode == "newYearDay" + } + + static func holidayTitle(for season: String?) -> String { + guard let season = season else { + return "Kellemes szünetet!" + } + + switch season { + case "spring": + return "Kellemes tavaszi szünetet!" + case "summer": + return "Kellemes nyári szünetet!" + case "autumn": + return "Kellemes őszi szünetet!" + case "winter": + return "Kellemes téli szünetet!" + default: + return "Kellemes szünetet!" + } + } +} + diff --git a/firka/ios/TimetableWidget/TimetableActivityAttributes.swift b/firka/ios/TimetableWidget/TimetableActivityAttributes.swift new file mode 100644 index 0000000..8433f57 --- /dev/null +++ b/firka/ios/TimetableWidget/TimetableActivityAttributes.swift @@ -0,0 +1,328 @@ +import ActivityKit +import Foundation + +struct TimetableActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var isBreak: Bool + var lessonName: String + var lessonTheme: String? + var roomName: String? + var teacherName: String? + var startTime: Date + var endTime: Date + var lessonNumber: Int? + + var mode: String? // "lesson" | "break" | "seasonalBreak" | "xmas" | "newYear" + var message: String? + var season: String? + + var nextLessonName: String? + var nextRoomName: String? + var nextStartTime: Date? + + var isSubstitution: Bool + var isCancelled: Bool + var substituteTeacher: String? + + var currentTime: Date + + enum CodingKeys: String, CodingKey { + case isBreak + case lessonName + case lessonTheme + case roomName + case teacherName + case startTime + case endTime + case lessonNumber + case mode + case message + case season + case nextLessonName + case nextRoomName + case nextStartTime + case isSubstitution + case isCancelled + case substituteTeacher + case currentTime + } + + init(isBreak: Bool, lessonName: String, lessonTheme: String?, roomName: String?, teacherName: String?, startTime: Date, endTime: Date, lessonNumber: Int?, mode: String?, message: String?, season: String?, nextLessonName: String?, nextRoomName: String?, nextStartTime: Date?, isSubstitution: Bool, isCancelled: Bool, substituteTeacher: String?, currentTime: Date) { + self.isBreak = isBreak + self.lessonName = lessonName + self.lessonTheme = lessonTheme + self.roomName = roomName + self.teacherName = teacherName + self.startTime = startTime + self.endTime = endTime + self.lessonNumber = lessonNumber + self.mode = mode + self.message = message + self.season = season + self.nextLessonName = nextLessonName + self.nextRoomName = nextRoomName + self.nextStartTime = nextStartTime + self.isSubstitution = isSubstitution + self.isCancelled = isCancelled + self.substituteTeacher = substituteTeacher + self.currentTime = currentTime + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + isBreak = try container.decode(Bool.self, forKey: .isBreak) + lessonName = try container.decode(String.self, forKey: .lessonName) + lessonTheme = try container.decodeIfPresent(String.self, forKey: .lessonTheme) + roomName = try container.decodeIfPresent(String.self, forKey: .roomName) + teacherName = try container.decodeIfPresent(String.self, forKey: .teacherName) + + let startTimeStr = try container.decode(String.self, forKey: .startTime) + guard let startTimeDate = isoFormatter.date(from: startTimeStr) else { + throw DecodingError.dataCorruptedError(forKey: .startTime, in: container, debugDescription: "Invalid startTime format: \(startTimeStr)") + } + startTime = startTimeDate + + let endTimeStr = try container.decode(String.self, forKey: .endTime) + guard let endTimeDate = isoFormatter.date(from: endTimeStr) else { + throw DecodingError.dataCorruptedError(forKey: .endTime, in: container, debugDescription: "Invalid endTime format: \(endTimeStr)") + } + endTime = endTimeDate + + lessonNumber = try container.decodeIfPresent(Int.self, forKey: .lessonNumber) + mode = try container.decodeIfPresent(String.self, forKey: .mode) + message = try container.decodeIfPresent(String.self, forKey: .message) + season = try container.decodeIfPresent(String.self, forKey: .season) + nextLessonName = try container.decodeIfPresent(String.self, forKey: .nextLessonName) + nextRoomName = try container.decodeIfPresent(String.self, forKey: .nextRoomName) + + if let nextStartTimeStr = try container.decodeIfPresent(String.self, forKey: .nextStartTime) { + nextStartTime = isoFormatter.date(from: nextStartTimeStr) + } else { + nextStartTime = nil + } + + isSubstitution = try container.decode(Bool.self, forKey: .isSubstitution) + isCancelled = try container.decode(Bool.self, forKey: .isCancelled) + substituteTeacher = try container.decodeIfPresent(String.self, forKey: .substituteTeacher) + + let currentTimeStr = try container.decode(String.self, forKey: .currentTime) + guard let currentTimeDate = isoFormatter.date(from: currentTimeStr) else { + throw DecodingError.dataCorruptedError(forKey: .currentTime, in: container, debugDescription: "Invalid currentTime format: \(currentTimeStr)") + } + currentTime = currentTimeDate + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + try container.encode(isBreak, forKey: .isBreak) + try container.encode(lessonName, forKey: .lessonName) + try container.encodeIfPresent(lessonTheme, forKey: .lessonTheme) + try container.encodeIfPresent(roomName, forKey: .roomName) + try container.encodeIfPresent(teacherName, forKey: .teacherName) + + try container.encode(isoFormatter.string(from: startTime), forKey: .startTime) + try container.encode(isoFormatter.string(from: endTime), forKey: .endTime) + + try container.encodeIfPresent(lessonNumber, forKey: .lessonNumber) + try container.encodeIfPresent(mode, forKey: .mode) + try container.encodeIfPresent(message, forKey: .message) + try container.encodeIfPresent(season, forKey: .season) + try container.encodeIfPresent(nextLessonName, forKey: .nextLessonName) + try container.encodeIfPresent(nextRoomName, forKey: .nextRoomName) + + if let nextStartTime = nextStartTime { + try container.encode(isoFormatter.string(from: nextStartTime), forKey: .nextStartTime) + } + + try container.encode(isSubstitution, forKey: .isSubstitution) + try container.encode(isCancelled, forKey: .isCancelled) + try container.encodeIfPresent(substituteTeacher, forKey: .substituteTeacher) + + try container.encode(isoFormatter.string(from: currentTime), forKey: .currentTime) + } + } + + var studentName: String + var schoolName: String +} + +extension TimetableActivityAttributes.ContentState { + var timeRemaining: TimeInterval { + return endTime.timeIntervalSince(currentTime) + } + + var isBeforeSchool: Bool { + return currentTime < startTime && !isBreak + } + + var formattedStartTime: String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone(identifier: "UTC") + let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: startTime) ?? startTime + return formatter.string(from: adjustedDate) + } + + var formattedEndTime: String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone(identifier: "UTC") + let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: endTime) ?? endTime + return formatter.string(from: adjustedDate) + } + + var formattedNextStartTime: String { + guard let nextStartTime = nextStartTime else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone(identifier: "UTC") + let adjustedDate = Calendar.current.date(byAdding: .hour, value: 1, to: nextStartTime) ?? nextStartTime + return formatter.string(from: adjustedDate) + } + + var timeRemainingText: String { + let remaining = timeRemaining + + if remaining < 0 { + return "0:00" + } + + let hours = Int(remaining) / 3600 + let minutes = (Int(remaining) % 3600) / 60 + let seconds = Int(remaining) % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } else if minutes > 0 { + return String(format: "%d:%02d", minutes, seconds) + } else { + return String(format: "0:%02d", seconds) + } + } + + var seasonalRemainingText: String { + let remaining = max(0, timeRemaining) + let hours = Int(remaining) / 3600 + if hours >= 24 { + let days = hours / 24 + return "Szünetből hátralévő idő: \(days) nap" + } + return "Szünetből hátralévő idő: \(hours) óra" + } + + var seasonalDisplayValue: String { + let remaining = max(0, timeRemaining) + let hours = Int(remaining) / 3600 + if hours >= 24 { + let days = hours / 24 + return "\(days) nap" + } + return "\(hours) óra" + } +} + +extension TimetableActivityAttributes.ContentState { + func toJSON() -> [String: Any] { + var json: [String: Any] = [ + "isBreak": isBreak, + "lessonName": lessonName, + "startTime": ISO8601DateFormatter().string(from: startTime), + "endTime": ISO8601DateFormatter().string(from: endTime), + "isSubstitution": isSubstitution, + "isCancelled": isCancelled, + "currentTime": ISO8601DateFormatter().string(from: currentTime) + ] + + if let lessonTheme = lessonTheme { + json["lessonTheme"] = lessonTheme + } + if let roomName = roomName { + json["roomName"] = roomName + } + if let teacherName = teacherName { + json["teacherName"] = teacherName + } + if let lessonNumber = lessonNumber { + json["lessonNumber"] = lessonNumber + } + if let nextLessonName = nextLessonName { + json["nextLessonName"] = nextLessonName + } + if let nextRoomName = nextRoomName { + json["nextRoomName"] = nextRoomName + } + if let nextStartTime = nextStartTime { + json["nextStartTime"] = ISO8601DateFormatter().string(from: nextStartTime) + } + if let substituteTeacher = substituteTeacher { + json["substituteTeacher"] = substituteTeacher + } + if let mode = mode { + json["mode"] = mode + } + if let message = message { + json["message"] = message + } + if let season = season { + json["season"] = season + } + + return json + } + + static func fromJSON(_ json: [String: Any]) -> TimetableActivityAttributes.ContentState? { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let isBreak = json["isBreak"] as? Bool, + let lessonName = json["lessonName"] as? String, + let startTimeStr = json["startTime"] as? String, + let endTimeStr = json["endTime"] as? String, + let isSubstitution = json["isSubstitution"] as? Bool, + let isCancelled = json["isCancelled"] as? Bool, + let startTime = isoFormatter.date(from: startTimeStr), + let endTime = isoFormatter.date(from: endTimeStr) else { + return nil + } + + let currentTimeStr = json["currentTime"] as? String + let currentTime = currentTimeStr.flatMap { isoFormatter.date(from: $0) } ?? Date() + + let nextStartTime: Date? + if let nextStartTimeStr = json["nextStartTime"] as? String { + nextStartTime = isoFormatter.date(from: nextStartTimeStr) + } else { + nextStartTime = nil + } + + return TimetableActivityAttributes.ContentState( + isBreak: isBreak, + lessonName: lessonName, + lessonTheme: json["lessonTheme"] as? String, + roomName: json["roomName"] as? String, + teacherName: json["teacherName"] as? String, + startTime: startTime, + endTime: endTime, + lessonNumber: json["lessonNumber"] as? Int, + mode: json["mode"] as? String, + message: json["message"] as? String, + season: json["season"] as? String, + nextLessonName: json["nextLessonName"] as? String, + nextRoomName: json["nextRoomName"] as? String, + nextStartTime: nextStartTime, + isSubstitution: isSubstitution, + isCancelled: isCancelled, + substituteTeacher: json["substituteTeacher"] as? String, + currentTime: currentTime + ) + } +} + diff --git a/firka/ios/TimetableWidget/TimetableLiveActivity.swift b/firka/ios/TimetableWidget/TimetableLiveActivity.swift new file mode 100644 index 0000000..8e184a9 --- /dev/null +++ b/firka/ios/TimetableWidget/TimetableLiveActivity.swift @@ -0,0 +1,583 @@ +import ActivityKit +import WidgetKit +import SwiftUI + +@available(iOS 16.2, *) +struct TimetableLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: TimetableActivityAttributes.self) { context in + // Lock screen/banner UI + TimetableLiveActivityView(context: context) + .activityBackgroundTint(Color(red: 0.1, green: 0.15, blue: 0.1)) + .activitySystemActionForegroundColor(Color.white) + } dynamicIsland: { context in + let mode = context.state.mode ?? (context.state.isBreak ? "break" : "lesson") + + if mode == "end" { + return DynamicIsland { + // Expanded UI for 'end' state + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + } + DynamicIslandExpandedRegion(.trailing) { + EmptyView() + } + DynamicIslandExpandedRegion(.center) { + Text(context.state.lessonName) + .lineLimit(1) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + } + DynamicIslandExpandedRegion(.bottom) { + Text("A mai órarended véget ért.") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } compactLeading: { + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + } compactTrailing: { + Text("Vége") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(.green) + } minimal: { + Image(systemName: "checkmark.circle.fill").foregroundColor(.green) + } + } else { + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "HH:mm" + + return DynamicIsland { + // Expanded UI + DynamicIslandExpandedRegion(.leading) { + let season = context.state.season ?? "" + HStack(alignment: .center, spacing: 4) { + if mode == "beforeSchool" { + Image(systemName: SeasonalIconHelper.iconName(for: mode, season: season)) + .font(.system(size: 18)) + .foregroundColor(SeasonalIconHelper.iconColor(for: mode)) + } else if !SeasonalIconHelper.isSeasonalMode(mode) && !context.state.isBreak { + if let lessonNumber = context.state.lessonNumber { + Text("\(lessonNumber).") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + } else { + EmptyView() + } + } else { + EmptyView() + } + } + } + + DynamicIslandExpandedRegion(.trailing) { + let beforeSchoolTime = mode == "beforeSchool" ? { + let adjustedDate = context.state.endTime + return timeFormatter.string(from: adjustedDate) + }() : nil + + if SeasonalIconHelper.isSeasonalMode(mode) { + EmptyView() + } else if mode == "beforeSchool", let timeString = beforeSchoolTime { + Text("Kezdés: \(timeString)") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray) + } else { + Text(context.state.formattedStartTime + " - " + context.state.formattedEndTime) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray) + } + } + + DynamicIslandExpandedRegion(.center) { + let season = context.state.season ?? "" + VStack(spacing: 4) { + if mode == "beforeSchool" { + Text("Hamarosan suli") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text("Első órád:") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text(context.state.lessonName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white) + } + + if let roomName = context.state.roomName { + HStack(spacing: 4) { + Text("Terem:") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text(roomName) + .font(.system(size: 12)) + .foregroundColor(.white) + } + } + + if let teacherName = context.state.teacherName { + HStack(spacing: 4) { + Text("Tanár:") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text(teacherName) + .font(.system(size: 12)) + .foregroundColor(.white) + } + } + } + } else if SeasonalIconHelper.isSeasonalMode(mode) { + if mode == "xmas" || mode == "newYearEve" || mode == "newYearDay" { + // Global holidays: show message prominently + HStack(alignment: .center, spacing: 6) { + Image(systemName: SeasonalIconHelper.iconName(for: mode, season: season)) + .font(.system(size: 18)) + .foregroundColor(SeasonalIconHelper.iconColor(for: mode)) + Text(context.state.message ?? "Szünet") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + .lineLimit(2) + } + } else { + // Seasonal breaks: show holiday title + HStack(alignment: .center, spacing: 6) { + Image(systemName: SeasonalIconHelper.iconName(for: mode, season: season)) + .font(.system(size: 18)) + .foregroundColor(SeasonalIconHelper.iconColor(for: mode)) + Text(SeasonalIconHelper.holidayTitle(for: season)) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + } + } + } else if context.state.isBreak { + Text("Szünet") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + + if let nextLessonName = context.state.nextLessonName { + HStack(spacing: 4) { + Text("Következő:") + .font(.system(size: 14)) + .foregroundColor(.gray) + Text(nextLessonName) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + } + } + + if let nextRoomName = context.state.nextRoomName { + Text("Terem: \(nextRoomName)") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } else { + Text(context.state.lessonName) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + + if let lessonTheme = context.state.lessonTheme, !lessonTheme.isEmpty { + Text(lessonTheme) + .font(.system(size: 12)) + .foregroundColor(.gray) + .lineLimit(1) + } + + HStack(spacing: 8) { + if let roomName = context.state.roomName { + Label(roomName, systemImage: "door.left.hand.closed") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + + if context.state.isSubstitution ?? false { + Label("Helyettesítés", systemImage: "arrow.triangle.2.circlepath") + .font(.system(size: 12)) + .foregroundColor(.orange) + } + + if context.state.isCancelled ?? false { + Label("Elmaradt", systemImage: "xmark.circle") + .font(.system(size: 12)) + .foregroundColor(.red) + } + } + } + } + } + + DynamicIslandExpandedRegion(.bottom) { + HStack { + Spacer() + if mode == "xmas" || mode == "newYearDay" { + EmptyView() + } else if mode == "beforeSchool" { + if context.state.endTime > context.state.currentTime { + Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(.green) + .multilineTextAlignment(.center) + .monospacedDigit() + } else { + Text(context.state.formattedEndTime) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(.green) + .multilineTextAlignment(.center) + .monospacedDigit() + } + } else if mode == "seasonalBreak" { + Text(context.state.seasonalRemainingText) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundColor(.green) + .multilineTextAlignment(.center) + .monospacedDigit() + } else { + Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(.green) + .multilineTextAlignment(.center) + .monospacedDigit() + } + Spacer() + } + } + } compactLeading: { + let season = context.state.season ?? "" + let iconName: String = { + if SeasonalIconHelper.isSeasonalMode(mode) || mode == "beforeSchool" { + return SeasonalIconHelper.iconName(for: mode, season: season) + } else { + return context.state.isBreak ? "cup.and.saucer.fill" : "book.fill" + } + }() + Image(systemName: iconName) + .font(.system(size: 14)) + .foregroundColor(SeasonalIconHelper.iconColor(for: mode)) + } compactTrailing: { + if SeasonalIconHelper.isSeasonalMode(mode) { + // Show timer for New Year's Eve countdown and seasonal breaks + if mode == "newYearEve" || mode == "seasonalBreak" { + Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(.green) + .monospacedDigit() + .frame(width: 50) + } else { + // No timer for xmas and newYearDay + Text("") + .frame(width: 50) + } + } else { + Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(.green) + .monospacedDigit() + .frame(width: 50) + } + } minimal: { + let season = context.state.season ?? "" + let iconName: String = { + if SeasonalIconHelper.isSeasonalMode(mode) || mode == "beforeSchool" { + return SeasonalIconHelper.iconName(for: mode, season: season) + } else { + return context.state.isBreak ? "cup.and.saucer.fill" : "book.fill" + } + }() + Image(systemName: iconName) + .font(.system(size: 12)) + .foregroundColor(SeasonalIconHelper.iconColor(for: mode)) + } + } + } + } +} + +// MARK: - Lock Screen View +@available(iOS 16.2, *) +struct TimetableLiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + let mode = context.state.mode ?? (context.state.isBreak ? "break" : "lesson") + + if mode == "end" { + VStack(spacing: 8) { + HStack { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.green) + Text(context.state.lessonName) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + Spacer() + } + Text("A mai órarended véget ért.") + .font(.system(size: 14)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + } else { + VStack(spacing: 12) { + let season = context.state.season ?? "" + let screenTimeFormatter = DateFormatter() + let _ = { screenTimeFormatter.dateFormat = "HH:mm" }() + + let beforeSchoolTime = mode == "beforeSchool" ? { + let adjustedDate = context.state.endTime + return screenTimeFormatter.string(from: adjustedDate) + }() : nil + let iconName: String = { + if SeasonalIconHelper.isSeasonalMode(mode) || mode == "beforeSchool" { + return SeasonalIconHelper.iconName(for: mode, season: season) + } else { + return context.state.isBreak ? "cup.and.saucer.fill" : "book.fill" + } + }() + + // Header + HStack { + Image(systemName: iconName) + .font(.system(size: 24)) + .foregroundColor(SeasonalIconHelper.iconColor(for: mode)) + + VStack(alignment: .leading, spacing: 2) { + if mode == "beforeSchool" { + Text("Hamarosan suli") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + } else if SeasonalIconHelper.isSeasonalMode(mode) { + // Check if it's a special holiday with a prominent message + if mode == "xmas" || mode == "newYearEve" || mode == "newYearDay" { + // Global holidays: show message prominently + Text(context.state.message ?? context.state.lessonName) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .lineLimit(2) + } else { + // Seasonal breaks: show holiday title + Text(SeasonalIconHelper.holidayTitle(for: context.state.season)) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + } + } else if context.state.isBreak { + Text("Szünet") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + if let nextLessonName = context.state.nextLessonName { + Text("Következő: \(nextLessonName)") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + } else { + if let lessonNumber = context.state.lessonNumber { + Text("\(lessonNumber). óra") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + Text(context.state.lessonName) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + if SeasonalIconHelper.isSeasonalMode(mode) { + EmptyView() + } else if mode == "beforeSchool", let timeString = beforeSchoolTime { + Text("Kezdés: \(timeString)") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + } else { + Text(context.state.formattedStartTime + " - " + context.state.formattedEndTime) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + } + + if SeasonalIconHelper.isSeasonalMode(mode) { + EmptyView() + } else if mode == "beforeSchool" { + EmptyView() + } else if context.state.isBreak { + if let _ = context.state.nextStartTime { + Text("Kezdés: \(context.state.formattedNextStartTime)") + .font(.system(size: 12)) + .foregroundColor(.gray) + } else { + EmptyView() + } + } else { + if let roomName = context.state.roomName { + HStack(spacing: 4) { + Image(systemName: "door.left.hand.closed") + .font(.system(size: 10)) + Text(roomName) + .font(.system(size: 12)) + } + .foregroundColor(.gray) + } else { + EmptyView() + } + } + } + } + + // Content + let mode2 = context.state.mode ?? (context.state.isBreak ? "break" : "lesson") + if mode2 == "beforeSchool" { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text("Első órád:") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.gray) + Text(context.state.lessonName) + .font(.system(size: 12)) + .foregroundColor(.white) + } + + if let roomName = context.state.roomName { + HStack(spacing: 4) { + Text("Terem:") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.gray) + Text(roomName) + .font(.system(size: 12)) + .foregroundColor(.white) + } + } + + if let teacherName = context.state.teacherName { + HStack(spacing: 4) { + Text("Tanár:") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.gray) + Text(teacherName) + .font(.system(size: 12)) + .foregroundColor(.white) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if SeasonalIconHelper.isSeasonalMode(mode2) { + EmptyView() + } else if !context.state.isBreak { + VStack(alignment: .leading, spacing: 4) { + if let lessonTheme = context.state.lessonTheme, !lessonTheme.isEmpty { + HStack { + Text("Téma:") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.gray) + Text(lessonTheme) + .font(.system(size: 12)) + .foregroundColor(.white) + .lineLimit(1) + } + } + + if context.state.isSubstitution ?? false { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 12)) + Text("Helyettesítés") + .font(.system(size: 12, weight: .semibold)) + if let substituteTeacher = context.state.substituteTeacher { + Text("(\(substituteTeacher))") + .font(.system(size: 12)) + } + } + .foregroundColor(.orange) + } + + if context.state.isCancelled ?? false { + HStack(spacing: 4) { + Image(systemName: "xmark.circle") + .font(.system(size: 12)) + Text("Elmaradt óra") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if let nextRoomName = context.state.nextRoomName { + HStack { + Image(systemName: "door.left.hand.closed") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text("Következő terem: \(nextRoomName)") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Countdown Timer + HStack { + Spacer() + VStack(spacing: 4) { + let mode3 = context.state.mode ?? (context.state.isBreak ? "break" : "lesson") + if mode3 == "xmas" || mode3 == "newYearDay" { + EmptyView() + } else if mode3 == "beforeSchool" { + Text("Első óra kezdése") + .font(.system(size: 10)) + .foregroundColor(.gray) + + if context.state.endTime > context.state.currentTime { + Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundColor(.green) + .multilineTextAlignment(.center) + .monospacedDigit() + } else { + Text(context.state.formattedEndTime) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundColor(.green) + .multilineTextAlignment(.center) + .monospacedDigit() + } + } else if mode3 == "seasonalBreak" { + Text("Szünetből hátralévő idő") + .font(.system(size: 10)) + .foregroundColor(.gray) + Text(context.state.seasonalDisplayValue) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundColor(.green) + .multilineTextAlignment(.center) + .monospacedDigit() + } else { + let labelText: String = { + if mode3 == "newYearEve" { + return "Új év" + } else if mode3 == "beforeSchool" { + return "Első óra kezdése" + } else if context.state.isBreak { + return "Szünet vége" + } else { + return "Óra vége" + } + }() + Text(labelText) + .font(.system(size: 10)) + .foregroundColor(.gray) + Text(timerInterval: context.state.currentTime...context.state.endTime, countsDown: true) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundColor(.green) + .multilineTextAlignment(.center) + .monospacedDigit() + } + } + Spacer() + } + } + .padding(16) + } + } +} \ No newline at end of file diff --git a/firka/ios/TimetableWidget/TimetableWidgetBundle.swift b/firka/ios/TimetableWidget/TimetableWidgetBundle.swift new file mode 100644 index 0000000..fb04aeb --- /dev/null +++ b/firka/ios/TimetableWidget/TimetableWidgetBundle.swift @@ -0,0 +1,11 @@ +import WidgetKit +import SwiftUI + +@main +struct TimetableWidgetBundle: WidgetBundle { + var body: some Widget { + if #available(iOS 16.2, *) { + TimetableLiveActivity() + } + } +} diff --git a/firka/ios/TimetableWidgetExtension.entitlements b/firka/ios/TimetableWidgetExtension.entitlements new file mode 100644 index 0000000..b71b0c8 --- /dev/null +++ b/firka/ios/TimetableWidgetExtension.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.security.application-groups + + group.app.firka.firkaa + + + diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart index dc7f0a2..73a35a2 100644 --- a/firka/lib/helpers/api/client/kreta_client.dart +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -2,10 +2,12 @@ import 'dart:convert'; import 'dart:math'; import 'package:dio/dio.dart'; +import 'package:firka/helpers/api/model/all_lessons.dart'; import 'package:firka/helpers/api/model/class_group.dart'; import 'package:firka/helpers/api/model/homework.dart'; import 'package:firka/helpers/api/model/timetable.dart'; import 'package:firka/helpers/db/models/generic_cache_model.dart'; +import 'package:firka/helpers/db/models/homework_cache_model.dart'; import 'package:firka/helpers/db/models/timetable_cache_model.dart'; import 'package:intl/intl.dart'; import 'package:isar/isar.dart'; @@ -591,6 +593,41 @@ class KretaClient { return ApiResponse(lessons, 200, err, cached); } + Future>> getLessons({bool forceCache = true}) async { + var (resp, status, ex, cached) = await _cachingGet( + CacheId.getLessons, + KretaEndpoints.getLessons(model.iss!), + forceCache, + 0, + ); + + var items = []; + String? err; + + try { + if (resp is List) { + for (var item in resp) { + if (item != null && item is Map) { + items.add(AllLessons.fromJson(item)); + } else { + logger.warning("$item"); + } + } + } else { + err = "${resp.runtimeType}"; + } + } catch (e, stack) { + err = e.toString(); + logger.warning(e, stack); + } + + if (ex != null) { + err = ex.toString(); + } + + return ApiResponse(items, status, err, cached); + } + Future>> getTests({bool forceCache = true}) async { var (resp, status, ex, cached) = await _cachingGet( CacheId.getTests, KretaEndpoints.getTests(model.iss!), forceCache, 0); diff --git a/firka/lib/helpers/api/client/live_activity_backend_client.dart b/firka/lib/helpers/api/client/live_activity_backend_client.dart new file mode 100644 index 0000000..2490c97 --- /dev/null +++ b/firka/lib/helpers/api/client/live_activity_backend_client.dart @@ -0,0 +1,321 @@ +import 'package:dio/dio.dart'; +import 'package:firka/helpers/api/model/timetable.dart'; +import 'package:logging/logging.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +/// Client for communicating with the LiveActivity backend service +/// This backend handles device tokens and timetable data for push notifications +class LiveActivityBackendClient { + static final Logger _logger = Logger('LiveActivityBackendClient'); + + final Dio _dio; + + LiveActivityBackendClient({Dio? dio}) : _dio = dio ?? Dio() { + final baseUrl = dotenv.env['BACKEND_BASE_URL']; + final apiKey = dotenv.env['BACKEND_API_KEY'] ?? ''; + + _dio.options.baseUrl = baseUrl!; + _dio.options.connectTimeout = const Duration(seconds: 10); + _dio.options.receiveTimeout = const Duration(seconds: 10); + _dio.options.headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'x-api-key': apiKey, + }; + + _logger.info('LiveActivity backend configured successfully!'); + } + + /// Register device token and upload timetable data + Future registerDevice({ + required String deviceToken, + required List timetable, + String? language, + }) async { + try { + final lessonsData = timetable.map((lesson) { + DateTime validLastModified = lesson.lastModifiedAt; + if (validLastModified.year < 1900) { + validLastModified = lesson.start; + } + + return { + 'uid': lesson.uid, + 'date': lesson.date, + 'startTime': lesson.start.toIso8601String(), + 'endTime': lesson.end.toIso8601String(), + 'name': lesson.name, + 'lessonNumber': lesson.lessonNumber, + 'teacher': lesson.teacher, + 'theme': lesson.theme, + 'roomName': lesson.roomName, + 'isSubstitution': lesson.substituteTeacher != null, + 'substituteTeacher': lesson.substituteTeacher, + 'isCancelled': lesson.state.name?.toLowerCase().contains('elmarad') ?? false, + 'lastModified': validLastModified.toIso8601String(), + }; + }).toList(); + + final requestData = { + 'deviceToken': deviceToken, + 'lessons': lessonsData, + 'lastUpdated': DateTime.now().toIso8601String(), + }; + + if (language != null) { + requestData['language'] = language; + } + + final response = await _dio.post( + '/live-activity/register', + data: requestData, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + _logger.info('Device registered successfully with ${timetable.length} lessons'); + return true; + } + + _logger.warning('Failed to register device: ${response.statusCode}'); + return false; + } catch (e) { + _logger.severe('Error registering device: $e'); + return false; + } + } + + /// Update timetable data for existing device + Future updateTimetable({ + required String deviceToken, + required List timetable, + }) async { + try { + final lessonsData = timetable.map((lesson) { + DateTime validLastModified = lesson.lastModifiedAt; + if (validLastModified.year < 1900) { + validLastModified = lesson.start; + } + + return { + 'uid': lesson.uid, + 'date': lesson.date, + 'startTime': lesson.start.toIso8601String(), + 'endTime': lesson.end.toIso8601String(), + 'name': lesson.name, + 'lessonNumber': lesson.lessonNumber, + 'teacher': lesson.teacher, + 'theme': lesson.theme, + 'roomName': lesson.roomName, + 'isSubstitution': lesson.substituteTeacher != null, + 'substituteTeacher': lesson.substituteTeacher, + 'isCancelled': lesson.state.name?.toLowerCase().contains('elmarad') ?? false, + 'lastModified': validLastModified.toIso8601String(), + }; + }).toList(); + + final response = await _dio.put( + '/live-activity/timetable', + data: { + 'deviceToken': deviceToken, + 'lessons': lessonsData, + 'lastUpdated': DateTime.now().toIso8601String(), + }, + ); + + if (response.statusCode == 200) { + _logger.info('Timetable updated successfully'); + return true; + } + + _logger.warning('Failed to update timetable: ${response.statusCode}'); + return false; + } catch (e) { + _logger.severe('Error updating timetable: $e'); + return false; + } + } + + /// Unregister device (called when user logs out) + Future unregisterDevice({ + required String deviceToken, + }) async { + try { + final response = await _dio.delete( + '/live-activity/unregister', + data: { + 'deviceToken': deviceToken, + }, + ); + + if (response.statusCode == 200) { + _logger.info('Device unregistered successfully'); + return true; + } + + _logger.warning('Failed to unregister device: ${response.statusCode}'); + return false; + } catch (e) { + _logger.severe('Error unregistering device: $e'); + return false; + } + } + + /// Check if timetable has changed on backend + Future checkTimetableChanges({ + required String deviceToken, + required DateTime lastUpdated, + }) async { + try { + final response = await _dio.get( + '/live-activity/check-changes', + queryParameters: { + 'deviceToken': deviceToken, + 'lastUpdated': lastUpdated.toIso8601String(), + }, + ); + + if (response.statusCode == 200 && response.data is Map) { + final hasChanges = response.data['hasChanges'] as bool? ?? false; + return hasChanges; + } + + return false; + } catch (e) { + _logger.severe('Error checking timetable changes: $e'); + return false; + } + } + + /// Get current timetable from backend + Future?> getTimetable({ + required String deviceToken, + }) async { + try { + final response = await _dio.get( + '/live-activity/timetable', + queryParameters: { + 'deviceToken': deviceToken, + }, + ); + + if (response.statusCode == 200 && response.data is Map) { + final lessonsData = response.data['lessons'] as List?; + if (lessonsData != null) { + return null; + } + } + + return null; + } catch (e) { + _logger.severe('Error getting timetable: $e'); + return null; + } + } + + /// Update LiveActivity push token + Future updatePushToken({ + required String deviceToken, + required String pushToken, + }) async { + try { + final response = await _dio.post( + '/live-activity/push-token', + data: { + 'deviceToken': deviceToken, + 'pushToken': pushToken, + }, + ); + + if (response.statusCode == 200) { + _logger.info('LiveActivity push token updated successfully'); + return true; + } + + _logger.warning('Failed to update push token: ${response.statusCode}'); + return false; + } catch (e) { + _logger.severe('Error updating push token: $e'); + return false; + } + } + + /// Update normal APNs push token for regular notifications + Future updateApnsToken({ + required String deviceToken, + required String apnsPushToken, + }) async { + try { + final response = await _dio.post( + '/live-activity/apns-token', + data: { + 'deviceToken': deviceToken, + 'apnsPushToken': apnsPushToken, + }, + ); + + if (response.statusCode == 200) { + _logger.info('APNs push token updated successfully'); + return true; + } + + _logger.warning('Failed to update APNs push token: ${response.statusCode}'); + return false; + } catch (e) { + _logger.severe('Error updating APNs push token: $e'); + return false; + } + } + + /// Send a test notification (for debugging) + Future sendTestNotification({ + required String deviceToken, + }) async { + try { + final response = await _dio.post( + '/live-activity/test-notification', + data: { + 'deviceToken': deviceToken, + }, + ); + + if (response.statusCode == 200) { + _logger.info('Test notification sent successfully'); + return true; + } + + _logger.warning('Failed to send test notification: ${response.statusCode}'); + return false; + } catch (e) { + _logger.severe('Error sending test notification: $e'); + return false; + } + } + + /// Update language preference for device + Future updateLanguage({ + required String deviceToken, + required String language, + }) async { + try { + final response = await _dio.put( + '/live-activity/language', + data: { + 'deviceToken': deviceToken, + 'language': language, + }, + ); + + if (response.statusCode == 200) { + _logger.info('Language updated to $language successfully'); + return true; + } + + _logger.warning('Failed to update language: ${response.statusCode}'); + return false; + } catch (e) { + _logger.severe('Error updating language: $e'); + return false; + } + } +} + diff --git a/firka/lib/helpers/api/consts.dart b/firka/lib/helpers/api/consts.dart index d06268f..1ad2617 100644 --- a/firka/lib/helpers/api/consts.dart +++ b/firka/lib/helpers/api/consts.dart @@ -117,4 +117,7 @@ class KretaEndpoints { static String getTests(String iss) => "${kreta(iss)}/ellenorzo/v3/sajat/BejelentettSzamonkeresek"; + + static String getLessons(String iss) => + "${kreta(iss)}/dktapi/intezmenyek/munkaterek/tanulok"; } diff --git a/firka/lib/helpers/api/model/all_lessons.dart b/firka/lib/helpers/api/model/all_lessons.dart new file mode 100644 index 0000000..f1c0804 --- /dev/null +++ b/firka/lib/helpers/api/model/all_lessons.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +class AllLessons { + final String schoolId; + final String yearId; + final dynamic classId; + final String? className; + final bool classWorkspace; + final dynamic groupId; + final String? groupName; + final bool groupWorkspace; + final String groupWorkspaceName; + final dynamic subjectId; + final String subjectName; + final dynamic teacherId; + final String teacherGuid; + final String teacherName; + final dynamic teacherAnnoId; + final dynamic annoId; + final String? languageId; + final dynamic subjectCategoryId; + final String subjectCategoryName; + final dynamic typeId; + final String typeName; + final dynamic gradeTypeId; + final String gradeTypeName; + final dynamic taskPlaceId; + final String taskPlaceName; + final dynamic teacherAvatarTypeId; + final String teacherAvatarTypePath; + final dynamic taskGroupId; + + AllLessons({ + required this.schoolId, + required this.yearId, + this.classId, + this.className, + required this.classWorkspace, + this.groupId, + this.groupName, + required this.groupWorkspace, + required this.groupWorkspaceName, + required this.subjectId, + required this.subjectName, + required this.teacherId, + required this.teacherGuid, + required this.teacherName, + this.teacherAnnoId, + this.annoId, + this.languageId, + required this.subjectCategoryId, + required this.subjectCategoryName, + required this.typeId, + required this.typeName, + required this.gradeTypeId, + required this.gradeTypeName, + required this.taskPlaceId, + required this.taskPlaceName, + required this.teacherAvatarTypeId, + required this.teacherAvatarTypePath, + this.taskGroupId, + }); + + factory AllLessons.fromJson(Map json) => AllLessons( + schoolId: json['intezmenyId']?.toString() ?? '', + yearId: json['tanevId']?.toString() ?? '', + classId: json['osztalyId'], + className: json['osztalyNev']?.toString(), + classWorkspace: json['osztalyMunkaTer'] == true, + groupId: json['csoportId'], + groupName: json['csoportNev']?.toString(), + groupWorkspace: json['csoportMunkaTer'] == true, + groupWorkspaceName: json['osztalyCsoportNev']?.toString() ?? '', + subjectId: json['tantargyId'], + subjectName: json['tantargyNev']?.toString() ?? '', + teacherId: json['alkalmazottId'], + teacherGuid: json['alkalmazottGuid']?.toString() ?? '', + teacherName: json['alkalmazottNev']?.toString() ?? '', + teacherAnnoId: json['alkalmazottUzenoFalId'], + annoId: json['uzenoFalId'], + languageId: json['nyelvId']?.toString(), + subjectCategoryId: json['tantargyKategoriaId'], + subjectCategoryName: json['tantargyKategoriaNev']?.toString() ?? '', + typeId: json['tipusId'], + typeName: json['tipusNev']?.toString() ?? '', + gradeTypeId: json['evfolyamTipusId'], + gradeTypeName: json['evfolyamTipusNev']?.toString() ?? '', + taskPlaceId: json['feladatEllatasiHelyId'], + taskPlaceName: json['feladatEllatasiHelyNev']?.toString() ?? '', + teacherAvatarTypeId: json['alkalmazottAvatarTipusId'], + teacherAvatarTypePath: + json['alkalmazottAvatarEleres']?.toString() ?? '', + taskGroupId: json['oraiFeladatGroupId'], + ); + + Map toJson() => { + 'intezmenyId': schoolId, + 'tanevId': yearId, + 'osztalyId': classId, + 'osztalyNev': className, + 'osztalyMunkaTer': classWorkspace, + 'csoportId': groupId, + 'csoportNev': groupName, + 'csoportMunkaTer': groupWorkspace, + 'osztalyCsoportNev': groupWorkspaceName, + 'tantargyId': subjectId, + 'tantargyNev': subjectName, + 'alkalmazottId': teacherId, + 'alkalmazottGuid': teacherGuid, + 'alkalmazottNev': teacherName, + 'alkalmazottUzenoFalId': teacherAnnoId, + 'uzenoFalId': annoId, + 'nyelvId': languageId, + 'tantargyKategoriaId': subjectCategoryId, + 'tantargyKategoriaNev': subjectCategoryName, + 'tipusId': typeId, + 'tipusNev': typeName, + 'evfolyamTipusId': gradeTypeId, + 'evfolyamTipusNev': gradeTypeName, + 'feladatEllatasiHelyId': taskPlaceId, + 'feladatEllatasiHelyNev': taskPlaceName, + 'alkalmazottAvatarTipusId': teacherAvatarTypeId, + 'alkalmazottAvatarEleres': teacherAvatarTypePath, + 'oraiFeladatGroupId': taskGroupId, + }; +} + +List lessonsFromJson(String str) => + List.from(json.decode(str).map((x) => AllLessons.fromJson(x))); + +String lessonsToJson(List data) => + json.encode(List.from(data.map((x) => x.toJson()))); diff --git a/firka/lib/helpers/live_activity_manager.dart b/firka/lib/helpers/live_activity_manager.dart new file mode 100644 index 0000000..35e4c59 --- /dev/null +++ b/firka/lib/helpers/live_activity_manager.dart @@ -0,0 +1,419 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:firka/helpers/api/model/timetable.dart'; +import 'package:logging/logging.dart'; + +class _ActivityState { + final Lesson? currentLesson; + final Lesson? nextLesson; + final bool isBreak; + final String? mode; + + _ActivityState({this.currentLesson, this.nextLesson, this.isBreak = false, this.mode}); +} + +class LiveActivityManager { + static const MethodChannel _channel = MethodChannel('firka.app/live_activity'); + static final Logger _logger = Logger('LiveActivityManager'); + + static String? _activityId; + static bool _isActivityActive = false; + static Function(String activityId, String pushToken)? _onPushTokenReceived; + + static Future initialize() async { + if (!Platform.isIOS) return; + try { + _channel.setMethodCallHandler(_handleMethodCall); + await _channel.invokeMethod('initialize'); + await _syncActivityState(); + _logger.info('LiveActivity initialized'); + } catch (e) { + _logger.warning('Failed to initialize LiveActivity: $e'); + } + } + + static Future _syncActivityState() async { + if (!Platform.isIOS) return; + try { + final activeActivities = await getActiveActivities(); + if (activeActivities.isNotEmpty) { + _activityId = activeActivities.first; + _isActivityActive = true; + _logger.info('Synced activity state: Found existing activity $_activityId'); + } else { + _activityId = null; + _isActivityActive = false; + _logger.info('Synced activity state: No active activities found'); + } + } catch (e) { + _logger.warning('Failed to sync activity state: $e'); + } + } + + static Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'onPushTokenReceived': + final args = call.arguments as Map; + final activityId = args['activityId'] as String; + final pushToken = args['pushToken'] as String; + _logger.info('Received LiveActivity push token: ${pushToken.substring(0, 10)}...'); + _onPushTokenReceived?.call(activityId, pushToken); + break; + default: + _logger.warning('Unknown method call from Swift: ${call.method}'); + } + } + + static void setOnPushTokenReceived(Function(String activityId, String pushToken) callback) { + _onPushTokenReceived = callback; + } + + static Future getDeviceToken() async { + if (!Platform.isIOS) return null; + try { + return await _channel.invokeMethod('getDeviceToken'); + } catch (e) { + _logger.warning('Failed to get device token: $e'); + return null; + } + } + + static Future registerForPushNotifications() async { + if (!Platform.isIOS) return null; + try { + return await _channel.invokeMethod('registerForPushNotifications'); + } catch (e) { + _logger.warning('Failed to register for push notifications: $e'); + return null; + } + } + + static Future startActivity({ + required String studentName, + required String schoolName, + required Lesson currentLesson, + Lesson? nextLesson, + bool isBreak = false, + String? mode, + }) async { + if (!Platform.isIOS) return false; + try { + await _syncActivityState(); + if (_isActivityActive) { + _logger.info('Activity already exists, updating instead.'); + return updateActivity( + currentLesson: currentLesson, + nextLesson: nextLesson, + isBreak: isBreak, + mode: mode, + ); + } + + final contentState = _createContentState( + currentLesson: currentLesson, + nextLesson: nextLesson, + isBreak: isBreak, + mode: mode, + ); + final attributes = {'studentName': studentName, 'schoolName': schoolName}; + + final result = await _channel.invokeMethod('startActivity', { + 'attributes': jsonEncode(attributes), + 'contentState': jsonEncode(contentState), + }); + + if (result is String) { + _activityId = result; + _isActivityActive = true; + _logger.info('LiveActivity started with ID: $_activityId'); + return true; + } + return false; + } catch (e) { + _logger.warning('Failed to start LiveActivity: $e'); + return false; + } + } + + static Future updateActivity({ + required Lesson currentLesson, + Lesson? nextLesson, + bool isBreak = false, + String? mode, + }) async { + if (!Platform.isIOS) return false; + await _syncActivityState(); + if (!_isActivityActive || _activityId == null) { + _logger.warning('Cannot update: No active Live Activity found.'); + return false; + } + + try { + final contentState = _createContentState( + currentLesson: currentLesson, + nextLesson: nextLesson, + isBreak: isBreak, + mode: mode, + ); + await _channel.invokeMethod('updateActivity', { + 'activityId': _activityId, + 'contentState': jsonEncode(contentState), + }); + _logger.info('LiveActivity updated.'); + return true; + } catch (e) { + _logger.warning('Failed to update LiveActivity: $e'); + return false; + } + } + + static Future endActivity() async { + if (!Platform.isIOS) return; + await _syncActivityState(); + if (!_isActivityActive || _activityId == null) return; + + try { + await _channel.invokeMethod('endActivity', {'activityId': _activityId}); + _activityId = null; + _isActivityActive = false; + _logger.info('LiveActivity ended.'); + } catch (e) { + _logger.warning('Failed to end LiveActivity: $e'); + } + } + + static Future> getActiveActivities() async { + if (!Platform.isIOS) return []; + try { + final result = await _channel.invokeMethod('getActiveActivities'); + return (result as List?)?.cast() ?? []; + } catch (e) { + _logger.warning('Failed to get active activities: $e'); + return []; + } + } + + static Future endAllActivities() async { + if (!Platform.isIOS) return; + try { + await _channel.invokeMethod('endAllActivities'); + _activityId = null; + _isActivityActive = false; + _logger.info('All LiveActivities ended.'); + } catch (e) { + _logger.warning('Failed to end all activities: $e'); + } + } + + static Map _createContentState({ + required Lesson currentLesson, + Lesson? nextLesson, + bool isBreak = false, + String? mode, + }) { + final now = DateTime.now(); + final isBeforeSchool = mode == 'beforeSchool'; + + DateTime startTimeForActivity; + DateTime endTimeForActivity; + + if (isBeforeSchool) { + startTimeForActivity = now; + endTimeForActivity = currentLesson.start; + } else { + startTimeForActivity = currentLesson.start; + endTimeForActivity = currentLesson.end; + } + + final nextStartTimeForActivity = nextLesson?.start; + + final payload = { + 'isBreak': isBeforeSchool ? false : isBreak, + 'lessonName': isBeforeSchool ? currentLesson.name : (isBreak ? 'Szünet' : currentLesson.name), + 'lessonTheme': (isBeforeSchool || isBreak) ? null : currentLesson.theme, + 'roomName': (isBeforeSchool || isBreak) ? null : currentLesson.roomName, + 'teacherName': (isBeforeSchool || isBreak) ? null : currentLesson.teacher, + 'startTime': startTimeForActivity.toUtc().toIso8601String(), + 'endTime': endTimeForActivity.toUtc().toIso8601String(), + 'lessonNumber': (isBeforeSchool || isBreak) ? null : currentLesson.lessonNumber, + 'nextLessonName': isBeforeSchool ? null : nextLesson?.name, + 'nextRoomName': isBeforeSchool ? null : nextLesson?.roomName, + 'nextStartTime': nextStartTimeForActivity?.toUtc().toIso8601String(), + 'isSubstitution': currentLesson.substituteTeacher != null, + 'isCancelled': currentLesson.state.name?.toLowerCase().contains('elmarad') ?? false, + 'substituteTeacher': currentLesson.substituteTeacher, + 'currentTime': now.toUtc().toIso8601String(), + 'mode': mode, + }; + return payload; + } + + static _ActivityState _findCurrentActivityState(List lessons, DateTime now) { + lessons.sort((a, b) => a.start.compareTo(b.start)); + + for (int i = 0; i < lessons.length; i++) { + final lesson = lessons[i]; + final lessonStart = DateTime(now.year, now.month, now.day, lesson.start.hour, lesson.start.minute); + final lessonEnd = DateTime(now.year, now.month, now.day, lesson.end.hour, lesson.end.minute); + + if (i == 0 && now.isBefore(lessonStart)) { + if (lessonStart.difference(now).inHours < 2) { + return _ActivityState(currentLesson: lesson, mode: 'beforeSchool'); + } + } + + if (now.isAfter(lessonStart) && now.isBefore(lessonEnd)) { + final correctedLesson = Lesson( + uid: lesson.uid, + date: lesson.date, + start: lessonStart, + end: lessonEnd, + name: lesson.name, + type: lesson.type, + state: lesson.state, + lessonNumber: lesson.lessonNumber, + roomName: lesson.roomName, + teacher: lesson.teacher, + theme: lesson.theme, + substituteTeacher: lesson.substituteTeacher, + canStudentEditHomework: lesson.canStudentEditHomework, + isHomeworkComplete: lesson.isHomeworkComplete, + attachments: lesson.attachments, + isDigitalLesson: lesson.isDigitalLesson, + digitalSupportDeviceTypeList: lesson.digitalSupportDeviceTypeList, + createdAt: lesson.createdAt, + lastModifiedAt: lesson.lastModifiedAt + ); + + final nextLesson = i + 1 < lessons.length ? lessons[i + 1] : null; + Lesson? correctedNextLesson; + if (nextLesson != null) { + correctedNextLesson = Lesson( + uid: nextLesson.uid, date: nextLesson.date, + start: DateTime(now.year, now.month, now.day, nextLesson.start.hour, nextLesson.start.minute), + end: DateTime(now.year, now.month, now.day, nextLesson.end.hour, nextLesson.end.minute), + name: nextLesson.name, type: nextLesson.type, state: nextLesson.state, + lessonNumber: nextLesson.lessonNumber, roomName: nextLesson.roomName, teacher: nextLesson.teacher, + theme: nextLesson.theme, substituteTeacher: nextLesson.substituteTeacher, + canStudentEditHomework: nextLesson.canStudentEditHomework, isHomeworkComplete: nextLesson.isHomeworkComplete, + attachments: nextLesson.attachments, isDigitalLesson: nextLesson.isDigitalLesson, + digitalSupportDeviceTypeList: nextLesson.digitalSupportDeviceTypeList, + createdAt: nextLesson.createdAt, lastModifiedAt: nextLesson.lastModifiedAt + ); + } + + return _ActivityState( + currentLesson: correctedLesson, + nextLesson: correctedNextLesson, + ); + } + + if (i + 1 < lessons.length) { + final nextLesson = lessons[i + 1]; + final nextLessonStart = DateTime(now.year, now.month, now.day, nextLesson.start.hour, nextLesson.start.minute); + if (now.isAfter(lessonEnd) && now.isBefore(nextLessonStart)) { + final breakLesson = Lesson( + uid: 'break-${lesson.uid}', + date: lesson.date, + start: lessonEnd, + end: nextLessonStart, + name: 'Szünet', + type: lesson.type, + state: lesson.state, + canStudentEditHomework: false, + isHomeworkComplete: false, + attachments: [], + isDigitalLesson: false, + digitalSupportDeviceTypeList: [], + createdAt: now, + lastModifiedAt: now, + ); + return _ActivityState(currentLesson: breakLesson, nextLesson: nextLesson, isBreak: true); + } + } + } + + if (lessons.isNotEmpty) { + final lastLesson = lessons.last; + final lastLessonEnd = DateTime(now.year, now.month, now.day, lastLesson.end.hour, lastLesson.end.minute); + final afterSchoolBreakEnd = lastLessonEnd.add(const Duration(minutes: 15)); + + if (now.isAfter(lastLessonEnd) && now.isBefore(afterSchoolBreakEnd)) { + final breakLesson = Lesson( + uid: 'break-after-school', + date: lastLesson.date, + start: lastLessonEnd, + end: afterSchoolBreakEnd, + name: 'Szünet', + type: lastLesson.type, + state: lastLesson.state, + canStudentEditHomework: false, + isHomeworkComplete: false, + attachments: [], + isDigitalLesson: false, + digitalSupportDeviceTypeList: [], + createdAt: now, + lastModifiedAt: now, + ); + return _ActivityState(currentLesson: breakLesson, isBreak: true); + } + } + + return _ActivityState(); + } + + static Future updateActivityFromTimetable( + List todayLessons, + String studentName, + String schoolName, + ) async { + if (!Platform.isIOS) return; + + final now = DateTime.now(); + _logger.info("Checking for activity update at ${now.toIso8601String()}"); + + final lessons = todayLessons.where((lesson) { + final type = lesson.type.name?.toLowerCase() ?? ''; + return !(lesson.state.name?.toLowerCase().contains('törölt') ?? false) && + !type.contains('tanevrendje'); + }).toList(); + + if (lessons.isEmpty) { + _logger.info("No relevant lessons today. Ending activity if running."); + await endAllActivities(); + return; + } + + final state = _findCurrentActivityState(lessons, now); + + _logger.info("Current state: lesson=${state.currentLesson?.name}, break=${state.isBreak}, mode=${state.mode}"); + + await _syncActivityState(); + + if (state.currentLesson != null) { + if (_isActivityActive) { + _logger.info("Updating existing activity."); + await updateActivity( + currentLesson: state.currentLesson!, + nextLesson: state.nextLesson, + isBreak: state.isBreak, + mode: state.mode, + ); + } else { + _logger.info("Starting new activity."); + await startActivity( + studentName: studentName, + schoolName: schoolName, + currentLesson: state.currentLesson!, + nextLesson: state.nextLesson, + isBreak: state.isBreak, + mode: state.mode, + ); + } + } else { + _logger.info("No current lesson or break. Ending activity if running."); + await endAllActivities(); + } + } +} diff --git a/firka/lib/helpers/live_activity_service.dart b/firka/lib/helpers/live_activity_service.dart new file mode 100644 index 0000000..51bb930 --- /dev/null +++ b/firka/lib/helpers/live_activity_service.dart @@ -0,0 +1,598 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:firka/helpers/api/client/kreta_client.dart'; +import 'package:firka/helpers/api/client/live_activity_backend_client.dart'; +import 'package:firka/helpers/api/model/generic.dart'; +import 'package:firka/helpers/api/model/timetable.dart'; +import 'package:firka/helpers/live_activity_manager.dart'; +import 'package:firka/helpers/settings.dart'; +import 'package:logging/logging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../main.dart'; + +/// Service that coordinates LiveActivity functionality +/// Handles timetable synchronization, device token management, and activity updates +class LiveActivityService { + static final Logger _logger = Logger('LiveActivityService'); + static final LiveActivityBackendClient _backendClient = LiveActivityBackendClient(); + + static const String _deviceTokenKey = 'live_activity_device_token'; + static const String _lastTimetableUpdateKey = 'live_activity_last_update'; + static const String _isRegisteredKey = 'live_activity_is_registered'; + + static Timer? _updateTimer; + static String? _cachedDeviceToken; + static bool _isInitialized = false; + + /// Get current language code from settings + static String? _getCurrentLanguageCode() { + try { + if (!initDone || initData.settings == null) { + return 'hu'; + } + + final languageSetting = initData.settings.group("settings") + .subGroup("application")["language"] as SettingsItemsRadio?; + + if (languageSetting == null) return 'hu'; + + switch (languageSetting.activeIndex) { + case 1: return 'hu'; + case 2: return 'en'; + case 3: return 'de'; + default: // auto + final systemLang = Platform.localeName.split('_').first; + if (['hu', 'en', 'de'].contains(systemLang)) { + return systemLang; + } + return 'hu'; + } + } catch (e) { + _logger.warning('Error getting current language: $e'); + return 'hu'; + } + } + + /// Update language preference on backend for Live Activity localization + static Future updateLanguagePreference(String languageCode) async { + try { + if (!Platform.isIOS) return; + + final prefs = await SharedPreferences.getInstance(); + final deviceToken = prefs.getString(_deviceTokenKey); + + if (deviceToken == null) { + _logger.warning('Cannot update language: device token not found'); + return; + } + + final success = await _backendClient.updateLanguage( + deviceToken: deviceToken, + language: languageCode, + ); + + if (success) { + _logger.info('Language preference updated to $languageCode'); + } else { + _logger.warning('Failed to update language preference'); + } + } catch (e) { + _logger.severe('Error updating language preference: $e'); + } + } + + /// Initialize the LiveActivity service + static Future initialize() async { + if (!Platform.isIOS) { + return; + } + + _isInitialized = false; + _cachedDeviceToken = null; + + try { + await LiveActivityManager.initialize(); + + LiveActivityManager.setOnPushTokenReceived(_onPushTokenReceived); + + final deviceToken = await LiveActivityManager.registerForPushNotifications(); + + if (deviceToken != null) { + _cachedDeviceToken = deviceToken; + await _saveDeviceToken(deviceToken); + } + + _isInitialized = true; + } catch (e, stackTrace) { + _logger.severe('Failed to initialize LiveActivity: $e', e, stackTrace); + } + } + + /// Check if LiveActivity is enabled in settings + static Future isEnabled([SettingsStore? settingsStore]) async { + try { + if (settingsStore == null) { + return false; + } + + final enabled = settingsStore + .group("settings") + .subGroup("application")["live_activity_enabled"] as SettingsBoolean; + return enabled.value; + } catch (e) { + _logger.warning('Error reading LiveActivity setting: $e'); + return false; + } + } + + /// Handle LiveActivity enabled state change + /// Called from settings toggle callback - does NOT save settings (already saved) + static Future handleEnabledChange(bool enabled) async { + if (!Platform.isIOS) return; + + try { + if (!enabled) { + await LiveActivityManager.endAllActivities(); + _stopTimetableMonitoring(); + await _clearCache(); + _logger.info('LiveActivity disabled - all activities ended'); + } else { + final studentResp = await initData.client.getStudent(); + final studentName = studentResp.response?.name ?? initData.tokens.first.studentId ?? "Student"; + + await onUserLogin( + client: initData.client, + studentName: studentName, + settingsStore: initData.settings, + ); + } + } catch (e) { + _logger.warning('Error handling LiveActivity enabled change: $e'); + } + } + + /// Handle token expiration - deactivate LiveActivity + static Future onTokenExpired() async { + if (!Platform.isIOS) return; + + try { + _logger.info('Token expired, deactivating LiveActivity'); + + await LiveActivityManager.endAllActivities(); + + _stopTimetableMonitoring(); + + } catch (e) { + _logger.severe('Error handling token expiration for LiveActivity: $e'); + } + } + + /// Handle LiveActivity push token received from Swift side + static Future _onPushTokenReceived(String activityId, String pushToken) async { + _logger.info('LiveActivity push token received, updating backend...'); + + try { + final deviceToken = _cachedDeviceToken ?? await LiveActivityManager.getDeviceToken(); + if (deviceToken == null) { + _logger.warning('No device token available to update push token'); + return; + } + + final success = await _backendClient.updatePushToken( + deviceToken: deviceToken, + pushToken: pushToken, + ); + + if (success) { + _logger.info('LiveActivity push token updated successfully in backend'); + } else { + _logger.warning('Failed to update LiveActivity push token in backend'); + } + } catch (e) { + _logger.severe('Error updating LiveActivity push token: $e'); + } + } + + /// Called when user logs in successfully + /// Registers the device and uploads the *full* timetable + static Future onUserLogin({ + required KretaClient client, + required String studentName, + SettingsStore? settingsStore, + }) async { + if (!Platform.isIOS || !_isInitialized) { + return; + } + + final enabled = await isEnabled(settingsStore); + + if (!enabled) { + return; + } + + try { + final now = DateTime.now(); + final startOfWeek = now.subtract(Duration(days: now.weekday - 1)); + final endOfWeek = startOfWeek.add(const Duration(days: 6)); + + final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek); + + final allLessons = timetableResponse.response ?? []; + + + if (allLessons.isEmpty) { + return; + } + + final deviceToken = await _getOrWaitDeviceToken(); + + if (deviceToken == null) { + return; + } + + /*final apnsTokenSuccess = await _backendClient.updateApnsToken( + deviceToken: deviceToken, + apnsPushToken: deviceToken, + );*/ + + String? currentLanguage = _getCurrentLanguageCode(); + + final success = await _backendClient.registerDevice( + deviceToken: deviceToken, + timetable: allLessons, + language: currentLanguage, + ); + + if (success) { + await _markAsRegistered(); + await _saveLastUpdate(); + + await _startPlaceholderActivity(allLessons, studentName); + + await _startTimetableMonitoring( + client: client, + studentName: studentName, + settingsStore: settingsStore, + ); + _logger.info('LiveActivity registration completed for $studentName'); + } else { + _logger.warning('Failed to register device with backend'); + } + } catch (e, st) { + _logger.severe('Error during onUserLogin: $e', e, st); + } + } + + /// Called when app is opened - sends timetable to backend, backend handles updates + static Future onAppOpened({ + required KretaClient client, + required String studentName, + SettingsStore? settingsStore, + }) async { + if (!Platform.isIOS || !_isInitialized) return; + + try { + final enabled = await isEnabled(settingsStore); + if (!enabled) { + _logger.info('LiveActivity is disabled, ending any running activities'); + await LiveActivityManager.endAllActivities(); + return; + } + + final activeActivities = await LiveActivityManager.getActiveActivities(); + if (activeActivities.isNotEmpty) { + _logger.info('Activity already running, sending timetable update to backend.'); + await checkAndUpdateTimetable( + client: client, + studentName: studentName, + settingsStore: settingsStore + ); + return; + } + + final now = DateTime.now(); + final startOfWeek = now.subtract(Duration(days: now.weekday - 1)); + final endOfWeek = startOfWeek.add(const Duration(days: 6)); + final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek); + final allLessons = timetableResponse.response ?? []; + + await _startPlaceholderActivity(allLessons, studentName); + + await checkAndUpdateTimetable( + client: client, + studentName: studentName, + settingsStore: settingsStore + ); + + } catch (e) { + _logger.severe('Error handling onAppOpened for LiveActivity: $e'); + } + } + + /// Called when user logs out + static Future onUserLogout() async { + if (!Platform.isIOS) return; + + try { + await LiveActivityManager.endAllActivities(); + + final deviceToken = _cachedDeviceToken ?? await LiveActivityManager.getDeviceToken(); + if (deviceToken != null) { + await _backendClient.unregisterDevice(deviceToken: deviceToken); + } + + await _clearCache(); + + _stopTimetableMonitoring(); + + _logger.info('User logout processed for LiveActivity'); + } catch (e) { + _logger.severe('Error processing user logout for LiveActivity: $e'); + } + } + + /// Check for timetable changes and update if necessary + static Future checkAndUpdateTimetable({ + required KretaClient client, + required String studentName, + SettingsStore? settingsStore, + }) async { + if (!Platform.isIOS || !_isInitialized) return; + + final enabled = await isEnabled(settingsStore); + if (!enabled) { + return; + } + + try { + final now = DateTime.now(); + final startOfWeek = now.subtract(Duration(days: now.weekday - 1)); + final endOfWeek = startOfWeek.add(const Duration(days: 6)); + + final timetableResponse = await client.getTimeTable(startOfWeek, endOfWeek); + final allLessons = timetableResponse.response ?? []; + + if (allLessons.isEmpty) { + await LiveActivityManager.endAllActivities(); + return; + } + + final deviceToken = await _getOrWaitDeviceToken(); + if (deviceToken == null) return; + + final lastUpdate = await _getLastUpdate(); + final hasChanges = await _backendClient.checkTimetableChanges( + deviceToken: deviceToken, + lastUpdated: lastUpdate, + ); + + if (hasChanges) { + _logger.info('Timetable changes detected, sending to backend...'); + + final success = await _backendClient.updateTimetable( + deviceToken: deviceToken, + timetable: allLessons, + ); + + if (success) { + await _saveLastUpdate(); + _logger.info('Timetable sent to backend successfully. Backend will update LiveActivity.'); + } + } + } catch (e) { + _logger.severe('Error checking and updating timetable: $e'); + } + } + + /// Start monitoring timetable and updating LiveActivity + static Future _startTimetableMonitoring({ + required KretaClient client, + required String studentName, + SettingsStore? settingsStore, + }) async { + _stopTimetableMonitoring(); + + await checkAndUpdateTimetable( + client: client, + studentName: studentName, + settingsStore: settingsStore, + ); + + // Pediódikus frissítés (minden 30 percben) + // Ez azért kell, hogy a KRETA változásokat észleljük, + // és összevessük az adatbázisban tárolt adatokkal. + _updateTimer = Timer.periodic( + const Duration(minutes: 30), + (timer) async { + await checkAndUpdateTimetable( + client: client, + studentName: studentName, + settingsStore: settingsStore, + ); + }, + ); + + _logger.info('Timetable monitoring started'); + } + + /// Stop monitoring timetable + static void _stopTimetableMonitoring() { + _updateTimer?.cancel(); + _updateTimer = null; + _logger.info('Timetable monitoring stopped'); + } + + /// Force update LiveActivity with latest data + static Future forceUpdate({ + required KretaClient client, + required String studentName, + }) async { + await checkAndUpdateTimetable( + client: client, + studentName: studentName, + ); + } + + /// Starts a minimal placeholder activity shell - backend will update with real data + static Future _startPlaceholderActivity(List allLessons, String studentName) async { + final activeActivities = await LiveActivityManager.getActiveActivities(); + if (activeActivities.isNotEmpty) { + _logger.info('_startPlaceholderActivity: Activity already running.'); + return; + } + + _logger.info('_startPlaceholderActivity: Creating minimal loading shell, backend will update.'); + + final now = DateTime.now(); + + Lesson placeholderLesson; + if (allLessons.isNotEmpty) { + final template = allLessons.first; + placeholderLesson = Lesson( + uid: 'loading-placeholder', + date: now.toIso8601String(), + start: now, + end: now.add(const Duration(minutes: 1)), + name: 'Betöltés...', + type: template.type, + state: template.state, + canStudentEditHomework: false, + isHomeworkComplete: false, + attachments: [], + isDigitalLesson: false, + digitalSupportDeviceTypeList: [], + createdAt: now, + lastModifiedAt: now, + ); + } else { + final emptyType = NameUidDesc(uid: 'placeholder', name: 'Placeholder', description: null); + final emptyState = NameUidDesc(uid: 'active', name: 'Active', description: null); + + placeholderLesson = Lesson( + uid: 'loading-placeholder', + date: now.toIso8601String(), + start: now, + end: now.add(const Duration(minutes: 1)), + name: 'Betöltés...', + type: emptyType, + state: emptyState, + canStudentEditHomework: false, + isHomeworkComplete: false, + attachments: [], + isDigitalLesson: false, + digitalSupportDeviceTypeList: [], + createdAt: now, + lastModifiedAt: now, + ); + } + + await LiveActivityManager.startActivity( + studentName: studentName, + schoolName: 'Iskola', + currentLesson: placeholderLesson, + isBreak: false, + mode: 'loading', + ); + + _logger.info('_startPlaceholderActivity: Placeholder created, waiting for backend update.'); + } + + static Future _saveDeviceToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_deviceTokenKey, token); + } + + static Future _getDeviceToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_deviceTokenKey); + } + + static Future _saveLastUpdate() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _lastTimetableUpdateKey, + DateTime.now().toIso8601String(), + ); + } + + static Future _getLastUpdate() async { + final prefs = await SharedPreferences.getInstance(); + final lastUpdateStr = prefs.getString(_lastTimetableUpdateKey); + if (lastUpdateStr != null) { + return DateTime.parse(lastUpdateStr); + } + return DateTime.now().subtract(const Duration(days: 365)); + } + + static Future _markAsRegistered() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_isRegisteredKey, true); + } + + /*static Future _isRegistered() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_isRegisteredKey) ?? false; + }*/ + + static Future _clearCache() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_deviceTokenKey); + await prefs.remove(_lastTimetableUpdateKey); + await prefs.remove(_isRegisteredKey); + _cachedDeviceToken = null; + } + + /// Try to get cached token or wait a short period until iOS provides it + static Future _getOrWaitDeviceToken({Duration timeout = const Duration(seconds: 5)}) async { + if (_cachedDeviceToken != null) { + return _cachedDeviceToken; + } + + final saved = await _getDeviceToken(); + if (saved != null) { + _cachedDeviceToken = saved; + return saved; + } + + final start = DateTime.now(); + String? token = await LiveActivityManager.getDeviceToken(); + while (token == null && DateTime.now().difference(start) < timeout) { + await Future.delayed(const Duration(milliseconds: 300)); + token = await LiveActivityManager.getDeviceToken(); + } + if (token != null) { + _cachedDeviceToken = token; + await _saveDeviceToken(token); + } + return token; + } + + /// Send a test notification (for debugging) + static Future sendTestNotification() async { + if (!Platform.isIOS || !_isInitialized) return false; + + try { + final deviceToken = await _getOrWaitDeviceToken(); + if (deviceToken == null) { + _logger.warning('No device token available for test notification'); + return false; + } + + final success = await _backendClient.sendTestNotification( + deviceToken: deviceToken, + ); + + if (success) { + _logger.info('Test notification sent successfully'); + } else { + _logger.warning('Failed to send test notification'); + } + + return success; + } catch (e) { + _logger.severe('Error sending test notification: $e'); + return false; + } + } +} \ No newline at end of file diff --git a/firka/lib/helpers/settings.dart b/firka/lib/helpers/settings.dart index 68759af..1754e18 100644 --- a/firka/lib/helpers/settings.dart +++ b/firka/lib/helpers/settings.dart @@ -3,6 +3,7 @@ import 'dart:core'; import 'dart:io'; import 'package:firka/helpers/db/models/app_settings_model.dart'; +import 'package:firka/helpers/live_activity_service.dart'; import 'package:firka/l10n/app_localizations.dart'; import 'package:firka/ui/widget/firka_icon.dart'; import 'package:flutter/cupertino.dart'; @@ -30,6 +31,7 @@ const statsForNerds = 1015; const developerOptsEnabled = 1016; const themeBrightness = 1017; const ttToastSubstitution = 1018; +const liveActivityEnabled = 1019; bool always() { return true; @@ -48,10 +50,18 @@ bool isAndroid() { return Platform.isAndroid; } +bool isIOS() { + return Platform.isIOS; +} + bool isDebug() { return kDebugMode; } +bool isDebugIOS() { + return kDebugMode && Platform.isIOS; +} + class SettingsStore { LinkedHashMap items = LinkedHashMap.of({}); @@ -113,6 +123,30 @@ class SettingsStore { null), "left_handed_mode": SettingsBoolean(leftHandedMode, null, null, l10n.s_ag_left_handed_mode, false, never), + "live_activity_enabled": SettingsBoolean( + liveActivityEnabled, + FirkaIconType.majesticons, + Majesticon.clockSolid, + "LiveActivity", + true, + isIOS, + () async { + final setting = initData.settings + .group("settings") + .subGroup("application")["live_activity_enabled"] as SettingsBoolean; + + final enabled = setting.value; + await LiveActivityService.handleEnabledChange(enabled); + }), + "test_notification": SettingsButton( + 0, + FirkaIconType.majesticons, + Majesticon.bellSolid, + "Teszt értesítés küldése", + isDebugIOS, + () async { + await LiveActivityService.sendTestNotification(); + }), "language_header": SettingsHeaderSmall(0, l10n.s_ag_language_header, always), "language": SettingsItemsRadio( @@ -873,7 +907,11 @@ class SettingsBoolean implements SettingsItem { bool defaultValue; SettingsBoolean(this.key, this.iconType, this.iconData, this.title, - this.defaultValue, this.visibilityProvider); + this.defaultValue, this.visibilityProvider, [Future Function()? postUpdateFn]) { + if (postUpdateFn != null) { + postUpdate = postUpdateFn; + } + } @override Future load(IsarCollection model) async { @@ -1039,3 +1077,27 @@ class SettingsString implements SettingsItem { initData.settingsUpdateNotifier.update(); } } + +class SettingsButton implements SettingsItem { + @override + Id key; + @override + FirkaIconType? iconType; + @override + Object? iconData; + @override + bool Function() visibilityProvider; + @override + Future Function() postUpdate = () async {}; + String title; + Future Function() onTap; + + SettingsButton(this.key, this.iconType, this.iconData, this.title, + this.visibilityProvider, this.onTap); + + @override + Future load(IsarCollection model) async {} + + @override + Future save(IsarCollection model) async {} +} diff --git a/firka/lib/helpers/ui/common_bottom_sheets.dart b/firka/lib/helpers/ui/common_bottom_sheets.dart index d5913e7..7b4f22c 100644 --- a/firka/lib/helpers/ui/common_bottom_sheets.dart +++ b/firka/lib/helpers/ui/common_bottom_sheets.dart @@ -50,6 +50,7 @@ Future showLessonBottomSheet( Widget statsForNerds = SizedBox(); final y2k = DateTime(2000, 1); + if (statsForNerdsEnabled) { final stats = "${data.l10n.stats_date}: ${lesson.start.isAfter(y2k) ? lesson.start.format(data.l10n, FormatMode.yyyymmddhhmmss) : "N/A"}\n" @@ -394,7 +395,7 @@ Future showTestBottomSheet( SizedBox( width: MediaQuery.of(context).size.width * 0.7, child: Text( - "${data.l10n.date}: $formattedDate", + "${data.l10n.data}: $formattedDate", style: appStyle.fonts.B_16R .apply(color: appStyle.colors.textPrimary), maxLines: 3, diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 455ea95..3815800 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -31,9 +31,11 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:watch_connectivity/watch_connectivity.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'helpers/db/models/homework_cache_model.dart'; import 'helpers/update_notifier.dart'; +import 'helpers/live_activity_service.dart'; import 'l10n/app_localizations.dart'; import 'l10n/app_localizations_de.dart'; import 'l10n/app_localizations_en.dart'; @@ -107,33 +109,50 @@ Future initDB() async { return isarInit!; } -void initLang(AppInitialization data) { +Future initLang(AppInitialization data) async { + String? languageCode; + switch ((data.settings.group("settings").subGroup("application")["language"] as SettingsItemsRadio) .activeIndex) { case 1: // hu data.l10n = AppLocalizationsHu(); + languageCode = 'hu'; break; case 2: // en data.l10n = AppLocalizationsEn(); + languageCode = 'en'; break; case 3: // de data.l10n = AppLocalizationsDe(); + languageCode = 'de'; break; default: // auto switch (ui.window.locale.languageCode) { case 'hu': data.l10n = AppLocalizationsHu(); + languageCode = 'hu'; break; case 'en': data.l10n = AppLocalizationsEn(); + languageCode = 'en'; break; case 'de': data.l10n = AppLocalizationsDe(); + languageCode = 'de'; break; } break; } + + // Update language preference on backend for Live Activity localization + if (languageCode != null && Platform.isIOS) { + try { + await LiveActivityService.updateLanguagePreference(languageCode); + } catch (e) { + logger.warning('Failed to update language preference on backend: $e'); + } + } } void initTheme(AppInitialization data) { @@ -188,6 +207,20 @@ Future _initData(AppInitialization init) async { init.client = KretaClient(token, init.isar); await WidgetCacheHelper.updateWidgetCache(appStyle, init.client); + + if (Platform.isIOS) { + try { + final studentResp = await init.client.getStudent(); + final studentName = studentResp.response?.name ?? token.studentId ?? "Student"; + await LiveActivityService.onUserLogin( + client: init.client, + studentName: studentName, + settingsStore: init.settings, + ); + } catch (e, st) { + logger.severe('LiveActivity registration failed: $e', e, st); + } + } } final dataDir = await getApplicationDocumentsDirectory(); @@ -241,6 +274,15 @@ Future initializeApp() async { l10n: AppLocalizationsHu(), ); + if (Platform.isIOS) { + try { + await LiveActivityService.initialize(); + + } catch (e, st) { + logger.severe('Failed to initialize LiveActivity: $e', e, st); + } + } + await _initData(init); init.settingsUpdateNotifier.addListener(() { @@ -260,6 +302,10 @@ void main() async { logger.finest("Initializing app"); WidgetsFlutterBinding.ensureInitialized(); + // Load environment variables from .env file + await dotenv.load(fileName: ".env"); + logger.info("Environment variables loaded"); + { final jwtPattern = RegExp(r'([A-Za-z0-9-_]+)\.([A-Za-z0-9-_]+)\.([A-Za-z0-9-_]+)'); @@ -498,6 +544,14 @@ class InitializationScreen extends StatelessWidget { key: ValueKey('loginScreen'), ), ), + '/home': (context) => DefaultAssetBundle( + bundle: FirkaBundle(), + child: HomeScreen( + initData, + false, + key: ValueKey('homeScreen'), + ), + ), '/debug': (context) => DefaultAssetBundle( bundle: FirkaBundle(), child: DebugScreen( diff --git a/firka/pubspec.yaml b/firka/pubspec.yaml index 96702a5..08787c0 100644 --- a/firka/pubspec.yaml +++ b/firka/pubspec.yaml @@ -21,7 +21,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.8+1018 environment: - sdk: ">=3.6.0 <=3.9.0" + sdk: ">=3.6.0 <=3.9.2" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -58,23 +58,28 @@ dependencies: flutter_screenutil: ^5.9.3 flutter_arc_text: ^0.6.0 flutter_svg: ^1.1.6 + fmb_dart: + path: vendor/fmb_dart home_widget: ^0.8.0 brotli: ^0.6.0 crypto: ^3.0.6 transparent_pointer: ^1.0.1 flutter_staggered_grid_view: ^0.7.0 - package_info_plus: ^8.3.1 + package_info_plus: ^9.0.0 smart_scroll: ^1.0.0 live_activities: ^2.4.1 logging: ^1.3.0 - share_plus: ^11.1.0 + share_plus: ^12.0.0 url_launcher: ^6.3.2 + shared_preferences: ^2.3.4 + flutter_dotenv: ^5.2.1 xml: ^6.6.1 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + analyzer: ^5.13.0 isar_generator: path: vendor/isar_generator android_notification_icons: ^0.0.1 @@ -93,6 +98,7 @@ flutter: generate: true uses-material-design: true assets: + - .env - assets/images/logos/colored_logo.webp - assets/images/logos/dave.svg - assets/images/carousel/ diff --git a/firka/vendor/fmb_dart/.gitignore b/firka/vendor/fmb_dart/.gitignore new file mode 100644 index 0000000..13faa94 --- /dev/null +++ b/firka/vendor/fmb_dart/.gitignore @@ -0,0 +1,9 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock +.flutter-plugins +.flutter-plugins-dependencies diff --git a/firka/vendor/fmb_dart/CHANGELOG.md b/firka/vendor/fmb_dart/CHANGELOG.md new file mode 100644 index 0000000..b88a75b --- /dev/null +++ b/firka/vendor/fmb_dart/CHANGELOG.md @@ -0,0 +1,3 @@ +## 5.0.0 + +- Initial version. diff --git a/firka/vendor/fmb_dart/LICENSE b/firka/vendor/fmb_dart/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/firka/vendor/fmb_dart/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/firka/vendor/fmb_dart/README.md b/firka/vendor/fmb_dart/README.md new file mode 100644 index 0000000..ae3c609 --- /dev/null +++ b/firka/vendor/fmb_dart/README.md @@ -0,0 +1,30 @@ +# FMBCrypt Dart Library 5 +Pretty easy to install and secure, I think... + +## Usage: +**Encryption**: +```dart +await FMBCrypt.handleText('encrypt', plaintext, password); +``` +**Decryption**: +```dart +await FMBCrypt.handleText('decrypt', ciphertext, password); +``` + +## Under the hood +### Key generation +1. Generates a random 32-byte salt +2. Convert password into bytes with UTF-8 encoding +3. PBKDF2 the password (SHA-512, 1,000,000x, 256 bits, salt) +4. Secure the final 32-byte key +### Encryption +1. Generates a 12-byte nonce for GCM +2. Creates associated data (application ID + version + salt + timestamp) for authentication +3. Sets up AES-256 in GCM mode (authenticated encryption) +4. Encrypt bytes using AES-GCM with associated data +5. Combine the data in the following order: + - First 4 bytes: Version header ("FMB5") + - Next 32 bytes: Salt + - Next 8 bytes: Timestamp + - Next 12 bytes: GCM Nonce + - Remaining: Encrypted data + 16-byte authentication tag diff --git a/firka/vendor/fmb_dart/analysis_options.yaml b/firka/vendor/fmb_dart/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/firka/vendor/fmb_dart/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/firka/vendor/fmb_dart/lib/fmb_dart.dart b/firka/vendor/fmb_dart/lib/fmb_dart.dart new file mode 100644 index 0000000..1a038cc --- /dev/null +++ b/firka/vendor/fmb_dart/lib/fmb_dart.dart @@ -0,0 +1,3 @@ +library; + +export 'src/fmb_dart_base.dart'; diff --git a/firka/vendor/fmb_dart/lib/src/fmb_dart_base.dart b/firka/vendor/fmb_dart/lib/src/fmb_dart_base.dart new file mode 100644 index 0000000..c592799 --- /dev/null +++ b/firka/vendor/fmb_dart/lib/src/fmb_dart_base.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:cryptography/cryptography.dart'; +import 'package:cryptography/helpers.dart'; + + +/// usage: +/// +/// String encrypted = await FMBCrypt.handleText('encrypt', 'Hello World', 'myPassword'); +/// +/// String decrypted = await FMBCrypt.handleText('decrypt', encrypted, 'myPassword'); +class FMBCrypt { + static const String _version = "FMB5"; + static const int _saltLength = 32; + static const int _nonceLength = 12; + static const int _timestampLength = 8; + static const int _gcmTagLength = 16; + + static Future<({Uint8List key, Uint8List salt})> generateKey( + String password, Uint8List? providedSalt) async { + final salt = providedSalt ?? Uint8List(_saltLength); + if (providedSalt == null) { + fillBytesWithSecureRandom(salt); + } + + final pbkdf2 = Pbkdf2( + macAlgorithm: Hmac.sha512(), + iterations: 1000000, + bits: 256, + ); + + + final newSecretKey = await pbkdf2.deriveKeyFromPassword( + password: password, + nonce: salt, + ); + + final keyBytes = await newSecretKey.extractBytes(); + return (key: Uint8List.fromList(keyBytes), salt: salt); + } + + static Future encrypt(dynamic data, String password) async { + try { + final keyAndSalt = await generateKey(password, null); + final key = keyAndSalt.key; + final salt = keyAndSalt.salt; + + final aesGcm = AesGcm.with256bits(); + final secretKey = SecretKey(key); + + final nonce = Uint8List(_nonceLength); + fillBytesWithSecureRandom(nonce); + + final versionBytes = utf8.encode(_version); + final timestamp = Uint8List(_timestampLength); + final now = DateTime.now().millisecondsSinceEpoch; + for (int i = 0; i < _timestampLength; i++) { + timestamp[i] = (now >> (i * 8)) & 0xFF; + } + + final associatedData = + Uint8List(versionBytes.length + salt.length + timestamp.length); + associatedData.setAll(0, versionBytes); + associatedData.setAll(versionBytes.length, salt); + associatedData.setAll(versionBytes.length + salt.length, timestamp); + + final inputData = data is Uint8List ? data : utf8.encode(data); + + final cipherText = await aesGcm.encrypt( + inputData, + secretKey: secretKey, + nonce: nonce, + aad: associatedData, + ); + + final result = Uint8List(versionBytes.length + + salt.length + + timestamp.length + + nonce.length + + cipherText.cipherText.length + + cipherText.mac.bytes.length); + + result.setAll(0, versionBytes); + result.setAll(versionBytes.length, salt); + result.setAll(versionBytes.length + salt.length, timestamp); + result.setAll( + versionBytes.length + salt.length + timestamp.length, nonce); + result.setAll( + versionBytes.length + salt.length + timestamp.length + nonce.length, + cipherText.cipherText); + result.setAll( + versionBytes.length + + salt.length + + timestamp.length + + nonce.length + + cipherText.cipherText.length, + cipherText.mac.bytes); + + return result; + } catch (e) { + throw Exception("Encryption failed: \${e.toString()}"); + } + } + + static Future decrypt( + Uint8List encryptedData, String password) async { + try { + if (encryptedData.length < + _version.length + + _saltLength + + _timestampLength + + _nonceLength + + _gcmTagLength) { + throw Exception("Invalid encrypted data: too short"); + } + + final versionBytes = encryptedData.sublist(0, _version.length); + final version = utf8.decode(versionBytes); + if (version != _version) { + throw Exception("Invalid or unsupported file format"); + } + + final salt = + encryptedData.sublist(_version.length, _version.length + _saltLength); + final timestamp = encryptedData.sublist(_version.length + _saltLength, + _version.length + _saltLength + _timestampLength); + final nonce = encryptedData.sublist( + _version.length + _saltLength + _timestampLength, + _version.length + _saltLength + _timestampLength + _nonceLength); + final cipherTextWithTag = encryptedData.sublist( + _version.length + _saltLength + _timestampLength + _nonceLength); + + final associatedData = + Uint8List(versionBytes.length + salt.length + timestamp.length); + associatedData.setAll(0, versionBytes); + associatedData.setAll(versionBytes.length, salt); + associatedData.setAll(versionBytes.length + salt.length, timestamp); + + final keyAndSalt = await generateKey(password, salt); + final key = keyAndSalt.key; + + final aesGcm = AesGcm.with256bits(); + final secretKey = SecretKey(key); + + final secretBox = SecretBox( + cipherTextWithTag.sublist(0, cipherTextWithTag.length - _gcmTagLength), + nonce: nonce, + mac: Mac(cipherTextWithTag + .sublist(cipherTextWithTag.length - _gcmTagLength)), + ); + + final decrypted = await aesGcm.decrypt( + secretBox, + secretKey: secretKey, + aad: associatedData, + ); + + return Uint8List.fromList(decrypted); + } catch (e) { + throw Exception("Decryption failed: \${e.toString()}"); + } + } + + static Future handleText( + String action, String input, String password) async { + if (password.isEmpty || input.isEmpty) { + throw Exception("Please provide both input text and a password."); + } + + try { + if (action == "encrypt") { + final encrypted = await encrypt(input, password); + return base64.encode(encrypted); + } else if (action == "decrypt") { + final encryptedData = base64.decode(input); + final decrypted = await decrypt(encryptedData, password); + return utf8.decode(decrypted); + } else { + throw Exception("Invalid action. Use 'encrypt' or 'decrypt'."); + } + } catch (e) { + throw Exception("Operation failed: \${e.toString()}"); + } + } +} diff --git a/firka/vendor/fmb_dart/pubspec.yaml b/firka/vendor/fmb_dart/pubspec.yaml new file mode 100644 index 0000000..c7d2659 --- /dev/null +++ b/firka/vendor/fmb_dart/pubspec.yaml @@ -0,0 +1,15 @@ +name: fmb_dart +description: FMBCrypt Dart Library +version: 5.0.0 +repository: https://git.anchietae.cc/FMBCrypt/fmb_dart +publish_to: https://git.anchietae.cc/api/packages/FMBCrypt/pub + +environment: + sdk: ^3.6.0 + +dependencies: + cryptography: ^2.7.0 + +dev_dependencies: + lints: ^5.1.1 + test: ^1.24.0