8 Commits

49 changed files with 2216 additions and 759 deletions
@@ -167,14 +167,6 @@ object WidgetRenderer {
horizontalPaddingDp = 7,
)
}
maybeAddNowIndicator(
packageName,
views,
R.id.widget_day_grid,
hourHeightDp,
anchorDate = data.anchorDate,
periods = data.periods,
)
}
views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
@@ -283,16 +275,6 @@ object WidgetRenderer {
horizontalPaddingDp = 3,
)
}
if (WidgetDateUtils.isSameDay(day, Date())) {
maybeAddNowIndicator(
packageName,
views,
columnId,
hourHeightDp,
anchorDate = day,
periods = data.periods,
)
}
}
views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
@@ -644,34 +626,6 @@ object WidgetRenderer {
}
}
private fun maybeAddNowIndicator(
packageName: String,
parent: RemoteViews,
containerId: Int,
hourHeightDp: Float,
anchorDate: Date,
periods: List<WidgetPeriod>,
) {
if (!WidgetDateUtils.isSameDay(anchorDate, Date())) return
val now = Calendar.getInstance()
val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
if (periods.isNotEmpty()) {
if (nowMinutes < periods.first().startMinutes ||
nowMinutes > periods.last().endMinutes
) return
}
val virtualNow = realMinutesToVirtual(nowMinutes, periods)
val topDp = virtualNow * hourHeightDp / 60.0f
val indicator = RemoteViews(packageName, R.layout.widget_now_indicator)
indicator.setViewLayoutMargin(
R.id.widget_now_indicator_root,
RemoteViews.MARGIN_TOP,
topDp,
TypedValue.COMPLEX_UNIT_DIP,
)
parent.addView(containerId, indicator)
}
/// Custom-events use the user-picked palette (orange/red/green/blue,
/// mirroring CustomTimetableColors).
private fun statusDrawable(lesson: WidgetLesson): Int {
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFE53935" />
</shape>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_now_indicator_root"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="0dp"
android:background="@drawable/widget_now_indicator" />
+1 -1
View File
@@ -21,7 +21,7 @@ plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.13.2' apply false
id "com.android.library" version '8.13.2' apply false
id "org.jetbrains.kotlin.android" version "2.1.10" apply false
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
}
-2
View File
@@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
@@ -0,0 +1,2 @@
#include? "../Pods/Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"
#include "Generated.xcconfig"
@@ -0,0 +1,2 @@
#include? "../Pods/Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"
#include "Generated.xcconfig"
@@ -0,0 +1,2 @@
#include? "../Pods/Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+78 -65
View File
@@ -36,53 +36,37 @@ PODS:
- SwiftyGif
- emoji_picker_flutter (0.0.1):
- Flutter
- fast_rsa (0.7.0):
- eraser (0.0.1):
- Flutter
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (12.4.0):
- FirebaseCore (~> 12.4.0)
- Firebase/InAppMessaging (12.4.0):
- Firebase/CoreOnly (12.12.0):
- FirebaseCore (~> 12.12.0)
- Firebase/Messaging (12.12.0):
- Firebase/CoreOnly
- FirebaseInAppMessaging (~> 12.4.0-beta)
- Firebase/Messaging (12.4.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.4.0)
- firebase_core (4.2.1):
- Firebase/CoreOnly (= 12.4.0)
- FirebaseMessaging (~> 12.12.0)
- firebase_core (4.7.0):
- Firebase/CoreOnly (= 12.12.0)
- Flutter
- firebase_in_app_messaging (0.9.0-4):
- Firebase/InAppMessaging (= 12.4.0)
- firebase_messaging (16.2.0):
- Firebase/Messaging (= 12.12.0)
- firebase_core
- Flutter
- firebase_messaging (16.0.4):
- Firebase/Messaging (= 12.4.0)
- firebase_core
- Flutter
- FirebaseABTesting (12.4.0):
- FirebaseCore (~> 12.4.0)
- FirebaseCore (12.4.0):
- FirebaseCoreInternal (~> 12.4.0)
- FirebaseCore (12.12.1):
- FirebaseCoreInternal (~> 12.12.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.4.0):
- FirebaseCoreInternal (12.12.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInAppMessaging (12.4.0-beta):
- FirebaseABTesting (~> 12.4.0)
- FirebaseCore (~> 12.4.0)
- FirebaseInstallations (~> 12.4.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- FirebaseInstallations (12.4.0):
- FirebaseCore (~> 12.4.0)
- FirebaseInstallations (12.12.0):
- FirebaseCore (~> 12.12.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.4.0):
- FirebaseCore (~> 12.4.0)
- FirebaseInstallations (~> 12.4.0)
- FirebaseMessaging (12.12.0):
- FirebaseCore (~> 12.12.0)
- FirebaseInstallations (~> 12.12.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
@@ -96,6 +80,9 @@ PODS:
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
@@ -123,6 +110,8 @@ PODS:
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- in_app_review (2.0.0):
@@ -136,9 +125,6 @@ PODS:
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PhoneNumberKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore (= 3.7.11)
- PhoneNumberKit/UIKit (= 3.7.11)
@@ -146,9 +132,13 @@ PODS:
- PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore
- PromisesObjC (2.4.0)
- SDWebImage (5.21.2):
- SDWebImage/Core (= 5.21.2)
- SDWebImage/Core (5.21.2)
- receive_sharing_intent (1.8.1):
- Flutter
- screen_brightness_ios (2.1.3):
- Flutter
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -162,41 +152,51 @@ PODS:
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- wakelock_plus (0.0.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
- fast_rsa (from `.symlinks/plugins/fast_rsa/ios`)
- eraser (from `.symlinks/plugins/eraser/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_in_app_messaging (from `.symlinks/plugins/firebase_in_app_messaging/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- PhoneNumberKit (~> 3.7.6)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseABTesting
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInAppMessaging
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
@@ -214,14 +214,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/device_info_plus/ios"
emoji_picker_flutter:
:path: ".symlinks/plugins/emoji_picker_flutter/ios"
fast_rsa:
:path: ".symlinks/plugins/fast_rsa/ios"
eraser:
:path: ".symlinks/plugins/eraser/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_in_app_messaging:
:path: ".symlinks/plugins/firebase_in_app_messaging/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
@@ -232,6 +230,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
@@ -240,8 +242,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/open_filex/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -252,6 +256,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
@@ -259,40 +269,43 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
fast_rsa: fb70897d51040b094c780d5f1d7358614738b879
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594
firebase_in_app_messaging: 04dfc07ab81578ef83bf0c0229be258ddf287c4f
firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde
FirebaseABTesting: c05b5ec9f1d9f21a65909525de301d375032d9a4
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6
FirebaseInAppMessaging: 606dd4d4d5590a3d8229f363fdebb485235985b2
FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2
FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5
Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33
firebase_core: 9156a152117c843440b0b990c785aa0259bc5447
firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269
FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284
FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9
FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e
FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
PhoneNumberKit: ced55861269312a5e3bc2ef82a58d6255b1c976a
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
screen_brightness_ios: 212d950bb99c915eee971c884f4a6c87c92cd13d
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: e21c9d4c7b9623c73c6784ddc132fd50a603ad93
PODFILE CHECKSUM: 424a9b4c0fe81d8ebeaa9cb0dfedb60a68b19a0d
COCOAPODS: 1.16.2
+514 -14
View File
@@ -7,17 +7,50 @@
objects = {
/* Begin PBXBuildFile section */
034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
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 */; };
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3321F8042FB1C00C0011C712;
remoteInfo = "Share Extension";
};
AA0101080000000011111111 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0101010000000011111111;
remoteInfo = TimetableWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
3321F8102FB1C00C0011C712 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -33,9 +66,12 @@
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4509EC31CB08BA9BF367AF6C /* 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 = "<group>"; };
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -48,11 +84,73 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AA0101020000000011111111 /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
AA0102020000000022222222 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
AA6B03D1433E7395021F7730 /* 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 = "<group>"; };
BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Debug.xcconfig"; path = "Flutter/ShareExtension-Debug.xcconfig"; sourceTree = "<group>"; };
BB0001020000000011111111 /* ShareExtension-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Release.xcconfig"; path = "Flutter/ShareExtension-Release.xcconfig"; sourceTree = "<group>"; };
BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Profile.xcconfig"; path = "Flutter/ShareExtension-Profile.xcconfig"; sourceTree = "<group>"; };
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
C7E1879BE78835C7E3256316 /* 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 = "<group>"; };
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
AA01010E0000000011111111 /* Exceptions for "Share Extension" folder in "Share Extension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
};
AA01010F0000000011111111 /* Exceptions for "TimetableWidgetExtension" folder in "TimetableWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3321F8062FB1C00C0011C712 /* Share Extension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AA01010E0000000011111111 /* Exceptions for "Share Extension" folder in "Share Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "Share Extension";
sourceTree = "<group>";
};
AA0101030000000011111111 /* TimetableWidgetExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AA01010F0000000011111111 /* Exceptions for "TimetableWidgetExtension" folder in "TimetableWidgetExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = TimetableWidgetExtension;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
3321F8022FB1C00C0011C712 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -61,6 +159,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0101050000000011111111 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -70,6 +175,9 @@
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */,
AA6B03D1433E7395021F7730 /* Pods-Runner.release.xcconfig */,
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */,
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */,
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */,
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -78,6 +186,7 @@
isa = PBXGroup;
children = (
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -89,6 +198,12 @@
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */,
BB0001020000000011111111 /* ShareExtension-Release.xcconfig */,
BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */,
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
@@ -98,6 +213,8 @@
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
3321F8062FB1C00C0011C712 /* Share Extension */,
AA0101030000000011111111 /* TimetableWidgetExtension */,
97C146EF1CF9000F007C117D /* Products */,
345F4BD4143471FDA71626DE /* Pods */,
731388A08E3B330B216381D0 /* Frameworks */,
@@ -108,6 +225,8 @@
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -124,6 +243,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
AA0102020000000022222222 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -132,6 +252,27 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3321F8042FB1C00C0011C712 /* Share Extension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3321F8152FB1C00C0011C712 /* Build configuration list for PBXNativeTarget "Share Extension" */;
buildPhases = (
AC0316D13BD5FB74CD9B5223 /* [CP] Check Pods Manifest.lock */,
3321F8012FB1C00C0011C712 /* Sources */,
3321F8022FB1C00C0011C712 /* Frameworks */,
3321F8032FB1C00C0011C712 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
3321F8062FB1C00C0011C712 /* Share Extension */,
);
name = "Share Extension";
productName = "Share Extension";
productReference = 3321F8052FB1C00C0011C712 /* Share Extension.appex */;
productType = "com.apple.product-type.app-extension";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
@@ -142,6 +283,7 @@
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3321F8102FB1C00C0011C712 /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
174B54D80220E5F588BD9737 /* [CP] Embed Pods Frameworks */,
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */,
@@ -149,12 +291,34 @@
buildRules = (
);
dependencies = (
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
AA0101090000000011111111 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
AA0101010000000011111111 /* TimetableWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA01010D0000000011111111 /* Build configuration list for PBXNativeTarget "TimetableWidgetExtension" */;
buildPhases = (
AA0101040000000011111111 /* Sources */,
AA0101050000000011111111 /* Frameworks */,
AA0101060000000011111111 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA0101030000000011111111 /* TimetableWidgetExtension */,
);
name = TimetableWidgetExtension;
productName = TimetableWidgetExtension;
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -162,13 +326,20 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
3321F8042FB1C00C0011C712 = {
CreatedOnToolsVersion = 26.1.1;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
AA0101010000000011111111 = {
CreatedOnToolsVersion = 26.1.1;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@@ -185,11 +356,20 @@
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
3321F8042FB1C00C0011C712 /* Share Extension */,
AA0101010000000011111111 /* TimetableWidgetExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3321F8032FB1C00C0011C712 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -202,6 +382,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0101060000000011111111 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -213,14 +400,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -250,14 +433,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -278,6 +457,28 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
AC0316D13BD5FB74CD9B5223 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Share Extension-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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;
};
EE78ADC5E762D17A29097E92 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -303,17 +504,45 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3321F8012FB1C00C0011C712 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
AA0102010000000022222222 /* SceneDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0101040000000011111111 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
};
AA0101090000000011111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
targetProxy = AA0101080000000011111111 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -375,7 +604,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -394,6 +623,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = group.eu.mhsl.marianum.mobile.client.share;
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -403,7 +633,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "${FLUTTER_BUILD_NAME)";
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -413,6 +643,132 @@
};
name = Profile;
};
3321F8112FB1C00C0011C712 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
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 = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = group.eu.mhsl.marianum.mobile.client.share;
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
INFOPLIST_FILE = "Share Extension/Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "eu.mhsl.marianum.mobile.client.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
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;
};
3321F8122FB1C00C0011C712 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001020000000011111111 /* ShareExtension-Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
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 = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = group.eu.mhsl.marianum.mobile.client.share;
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
INFOPLIST_FILE = "Share Extension/Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "eu.mhsl.marianum.mobile.client.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
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;
};
3321F8132FB1C00C0011C712 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
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 = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = group.eu.mhsl.marianum.mobile.client.share;
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
INFOPLIST_FILE = "Share Extension/Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "eu.mhsl.marianum.mobile.client.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
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 = {
@@ -460,7 +816,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -509,7 +865,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -530,6 +886,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = group.eu.mhsl.marianum.mobile.client.share;
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -539,7 +896,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "${FLUTTER_BUILD_NAME)";
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -560,6 +917,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CUSTOM_GROUP_ID = group.eu.mhsl.marianum.mobile.client.share;
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -569,7 +927,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "${FLUTTER_BUILD_NAME)";
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -579,9 +937,141 @@
};
name = Release;
};
AA01010A0000000011111111 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
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/TimetableWidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
INFOPLIST_FILE = TimetableWidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.TimetableWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA01010B0000000011111111 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
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/TimetableWidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
INFOPLIST_FILE = TimetableWidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.TimetableWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
AA01010C0000000011111111 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
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/TimetableWidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = MY55VF3KPG;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
INFOPLIST_FILE = TimetableWidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.TimetableWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3321F8152FB1C00C0011C712 /* Build configuration list for PBXNativeTarget "Share Extension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3321F8112FB1C00C0011C712 /* Debug */,
3321F8122FB1C00C0011C712 /* Release */,
3321F8132FB1C00C0011C712 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -602,6 +1092,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA01010D0000000011111111 /* Build configuration list for PBXNativeTarget "TimetableWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA01010A0000000011111111 /* Debug */,
AA01010B0000000011111111 /* Release */,
AA01010C0000000011111111 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2610"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3321F8042FB1C00C0011C712"
BuildableName = "Share Extension.appex"
BlueprintName = "Share Extension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2610"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0101010000000011111111"
BuildableName = "TimetableWidgetExtension.appex"
BlueprintName = "TimetableWidgetExtension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+6 -3
View File
@@ -1,13 +1,16 @@
import UIKit
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
+83 -62
View File
@@ -1,69 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Marianum Fulda</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>client</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Marianum Fulda</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>client</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
+33
View File
@@ -0,0 +1,33 @@
import Flutter
import UIKit
import receive_sharing_intent
// FlutterSceneDelegate has a fallback that forwards URL events to plugins
// registered via addApplicationDelegate, but the fallback is best-effort and
// has not always fired in our setup. This subclass forwards URLs explicitly
// to receive_sharing_intent so cold-start and warm shares both reach Dart.
class SceneDelegate: FlutterSceneDelegate {
override func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application(
UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
)
}
}
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application(
UIApplication.shared,
open: context.url,
options: [:]
)
}
}
}
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
@@ -10,15 +9,15 @@
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target">
<view key="view" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="bcg-RR-FT9"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CzN-xT-EUl" userLabel="First Responder" sceneMemberID="firstResponder"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
-93
View File
@@ -1,93 +0,0 @@
# iOS Share Extension — Xcode Setup
Die Quellen unter `ios/Share Extension/` müssen einmalig in Xcode als **Share Extension Target** verdrahtet werden — analog zur `TimetableWidgetExtension`. Erst danach taucht „Marianum Fulda" im System-Share-Sheet auf.
## Schritt 1 — Share-Extension-Target anlegen
1. `ios/Runner.xcworkspace` in Xcode öffnen.
2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links.
3. **iOS → Share Extension** wählen.
4. Eigenschaften:
- Product Name: `Share Extension` (mit Leerzeichen, exakt so — der Ordnername und Podfile-Eintrag matchen).
- Bundle Identifier: `eu.mhsl.marianum.mobile.client.Share-Extension`.
- Language: Swift.
- Embed in: Runner.
5. Beim Activate-Scheme-Dialog auf **Cancel** klicken.
6. Deployment Target = mind. iOS 12.0 (Plugin-Mindestanforderung).
## Schritt 2 — Vorhandene Quelldateien ins Target ziehen
Xcode legt Dummy-Dateien an. Diese **löschen** (Move to Trash). Dann:
1. Sidebar → Rechtsklick auf den Ordner `Share Extension`**Add Files to "Runner"…**
2. Im File-Picker zu `ios/Share Extension/` navigieren und folgende Dateien selektieren:
- `ShareViewController.swift`
- `Info.plist`
- `MainInterface.storyboard`
- `Share Extension.entitlements`
3. **Wichtig**: bei „Add to targets" nur `Share Extension` ankreuzen, **nicht** Runner.
## Schritt 3 — App Group aktivieren
Beide Targets brauchen die App-Group-Berechtigung, damit die Extension geteilte Dateien für die Hauptapp im gemeinsamen Container ablegen kann.
1. **Runner**-Target → **Signing & Capabilities****+ Capability** → **App Groups**.
- Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.share` (zusätzlich zur bereits existierenden Widget-Group).
2. Dasselbe für **Share Extension**-Target — mit derselben Group-ID `group.eu.mhsl.marianum.mobile.client.share`.
Im Apple-Developer-Portal muss diese App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl.
## Schritt 4 — User-Defined Build Setting `CUSTOM_GROUP_ID`
Beide Targets brauchen das User-Defined Setting, das in `Runner/Info.plist` und `Share Extension/Info.plist` als `$(CUSTOM_GROUP_ID)` referenziert wird.
1. **Runner** → Build Settings → `+` (oben links) → **Add User-Defined Setting**.
- Name: `CUSTOM_GROUP_ID`
- Wert: `group.eu.mhsl.marianum.mobile.client.share`
2. Dasselbe für **Share Extension**-Target.
## Schritt 5 — Entitlements verlinken
1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` zeigt bereits auf `Runner/Runner.entitlements` (jetzt mit beiden Groups).
2. **Share Extension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `Share Extension/Share Extension.entitlements` setzen.
## Schritt 6 — Info.plist-Pfad
**Share Extension** → Build Settings → `INFOPLIST_FILE` → auf `Share Extension/Info.plist` setzen.
## Schritt 7 — Build Phases reorder
Damit das Plugin-Modul vom Extension-Target gefunden wird:
1. **Runner**-Target → **Build Phases**.
2. `Embed Foundation Extensions` per Drag-and-Drop **vor** `Thin Binary` ziehen.
## Schritt 8 — Pods installieren
```bash
cd ios && pod install
```
Der Podfile-Eintrag (`target 'Share Extension' do inherit! :search_paths end`) ist bereits vorhanden.
## Schritt 9 — Build & Run
1. Scheme `Runner` wählen → Run auf Device oder Simulator (≥ iOS 12).
2. Foto in der Fotos-App auswählen → Teilen → „Marianum Fulda" sollte erscheinen.
3. Auswahl → App öffnet sich, ShareTargetPage erscheint.
## Troubleshooting
- **Error: No such module 'receive_sharing_intent'**
→ Schritt 7 (Build Phases reorder) wurde übersprungen.
- **Error: Frameworks' not allowed in extension**
→ In Build Settings der Share Extension `Other Linker Flags` und `Framework Search Paths` leeren (nur die geerbten Pod-Pfade behalten).
- **Share-Sheet zeigt App nicht an**
`NSExtensionActivationRule`-Limits in `Share Extension/Info.plist` zu klein? Werte testweise erhöhen. Außerdem: App muss **mindestens einmal nach Install** geöffnet worden sein, sonst wird die Extension von iOS nicht registriert.
- **Files kommen mit `nil` Pfad an**
→ App-Group nicht konsistent. Prüfen, dass `CUSTOM_GROUP_ID` in beiden Targets identisch ist und die Entitlement-Files dieselbe Group enthalten.
## Was am Mac noch zu tun ist
- Schritte 18 oben (~15 Min).
- Auf physischem iPhone testen — Simulator-Share-Sheet ist eingeschränkt.
+237 -5
View File
@@ -1,8 +1,240 @@
import UIKit
import receive_sharing_intent
import UniformTypeIdentifiers
import AVFoundation
class ShareViewController: RSIShareViewController {
override func shouldAutoRedirect() -> Bool {
return true
}
// Datenmodell muss byte-für-byte zu dem passen, was
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
private enum SharedMediaType: String, Codable {
case image, video, text, file, url
}
private struct SharedMediaFile: Codable {
let path: String
let mimeType: String?
let thumbnail: String?
let duration: Double?
let message: String?
let type: SharedMediaType
}
final class ShareViewController: UIViewController {
// Schlüssel sind die, die das Plugin liest.
private let userDefaultsKey = "ShareKey"
private let userDefaultsMessageKey = "ShareMessageKey"
private let urlSchemePrefix = "ShareMedia"
private var appGroupId = ""
private var hostBundleId = ""
override func viewDidLoad() {
super.viewDidLoad()
resolveIds()
setupPlaceholderUI()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
processAttachments()
}
private func resolveIds() {
let extBundleId = Bundle.main.bundleIdentifier ?? ""
if let lastDot = extBundleId.lastIndex(of: ".") {
hostBundleId = String(extBundleId[..<lastDot])
}
let custom = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
if let custom, !custom.isEmpty, !custom.contains("$(") {
appGroupId = custom
} else {
appGroupId = "group.\(hostBundleId)"
}
}
private func setupPlaceholderUI() {
view.backgroundColor = .systemBackground
let spinner = UIActivityIndicatorView(style: .medium)
spinner.startAnimating()
let label = UILabel()
label.text = "Wird geteilt …"
label.font = .preferredFont(forTextStyle: .footnote)
label.textColor = .secondaryLabel
let stack = UIStackView(arrangedSubviews: [spinner, label])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
private func processAttachments() {
let attachments: [NSItemProvider] = (extensionContext?.inputItems ?? [])
.compactMap { $0 as? NSExtensionItem }
.flatMap { $0.attachments ?? [] }
guard !attachments.isEmpty else {
finish()
return
}
let group = DispatchGroup()
var collected: [SharedMediaFile] = []
let lock = NSLock()
for provider in attachments {
group.enter()
handle(provider: provider) { file in
if let f = file {
lock.lock(); collected.append(f); lock.unlock()
}
group.leave()
}
}
group.notify(queue: .main) { [weak self] in
self?.saveAndRedirect(items: collected)
}
}
// Reihenfolge entspricht SharedMediaType.allCases im Plugin
// spezifische Typen (image, video) vor generischen (file, url).
private func handle(provider: NSItemProvider,
completion: @escaping (SharedMediaFile?) -> Void) {
let order: [(String, SharedMediaType)] = [
(UTType.image.identifier, .image),
(UTType.movie.identifier, .video),
(UTType.text.identifier, .text),
(UTType.fileURL.identifier, .file),
(UTType.url.identifier, .url),
(UTType.data.identifier, .file),
]
for (typeId, kind) in order {
if provider.hasItemConformingToTypeIdentifier(typeId) {
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
guard let self else { completion(nil); return }
if error != nil { completion(nil); return }
completion(self.toSharedFile(data: data, kind: kind))
}
return
}
}
completion(nil)
}
private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? {
switch kind {
case .text:
guard let s = data as? String else { return nil }
return SharedMediaFile(path: s, mimeType: "text/plain",
thumbnail: nil, duration: nil, message: nil, type: .text)
case .url:
guard let u = data as? URL else { return nil }
return SharedMediaFile(path: u.absoluteString, mimeType: nil,
thumbnail: nil, duration: nil, message: nil, type: .url)
case .image:
if let u = data as? URL, let dst = copyIntoAppGroup(src: u) {
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
thumbnail: nil, duration: nil, message: nil, type: .image)
}
if let img = data as? UIImage, let dst = writePng(image: img) {
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
thumbnail: nil, duration: nil, message: nil, type: .image)
}
return nil
case .video:
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
thumbnail: nil, duration: videoDurationMs(url: u),
message: nil, type: .video)
case .file:
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
thumbnail: nil, duration: nil, message: nil, type: .file)
}
}
private func copyIntoAppGroup(src: URL) -> URL? {
guard let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
return nil
}
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
let dst = container.appendingPathComponent(name)
do {
if FileManager.default.fileExists(atPath: dst.path) {
try FileManager.default.removeItem(at: dst)
}
try FileManager.default.copyItem(at: src, to: dst)
return dst
} catch {
return nil
}
}
private func writePng(image: UIImage) -> URL? {
guard let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
let data = image.pngData() else { return nil }
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
return (try? data.write(to: dst)) != nil ? dst : nil
}
// Das Plugin macht `path.replacingOccurrences(of: "file://", with: "")`,
// also liefern wir absoluteString mit Prozent-Decoding selbes Format wie
// im Original-RSIShareViewController.
private func pathString(for url: URL) -> String {
url.absoluteString.removingPercentEncoding ?? url.absoluteString
}
private func mime(for url: URL) -> String? {
UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
}
private func videoDurationMs(url: URL) -> Double {
(CMTimeGetSeconds(AVURLAsset(url: url).duration) * 1000).rounded()
}
private func saveAndRedirect(items: [SharedMediaFile]) {
guard !items.isEmpty else {
finish()
return
}
let defaults = UserDefaults(suiteName: appGroupId)
guard let encoded = try? JSONEncoder().encode(items) else {
finish()
return
}
defaults?.set(encoded, forKey: userDefaultsKey)
defaults?.removeObject(forKey: userDefaultsMessageKey)
let urlStr = "\(urlSchemePrefix)-\(hostBundleId):share"
guard let url = URL(string: urlStr) else {
finish()
return
}
// Apple-DTS says Share Extensions are not officially allowed to open
// URLs. Both `extensionContext.open` and the responder-chain trick
// succeed in some iOS versions and fail silently in others, so we fire
// both in parallel and let whichever Apple is honouring this release
// win the race.
extensionContext?.open(url, completionHandler: nil)
var responder: UIResponder? = self
while responder != nil {
if let app = responder as? UIApplication {
app.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
// Brief window so the open request reaches the system before we tear
// the extension down.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.finish()
}
}
private func finish() {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
@@ -1,28 +1,29 @@
import SwiftUI
/// Marianum-M peeking out of the bottom-right corner. Sized to the longer
/// widget edge so it scales with resize; offset nudges a sliver behind the
/// edge.
/// widget edge so it scales with the picked WidgetFamily; offset nudges a
/// sliver behind the edge. Opacity comes from the widget palette so the
/// watermark matches Android's `watermarkAlpha` at the same brightness.
struct MarianumWatermark: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.widgetPalette) private var palette
var body: some View {
GeometryReader { geo in
let markSize = min(400, max(160, max(geo.size.width, geo.size.height) * 0.8))
let offsetX = markSize * 0.18
let offsetY = markSize * 0.18
ZStack(alignment: .bottomTrailing) {
Color.clear
Image("marianum_m")
.resizable()
.renderingMode(.template)
.aspectRatio(contentMode: .fit)
.foregroundStyle(.primary)
.frame(width: markSize, height: markSize)
.opacity(colorScheme == .dark ? 0.025 : 0.014)
.offset(x: offsetX, y: offsetY)
}
}
.clipped()
var body: some View {
GeometryReader { geo in
let markSize = min(400, max(160, max(geo.size.width, geo.size.height) * 0.8))
let offsetX = markSize * 0.18
let offsetY = markSize * 0.18
ZStack(alignment: .bottomTrailing) {
Color.clear
Image("marianum_m")
.resizable()
.renderingMode(.template)
.aspectRatio(contentMode: .fit)
.foregroundStyle(palette.textPrimary)
.frame(width: markSize, height: markSize)
.opacity(palette.watermarkOpacity)
.offset(x: offsetX, y: offsetY)
}
}
.clipped()
}
}
-72
View File
@@ -1,72 +0,0 @@
# iOS Widget Extension — Xcode Setup
Die Swift-Quellen unter `ios/TimetableWidgetExtension/` müssen einmalig in Xcode als **Widget Extension Target** verdrahtet werden — ohne diesen Schritt bleibt der Code unkompiliert.
## Schritt 1 — Widget-Extension-Target anlegen
1. `ios/Runner.xcworkspace` in Xcode öffnen.
2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links.
3. **iOS → Widget Extension** wählen.
4. Eigenschaften:
- Product Name: `TimetableWidgetExtension`
- Bundle Identifier: `eu.mhsl.marianum.mobile.client.TimetableWidgetExtension`
- Language: Swift
- Include Configuration Intent: **OFF** (StaticConfiguration reicht)
- Embed in: Runner
5. Beim Activate-Scheme-Dialog auf **Cancel** klicken.
## Schritt 2 — Vorhandene Quelldateien ins Target ziehen
Xcode hat zunächst Dummy-Dateien (`TimetableWidgetExtension.swift`, `TimetableWidgetExtensionBundle.swift`) angelegt. Diese **löschen** (Move to Trash). Dann:
1. Sidebar → Rechtsklick auf den Ordner `TimetableWidgetExtension`**Add Files to "Runner"…**
2. Im File-Picker zu `ios/TimetableWidgetExtension/` navigieren und alle `.swift`-Dateien, die `Info.plist`, `TimetableWidgetExtension.entitlements` **und den `Assets.xcassets`-Ordner** selektieren (mit `marianum_m`-Asset darin — gleicher Asset-Name wie auf Android-Seite).
3. **Wichtig**: bei „Add to targets" nur `TimetableWidgetExtension` ankreuzen, **nicht** Runner.
## Schritt 3 — App Group aktivieren
Beide Targets brauchen die App-Group-Berechtigung, damit Hauptapp und Widget über `UserDefaults(suiteName:)` schreiben/lesen können.
1. **Runner**-Target → **Signing & Capabilities****+ Capability** → **App Groups**.
- Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.widget`
2. Dasselbe für **TimetableWidgetExtension** — mit derselben Group-ID.
Im Apple-Developer-Portal muss die App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl.
## Schritt 4 — Entitlements verlinken
1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` sollte bereits auf `Runner/Runner.entitlements` zeigen.
2. **TimetableWidgetExtension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `TimetableWidgetExtension/TimetableWidgetExtension.entitlements` setzen.
## Schritt 5 — Info.plist + Deployment Target
1. **TimetableWidgetExtension** → Build Settings → `INFOPLIST_FILE` → auf `TimetableWidgetExtension/Info.plist` setzen.
2. Build Settings → `IPHONEOS_DEPLOYMENT_TARGET` ≥ 16.0 (Code gated `.containerBackground` mit `if #available(iOS 17, *)`, läuft also auch auf 16).
## Schritt 6 — Build & Run
- Scheme `Runner` (nicht das Widget-Scheme) wählen → Run.
- Auf Home-Screen langes Drücken → Widget hinzufügen → "Marianum · Heute" / "Marianum · Woche".
- Widget-Tap öffnet die App im zuletzt sichtbaren Tab. Eine Tab-Navigation auf den Stundenplan ist bewusst nicht implementiert (Android nutzt Intent-Extras, iOS würde dafür ein URL-Scheme oder AppIntent brauchen — beides bewusst ausgespart).
## Troubleshooting
- **Widget zeigt „Lade…"** auch nach Refresh: App-Group greift nicht. Prüfen, ob beide Targets dieselbe Group-ID haben und das Provisioning aktualisiert wurde.
- **Stale-Daten nach Logout**: `WidgetSync.clear()` schreibt `widget_data_logged_in_v1 = false`; Widget zeigt dann den Login-Placeholder.
- **Lessons um 12 Stunden verschoben**: Date-Parser-Bug. Sollte gefixt sein in `WidgetData.swift::parseDartDate` — verifizieren, dass die ISO-8601-Strings ohne Z-Suffix als `TimeZone.current` geparsed werden.
- **App-Store-Submit später**: `Runner.entitlements` `aps-environment` von `development` auf `production` umbiegen.
## Was bereits im Repo erledigt ist
- Alle Swift-Quellen, Info.plist, Entitlements liegen unter `ios/TimetableWidgetExtension/`.
- App-Group-ID konsistent zwischen Dart (`WidgetSync.iosAppGroupId`), Swift (`WidgetDataKey.appGroupId`) und der Entitlements-Datei.
- `home_widget`-Plugin auf der Dart-Seite konfiguriert; ruft `HomeWidget.setAppGroupId` beim ersten Sync.
- `containerBackground` für iOS 17+ gegated, fällt auf iOS 16 sauber zurück.
- Date-Parser fixt das fehlende Z-Suffix (Dart schreibt lokale Zeit ohne TZ-Marker).
## Was am Mac noch zu tun ist
- Schritte 15 oben in Xcode durchklicken (1015 Min).
- `flutter pub get` + `cd ios && pod install`.
- Auf physischem Gerät oder iOS-Simulator (≥ 16.0) bauen.
- Widget aufs Home-Screen ziehen, prüfen dass Lesson-Zeiten korrekt rendern.
@@ -53,6 +53,7 @@ func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
struct TimetableDayView: View {
let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette
var body: some View {
ZStack {
@@ -65,7 +66,6 @@ struct TimetableDayView: View {
}
}
.background(MarianumWatermark())
.widgetThemeOverride(entry.themeMode)
}
@ViewBuilder
@@ -82,7 +82,6 @@ struct TimetableDayView: View {
TimeGridView(
lessons: data.lessons,
periods: data.periods,
anchorDate: data.anchorDate,
hourHeight: max(
MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
@@ -100,11 +99,11 @@ struct TimetableDayView: View {
HStack {
Text(dayLabel(for: data.anchorDate))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.foregroundStyle(palette.textPrimary)
Spacer()
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.foregroundStyle(palette.textSecondary)
}
}
@@ -113,7 +112,7 @@ struct TimetableDayView: View {
Spacer()
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(palette.textSecondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -123,9 +122,10 @@ struct TimetableDayView: View {
VStack(spacing: 4) {
Text("Marianum Stundenplan")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textPrimary)
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -135,7 +135,6 @@ struct TimetableDayView: View {
struct TimeGridView: View {
let lessons: [WidgetLesson]
let periods: [WidgetPeriod]
let anchorDate: Date
let hourHeight: CGFloat
let showRoom: Bool
let showTeacher: Bool
@@ -143,6 +142,8 @@ struct TimeGridView: View {
/// Week-widget passes 3 for narrow columns; day-widget keeps 7.
var horizontalPadding: CGFloat = 7
@Environment(\.widgetPalette) private var palette
private var totalVirtualMinutes: Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
@@ -158,10 +159,12 @@ struct TimeGridView: View {
var body: some View {
HStack(alignment: .top, spacing: 0) {
if showTimeLabels {
// 28pt matches the Week widget's time-label column so the
// visual rhythm is identical across both widgets.
timeLabelsColumn
.frame(width: 32, alignment: .topTrailing)
.frame(width: 28, alignment: .topTrailing)
Rectangle()
.fill(Color.primary.opacity(0.13))
.fill(palette.divider)
.frame(width: 1)
}
ZStack(alignment: .top) {
@@ -170,9 +173,6 @@ struct TimeGridView: View {
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx])
}
if Calendar.current.isDate(anchorDate, inSameDayAs: Date()) {
nowIndicator
}
}
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
}
@@ -180,13 +180,9 @@ struct TimeGridView: View {
private var timeLabelsColumn: some View {
ZStack(alignment: .topTrailing) {
// Hour rules continue through the time-label column so it reads
// as a real table column rather than a free-floating tick list.
// Hour rules extend through the time-label column so it reads
// as a table column rather than a free-floating tick list.
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle()
.fill(Color.primary.opacity(0.08))
.fill(palette.divider)
.frame(height: 1)
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
}
@@ -195,16 +191,16 @@ struct TimeGridView: View {
if compactLabels {
Text("\(period.name).")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.primary)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else {
Text(formatHm(period.startMinutes))
.font(.system(size: 9))
.foregroundStyle(.primary)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.secondary)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
}
@@ -217,11 +213,9 @@ struct TimeGridView: View {
private var gridLines: some View {
ZStack(alignment: .top) {
// Hour rules continue through the time-label column so it reads
// as a real table column rather than a free-floating tick list.
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle()
.fill(Color.primary.opacity(0.08))
.fill(palette.divider)
.frame(height: 1)
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
}
@@ -237,7 +231,7 @@ struct TimeGridView: View {
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
if virtualGap > 0 {
Rectangle()
.fill(Color.primary.opacity(0.03))
.fill(palette.breakBlock)
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
.padding(.horizontal, 1)
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
@@ -344,27 +338,6 @@ struct TimeGridView: View {
}
}
private var nowIndicator: some View {
let cal = Calendar.current
let comps = cal.dateComponents([.hour, .minute], from: Date())
let nowMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
let inside: Bool
if let first = periods.first, let last = periods.last {
inside = nowMinutes >= first.startMinutes && nowMinutes <= last.endMinutes
} else {
inside = true
}
let top = realMinutesToVirtual(nowMinutes, periods: periods) * hourHeight / 60.0
return Group {
if inside {
Rectangle()
.fill(Color.red)
.frame(height: 2)
.offset(y: top)
}
}
}
private func subjectLabel(_ lesson: WidgetLesson) -> String {
!lesson.subjectShort.isEmpty
? lesson.subjectShort
@@ -3,6 +3,7 @@ import WidgetKit
struct TimetableWeekView: View {
let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette
var body: some View {
ZStack {
@@ -15,7 +16,6 @@ struct TimetableWeekView: View {
}
}
.background(MarianumWatermark())
.widgetThemeOverride(entry.themeMode)
}
@ViewBuilder
@@ -52,7 +52,7 @@ struct TimetableWeekView: View {
private var columnDivider: some View {
Rectangle()
.fill(Color.primary.opacity(0.13))
.fill(palette.divider)
.frame(width: 1)
}
@@ -63,16 +63,23 @@ struct TimetableWeekView: View {
return HStack {
Text("KW \(week) · \(shortDate(data.anchorDate))\(shortDate(endDate))")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
.foregroundStyle(palette.textPrimary)
Spacer()
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.foregroundStyle(palette.textSecondary)
}
}
private func dayHeaderRow(data: WidgetTimetableData) -> some View {
let cal = Calendar.current
// `.frame(maxWidth: .infinity)` is critical: without an explicit
// greedy width (or a true `Spacer()` inside), this HStack collapses
// to the sum of its fixed-width children in a `.leading` VStack
// and the 5 day-columns end up sharing ~5pt of width the labels
// crunch to the left and stop aligning with the grid columns below.
// The fixed height keeps `columnDivider`'s vertically-flexible
// Rectangle from stealing space from the GeometryReader.
return HStack(spacing: 0) {
Spacer().frame(width: 28)
columnDivider
@@ -81,15 +88,16 @@ struct TimetableWeekView: View {
VStack(spacing: 0) {
Text(weekday(for: day))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.primary)
.foregroundStyle(palette.textPrimary)
Text(shortDate(day))
.font(.system(size: 9))
.foregroundStyle(.secondary)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
}
}
.frame(maxWidth: .infinity, minHeight: 26, maxHeight: 26)
}
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
@@ -98,7 +106,7 @@ struct TimetableWeekView: View {
return ZStack(alignment: .topTrailing) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle()
.fill(Color.primary.opacity(0.08))
.fill(palette.divider)
.frame(height: 1)
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
}
@@ -106,11 +114,11 @@ struct TimetableWeekView: View {
VStack(alignment: .trailing, spacing: -2) {
Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60))
.font(.system(size: 8))
.foregroundStyle(.primary)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 6, weight: .bold))
.foregroundStyle(.secondary)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
@@ -131,7 +139,6 @@ struct TimetableWeekView: View {
return TimeGridView(
lessons: lessonsForDay,
periods: data.periods,
anchorDate: day,
hourHeight: hourHeight,
showRoom: !subjectOnly,
showTeacher: !subjectOnly,
@@ -151,9 +158,10 @@ struct TimetableWeekView: View {
VStack(spacing: 4) {
Text("Marianum Stundenplan")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textPrimary)
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -16,7 +16,7 @@ struct TimetableDayWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: TimetableDayProvider()) { entry in
TimetableDayView(entry: entry).widgetContainerBackground()
TimetableDayView(entry: entry).widgetSurface(entry: entry)
}
.configurationDisplayName("Marianum · Heute")
.description("Stundenplan und Vertretungen für den anstehenden Schultag.")
@@ -52,7 +52,7 @@ struct TimetableWeekWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: TimetableWeekProvider()) { entry in
TimetableWeekView(entry: entry).widgetContainerBackground()
TimetableWeekView(entry: entry).widgetSurface(entry: entry)
}
.configurationDisplayName("Marianum · Woche")
.description("Stundenplan und Vertretungen für die ganze Schulwoche.")
@@ -116,6 +116,8 @@ struct TimetableEntry: TimelineEntry {
}
extension View {
/// Applies the user's chosen light/dark override on top of the system
/// scheme so the widget honours the in-app theme setting.
@ViewBuilder
func widgetThemeOverride(_ mode: String) -> some View {
switch mode {
@@ -125,14 +127,38 @@ extension View {
}
}
/// `.containerBackground(_:for:)` is iOS 17+. Older iOS uses the
/// implicit `.background(...)` model and renders fine without it.
/// Wraps the widget view in the Marianum palette + container background
/// so all subviews can read `\.widgetPalette` and so the widget renders
/// in our warm off-white / dark-clay instead of system grey.
@ViewBuilder
func widgetContainerBackground() -> some View {
func widgetSurface(entry: TimetableEntry) -> some View {
WidgetSurface(entry: entry) { self }
}
}
private struct WidgetSurface<Content: View>: View {
let entry: TimetableEntry
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let palette = WidgetPalette.resolve(
themeMode: entry.themeMode,
colorScheme: colorScheme
)
return AnyView(
background(content: content(), palette: palette)
.environment(\.widgetPalette, palette)
.widgetThemeOverride(entry.themeMode)
)
}
@ViewBuilder
private func background<C: View>(content: C, palette: WidgetPalette) -> some View {
if #available(iOS 17.0, *) {
self.containerBackground(.fill.tertiary, for: .widget)
content.containerBackground(palette.background, for: .widget)
} else {
self
content.background(palette.background)
}
}
}
@@ -0,0 +1,53 @@
import SwiftUI
/// Mirrors the Kotlin `WidgetPalette` in WidgetRenderer.kt so day/week widgets
/// look identical across platforms. All values are hex tokens from the in-app
/// LightAppTheme / DarkAppTheme do not swap to system colors, the whole
/// point is platform-independent branding.
struct WidgetPalette {
let background: Color
let textPrimary: Color
let textSecondary: Color
let divider: Color
let breakBlock: Color
let watermarkOpacity: Double
static let light = WidgetPalette(
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
divider: Color.black.opacity(0x22 / 255.0),
breakBlock: Color.black.opacity(0x0C / 255.0),
watermarkOpacity: 0.014
)
static let dark = WidgetPalette(
background: Color(red: 0x1F / 255, green: 0x17 / 255, blue: 0x16 / 255),
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
divider: Color.white.opacity(0x33 / 255.0),
breakBlock: Color.white.opacity(0x14 / 255.0),
watermarkOpacity: 0.025
)
static func resolve(themeMode: String, colorScheme: ColorScheme) -> WidgetPalette {
let isDark: Bool
switch themeMode {
case "light": isDark = false
case "dark": isDark = true
default: isDark = colorScheme == .dark
}
return isDark ? .dark : .light
}
}
private struct WidgetPaletteKey: EnvironmentKey {
static let defaultValue = WidgetPalette.light
}
extension EnvironmentValues {
var widgetPalette: WidgetPalette {
get { self[WidgetPaletteKey.self] }
set { self[WidgetPaletteKey.self] = newValue }
}
}
@@ -0,0 +1,69 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and we need 304 to be a
/// normal "no new messages" outcome. `setReadMarker=on` lets the server
/// move the read cursor whenever the call returns messages.
class LongPollChat {
final String chatToken;
final int lastKnownMessageId;
final int timeoutSeconds;
LongPollChat({
required this.chatToken,
required this.lastKnownMessageId,
this.timeoutSeconds = 30,
});
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
limit: 100,
);
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15));
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
}
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
);
}
}
@@ -26,11 +26,5 @@ class SetReadMarker extends TalkApi {
Uri uri,
Object? body,
Map<String, String>? headers,
) {
if (readState) {
return http.post(uri, headers: headers);
} else {
return http.delete(uri, headers: headers);
}
}
) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers);
}
+55 -55
View File
@@ -36,17 +36,33 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _refetchChats;
late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync;
// Tracked via the bottom-nav controller's listener so it always reflects the
// user's actual position, even between rapid setting emits where the
// controller hasn't caught up to a scheduled jump yet.
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1;
bool _userOnLastTab = false;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
void _onTabControllerChanged() {
_userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1;
_syncChatListPolling();
}
void _syncChatListPolling() {
if (!mounted) return;
final modules = AppModule.getBottomBarModules(context);
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
final talkIsActive =
talkSlot >= 0 && Main.bottomNavigator.index == talkSlot;
final bloc = context.read<ChatListBloc>();
bloc.setAutoRefreshInterval(
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
);
if (talkIsActive) bloc.refresh();
}
@override
@@ -65,13 +81,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return;
// Routes pushed with `withNavBar: false` (chat views, file viewers, …)
// sit on the root navigator above the bottom-nav, so a bare jumpToTab
// would swap the tab behind them and leave the user staring at the
// previous screen. Reset to the tab root first.
// `withNavBar: false` routes sit on the root navigator above the
// bottom-nav; pop them so jumpToTab is actually visible. Stop at
// popups so open dialogs/sheets stay alive.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
AppRoutes.goToTab(context, Modules.timetable);
}
@@ -80,12 +95,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return;
final share = ShareIntentListener.pending.value;
if (share == null) return;
// A second share arriving while a previous share-flow page is still on
// the stack would otherwise leave the old page sitting on top with stale
// (already-cleared) file paths. Reset to the tab root before pushing.
// A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
AppRoutes.openShareTarget(context, share);
}
@@ -101,15 +115,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return;
context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh();
// App is freshly mounted on every login (BlocConsumer in main.dart
// swaps it in for Login), so this also covers the post-logout case
// where the bloc was reset to an empty state and needs a fresh fetch.
// Re-mounts on every login, so this also covers post-logout state reset.
final timetable = context.read<TimetableBloc>();
timetable.refresh();
// Push the freshest timetable state into the home-screen widget any
// time the BLoC reports new data — without waiting for the periodic
// background refresh. This is the "user just opened the app" path:
// the widget gets the same data the user is looking at on screen.
// Mirror BLoC updates into the home-screen widget without waiting
// for the periodic background refresh.
final settingsCubit = context.read<SettingsCubit>();
_timetableWidgetSync?.cancel();
_timetableWidgetSync = timetable.stream.listen((state) {
@@ -123,8 +133,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
);
}
});
// Also publish the current state once, in case data is already loaded
// from hydrated storage before the listener attaches.
// Initial publish in case hydrated storage already has data.
final initialData = timetable.state.data;
if (initialData is TimetableState) {
unawaited(
@@ -138,28 +147,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare();
_syncChatListPolling();
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {});
});
_refetchChats = Timer.periodic(const Duration(seconds: 60), (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<ChatListBloc>().refresh();
});
});
UpdateUserIndex.index();
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
void update() => NotifyUpdater.registerToServer();
FirebaseMessaging.instance.onTokenRefresh.listen((_) => update());
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
(_) => update(),
);
update();
}
FirebaseMessaging.onMessage.listen((message) {
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context);
});
@@ -167,7 +172,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationController.onBackgroundMessageHandler,
);
FirebaseMessaging.onMessageOpenedApp.listen((message) {
_onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
message,
) {
if (!mounted) return;
NotificationController.onAppOpenedByNotification(message, context);
});
@@ -181,9 +188,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override
void dispose() {
_refetchChats.cancel();
_updateTimings.cancel();
_timetableWidgetSync?.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
@@ -200,17 +209,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final totalTabs = bottomBarModules.length + 1;
final currentIndex = Main.bottomNavigator.index;
// The bottom-bar layout is identified by the ordered list of module
// names plus the trailing 'more' slot. Whenever this layout changes
// — slot count, reordering, or hiding a module — we recreate the
// entire PersistentTabView via the [layoutKey] below. The package
// caches per-tab navigator state by index in `_navigatorKeys`, and
// its internal `alignLength` only ever appends or trims at the end.
// So when the module sitting at e.g. index 3 changes, the navigator
// at that index still serves the old screen's route stack and the
// user sees stale content. Re-mounting clears those stacks; the
// trade-off (losing in-tab pushed routes on a settings change) is
// acceptable since the user explicitly re-shaped the bar.
// PersistentTabView caches per-tab navigators by index and only
// appends/trims at the end, so reordering/hiding leaves stale
// route stacks under the wrong tabs. Re-key on layout to remount.
final layoutKey = ValueKey(
'${bottomBarModules.map((m) => m.module.name).join('|')}|more',
);
@@ -222,12 +223,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} else if (currentIndex >= totalTabs) {
targetIndex = totalTabs - 1;
}
// Re-mounting PTV with a new key constructs fresh internals from
// its controller's current index. If the controller still points
// past the new tab list, Style6BottomNavBar (and others) crash on
// out-of-range access during initState. Replace the controller
// atomically with one initialised at the safe target index so the
// new PTV mounts cleanly.
// Replace the controller atomically: a stale index past the new
// tab list crashes Style6BottomNavBar's initState.
if (targetIndex != currentIndex) {
Main.bottomNavigator.removeListener(_onTabControllerChanged);
Main.bottomNavigator = PersistentTabController(
@@ -263,14 +260,17 @@ class _AppState extends State<App> with WidgetsBindingObserver {
),
],
navBarBuilder: (config) => Style6BottomNavBar(
// Style6BottomNavBar builds its internal animation controller list
// in initState and never grows it on didUpdateWidget. Keying by the
// item count forces a fresh State whenever the slot count changes,
// which avoids a RangeError when more tabs slide in.
// Animation controllers are built once in initState and never
// grown — re-key on item count to avoid RangeError on growth.
key: ValueKey(config.items.length),
navBarConfig: config,
navBarDecoration: NavBarDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.grey)),
border: Border(
top: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
color: Theme.of(context).colorScheme.surface,
),
),
+6 -1
View File
@@ -23,6 +23,7 @@ import 'app.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
import 'model/account_data.dart';
import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart';
@@ -153,7 +154,9 @@ Future<void> main() async {
),
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(create: (_) => ChatBloc()),
BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
],
child: const Main(),
@@ -199,6 +202,8 @@ class _MainState extends State<Main> {
checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
navigatorObservers: [AppRoutes.chatRouteObserver],
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
+26 -2
View File
@@ -1,13 +1,19 @@
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart';
import 'notification_tasks.dart';
// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM
// background isolate looks the class up by name from native code.
@pragma('vm:entry-point')
class NotificationController {
// Notification display is handled by the Firebase SDK using server-generated payloads.
@pragma('vm:entry-point')
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
NotificationTasks.updateBadgeCount(message);
@@ -17,8 +23,26 @@ class NotificationController {
RemoteMessage message,
BuildContext context,
) async {
NotificationTasks.updateProviders(context);
final pushToken = _extractChatToken(message);
final chatBloc = context.read<ChatBloc>();
// hasOpenChat, not currentToken: currentToken sticks around after
// leaveChat so didPopNext can re-claim a stacked chat.
final activeToken = chatBloc.state.data?.currentToken ?? '';
final chatIsOpen =
chatBloc.hasOpenChat &&
pushToken != null &&
pushToken.isNotEmpty &&
pushToken == activeToken;
NotificationTasks.updateBadgeCount(message);
if (chatIsOpen) {
// Long-poll handles the message; just dismiss any stray tray entry.
unawaited(NotificationTasks.clearNotificationsForChat(pushToken));
return;
}
NotificationTasks.updateProviders(context);
}
static Future<void> onAppOpenedByNotification(
+40 -2
View File
@@ -1,11 +1,15 @@
import 'dart:developer';
import 'package:eraser/eraser.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../routing/app_routes.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'notification_service.dart';
class NotificationTasks {
static void updateBadgeCount(RemoteMessage notification) {
@@ -14,9 +18,43 @@ class NotificationTasks {
);
}
/// Per-chat tag scheme. MUST match the Notify backend, which sets this
/// value on `AndroidNotification.setTag` AND `apns-collapse-id`.
static String chatTag(String chatToken) => 'talk_$chatToken';
/// Removes tray notifications belonging to [chatToken]. Eraser handles
/// iOS (where the plugin's `getActiveNotifications` returns null ids
/// for FCM posts and can't cancel them); the local-notifications sweep
/// handles Android and acts as a fallback while Eraser's native side
/// isn't built in yet.
static Future<void> clearNotificationsForChat(String chatToken) async {
final tag = chatTag(chatToken);
try {
await Eraser.clearAppNotificationsByTag(tag);
} on MissingPluginException {
// Eraser native code not yet linked — needs flutter clean + run.
} on Object catch (e) {
log('Eraser($tag) failed: $e');
}
try {
final plugin = NotificationService().flutterLocalNotificationsPlugin;
final actives = await plugin.getActiveNotifications();
for (final n in actives) {
final id = n.id;
if (id == null) continue;
if (n.tag == tag) await plugin.cancel(id: id, tag: n.tag);
}
} on Object catch (e) {
log('Active-notification sweep failed: $e');
}
}
/// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] —
/// the open chat view manages its own state via long-poll, and refreshing
/// it here would re-fetch the last-opened chat with setReadMarker=on
/// even if the user has already left.
static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh();
context.read<ChatBloc>().refresh();
}
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
+12
View File
@@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../main.dart';
import '../model/account_data.dart';
import '../notification/notification_tasks.dart';
import '../share_intent/pending_share.dart';
import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart';
@@ -39,6 +40,11 @@ class AppRoutes {
/// by `ChatList` once the matching room is loaded.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
/// Root-navigator observer used by [ChatView] to reclaim the global
/// [ChatBloc] on `didPopNext` after a stacked chat is popped.
static final RouteObserver<PageRoute<dynamic>> chatRouteObserver =
RouteObserver<PageRoute<dynamic>>();
static void openFolder(BuildContext context, List<String> path) {
pushScreen(context, withNavBar: false, screen: Files(path: path));
}
@@ -177,6 +183,12 @@ class AppRoutes {
required UserAvatar avatar,
bool overrideToSingleSubScreen = true,
}) {
// Local mark only. Server-side mark is sent later from
// ChatBloc._loadChat with the freshly-fetched maxId — sending one
// here too with the chat list's possibly-stale room.lastMessage.id
// would race the fresh one and could regress the server cursor.
context.read<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id);
NotificationTasks.clearNotificationsForChat(room.token);
TalkNavigator.pushSplitView(
context,
ChatView(room: room, selfId: selfId, avatar: avatar),
+208 -11
View File
@@ -1,15 +1,53 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../chat_list/bloc/chat_list_bloc.dart';
import '../repository/chat_repository.dart';
import 'chat_event.dart';
import 'chat_state.dart';
class ChatBloc
extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository> {
extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository>
with WidgetsBindingObserver {
final ChatListBloc? _chatListBloc;
String? _pollingToken;
int _backoffMs = 0;
int _lastKnownMessageId = 0;
bool _appResumed = true;
/// True only while a ChatView is mounted. Can't reuse `currentToken` —
/// clearing it on leaveChat races with setToken from didPopNext when
/// popping a stacked chat, causing spurious server read-markers on resume.
bool _chatViewActive = false;
bool get hasOpenChat => _chatViewActive;
DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0);
ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc {
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> close() {
WidgetsBinding.instance.removeObserver(this);
_stopLongPoll();
return super.close();
}
@override
ChatRepository repository() => ChatRepository();
@@ -33,24 +71,70 @@ class ChatBloc
}
void setToken(String token) {
_chatViewActive = true;
if (token == (innerState?.currentToken ?? '')) {
refresh();
return;
}
_stopLongPoll();
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
add(RefetchStarted<ChatState>());
_loadChat(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
_scheduleLoad(token);
}
void refresh() {
final token = innerState?.currentToken ?? '';
if (token.isEmpty) return;
add(RefetchStarted<ChatState>());
_loadChat(token);
_scheduleLoad(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
}
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
_stopLongPoll();
}
Future<void> sendServerReadMarker(String token, int messageId) async {
try {
await SetReadMarker(
token,
true,
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: messageId),
).run();
} on Object catch (e) {
log('Server read-marker for $token failed: $e');
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasResumed = _appResumed;
_appResumed = state == AppLifecycleState.resumed;
if (!_appResumed) {
_stopLongPoll();
return;
}
if (wasResumed) return;
final token = innerState?.currentToken ?? '';
if (token.isNotEmpty && _chatViewActive) refresh();
}
/// Microtask hop so the Bloc worker drains the preceding Emit before
/// any cache callback fires — a quick cache hit otherwise runs with
/// the previous token in state and fails stillCurrent().
void _scheduleLoad(String token) {
Future<void>.microtask(() {
if (isClosed) return;
_loadChat(token).then((_) => _startLongPoll(token));
});
}
Future<void> _loadChat(String token) async {
@@ -69,14 +153,25 @@ class ChatBloc
token: token,
onCacheData: (data) {
if (!stillCurrent()) return;
// Cache hit: show data immediately but preserve lastFetch — the
// cached payload may be stale and we don't want the UI to claim a
// fresh fetch just happened.
// Skip cache paint over already-merged long-poll data — would
// visibly drop those messages until the network call resolves.
if (innerState?.chatResponse != null) return;
add(Emit((s) => s.copyWith(chatResponse: data)));
},
onNetworkData: (data) {
// Mark runs even if no longer current — otherwise a quick
// navigation away leaves the server cursor stale. Cache check
// skips the POST when the cursor is already at maxId.
final maxId = _maxMessageId(data);
if (maxId > 0) {
final cached = _chatListBloc?.lastReadMessageFor(token);
if (cached == null || cached < maxId) {
unawaited(sendServerReadMarker(token, maxId));
}
}
if (!stillCurrent()) return;
add(DataGathered((s) => s.copyWith(chatResponse: data)));
_applyChatResponse(data);
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
},
onError: (e) => capturedError = e,
);
@@ -98,4 +193,106 @@ class ChatBloc
);
}
}
void _startLongPoll(String token) {
if (!_appResumed) return;
if (_pollingToken == token) return;
_stopLongPoll();
_pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token));
}
void _stopLongPoll() {
_pollingToken = null;
_backoffMs = 0;
}
Future<void> _pollLoop(String token) async {
while (_pollingToken == token && !isClosed) {
try {
final response = await LongPollChat(
chatToken: token,
lastKnownMessageId: _lastKnownMessageId,
).run();
if (_pollingToken != token || isClosed) return;
_backoffMs = 0;
if (response == null) continue;
final headerId = int.tryParse(
response.headers?[_kLongPollLastGivenHeader] ?? '',
);
if (headerId != null && headerId > _lastKnownMessageId) {
_lastKnownMessageId = headerId;
}
if (response.data.isEmpty) continue;
_applyChatResponse(response);
final maxId = _maxMessageId(response);
if (maxId > _lastKnownMessageId) _lastKnownMessageId = maxId;
// Long-poll's setReadMarker=on moved the server cursor; mirror locally.
final preview = _pickDisplayMessage(response);
if (preview != null) {
_chatListBloc?.applyIncomingMessage(token, preview);
} else {
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
}
} on Object catch (e) {
if (_pollingToken != token || isClosed) return;
log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs));
}
}
}
/// Dedups by id with newer-wins so server edits/deletes propagate.
void _applyChatResponse(GetChatResponse incoming) {
final current = innerState?.chatResponse;
if (current == null) {
add(DataGathered((s) => s.copyWith(chatResponse: incoming)));
return;
}
final byId = <int, GetChatResponseObject>{};
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in incoming.data) {
byId[m.id] = m;
}
final merged = GetChatResponse(byId.values.toSet())
..headers = incoming.headers;
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
}
int _maxMessageId(GetChatResponse? response) {
if (response == null) return 0;
var max = 0;
for (final m in response.data) {
if (m.id > max) max = m.id;
}
return max;
}
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
GetChatResponseObject? best;
for (final m in response.data) {
switch (m.messageType) {
case GetRoomResponseObjectMessageType.comment:
case GetRoomResponseObjectMessageType.voiceMessage:
if (best == null || m.id > best.id) best = m;
case GetRoomResponseObjectMessageType.deletedComment:
case GetRoomResponseObjectMessageType.system:
case GetRoomResponseObjectMessageType.command:
break;
}
}
return best;
}
}
const _kLongPollLastGivenHeader = 'x-chat-last-given';
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -15,6 +17,8 @@ class ChatListBloc
extends
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
bool _forceRenew = false;
Timer? _autoRefreshTimer;
Duration? _autoRefreshInterval;
@override
void retry() {
@@ -22,6 +26,25 @@ class ChatListBloc
super.retry();
}
@override
Future<void> close() {
_autoRefreshTimer?.cancel();
return super.close();
}
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
_autoRefreshInterval = interval;
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
if (interval == null) return;
_autoRefreshTimer = Timer.periodic(interval, (_) {
if (isClosed) return;
refresh(silent: true);
});
}
@override
ChatListRepository repository() => ChatListRepository();
@@ -51,8 +74,8 @@ class ChatListBloc
if (capturedError != null) throw capturedError!;
}
Future<void> refresh({bool renew = true}) async {
add(RefetchStarted<ChatListState>());
Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError;
try {
final rooms = await repo.data.getRooms(
@@ -82,6 +105,65 @@ class ChatListBloc
await refresh();
}
int? lastReadMessageFor(String token) {
final rooms = innerState?.rooms;
if (rooms == null) return null;
for (final room in rooms.data) {
if (room.token == token) return room.lastReadMessage;
}
return null;
}
/// Optimistic — server-side mark-as-read is the caller's job.
void markRoomAsRead(String token, int lastMessageId) {
_mutateRoom(token, (r) {
if (r.unreadMessages == 0 && r.lastReadMessage >= lastMessageId) {
return false;
}
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (lastMessageId > r.lastReadMessage) r.lastReadMessage = lastMessageId;
return true;
});
}
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
final wasRead =
r.unreadMessages == 0 && r.lastReadMessage >= message.id;
final hasNewer = r.lastMessage.id >= message.id;
if (wasRead && hasNewer) return false;
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (message.id > r.lastReadMessage) r.lastReadMessage = message.id;
if (message.id > r.lastMessage.id) r.lastMessage = message;
if (message.timestamp > r.lastActivity) r.lastActivity = message.timestamp;
return true;
});
}
/// Re-wraps in a fresh [GetRoomResponse] so identity-based equality picks it up.
void _mutateRoom(
String token,
bool Function(GetRoomResponseObject room) mutator,
) {
final rooms = innerState?.rooms;
if (rooms == null) return;
var changed = false;
final updated = rooms.data.map((r) {
if (r.token != token) return r;
if (mutator(r)) changed = true;
return r;
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}
void _updateAppBadge(GetRoomResponse rooms) {
try {
final unread = rooms.data.fold<int>(
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
initialDescription: event.description,
initialStart: event.start,
initialEnd: event.end,
initialAllDay: event.isAllDay,
),
barrierDismissible: false,
),
@@ -67,12 +67,12 @@ class AboutSection extends StatelessWidget {
applicationIcon: const Icon(Icons.apps),
applicationName: 'MarianumMobile',
applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
'${appInfo.appName}\n\n${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild/Relase-nummer: ${appInfo.buildNumber}',
applicationLegalese:
'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
"${kReleaseMode ? "Production" : "Development"} build\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
"${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
'Marianum Fulda 2019-2020, 2023-${Jiffy.now().year}\nElias Müller',
);
}
@@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget {
),
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'),
title: const Text('Infos zu (Web) Untis'),
subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
@@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget {
Icon(Icons.send_time_extension_outlined),
),
title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'),
subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'mhsl',
+11 -15
View File
@@ -7,7 +7,6 @@ import '../../../notification/notify_updater.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -19,15 +18,13 @@ import 'search_chat.dart';
import 'widgets/chat_tile.dart';
import 'widgets/split_view_placeholder.dart';
// Reads from the global ChatListBloc in main.dart — re-providing a local
// one here would shadow it and split the state in two.
class ChatList extends StatelessWidget {
const ChatList({super.key});
@override
Widget build(BuildContext context) =>
BlocModule<ChatListBloc, LoadableState<ChatListState>>(
create: (_) => ChatListBloc(),
child: (context, bloc, _) => const _ChatListView(),
);
Widget build(BuildContext context) => const _ChatListView();
}
class _ChatListView extends StatefulWidget {
@@ -65,14 +62,6 @@ class _ChatListViewState extends State<_ChatListView> {
final resolved = AppRoutes.resolvePendingChat(context);
if (resolved == null) return;
AppRoutes.pendingChatToken.value = null;
// Replace any chat already pushed on top of the chat list so a freshly
// tapped notification doesn't stack indefinitely on previous chats.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
}
AppRoutes.openChatView(
context,
room: resolved.room,
@@ -193,7 +182,14 @@ class _ChatListViewState extends State<_ChatListView> {
.talkSettings
.drafts
.containsKey(room.token);
return ChatTile(data: room, hasDraft: hasDraft);
// Stable key keeps element identity across re-sorts so the
// inner UserAvatar reuses its cached bytes instead of
// flashing on every list update.
return ChatTile(
key: ValueKey(room.token),
data: room,
hasDraft: hasDraft,
);
}).toList(),
);
},
+66 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
@@ -7,9 +8,12 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../extensions/date_time.dart';
import '../../../notification/notification_tasks.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../state/app/modules/chat/bloc/chat_state.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../theming/app_theme.dart';
import '../../../widget/clickable_app_bar.dart';
import '../../../widget/user_avatar.dart';
@@ -36,7 +40,7 @@ class ChatView extends StatefulWidget {
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> {
class _ChatViewState extends State<ChatView> with RouteAware {
final ItemScrollController _itemScrollController = ItemScrollController();
final TextEditingController _searchTextController = TextEditingController();
final Map<int, int> _matchIndices = {};
@@ -48,12 +52,73 @@ class _ChatViewState extends State<ChatView> {
GetChatResponse? _matchesComputedFor;
String? _matchesComputedQuery;
// Captured in initState because the framework has unmounted us by the
// time dispose runs.
ChatBloc? _chatBlocRef;
ChatListBloc? _chatListBlocRef;
PageRoute<dynamic>? _subscribedRoute;
@override
void initState() {
super.initState();
_chatBlocRef = context.read<ChatBloc>();
_chatListBlocRef = context.read<ChatListBloc>();
NotificationTasks.clearNotificationsForChat(widget.room.token);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is PageRoute && route != _subscribedRoute) {
if (_subscribedRoute != null) {
AppRoutes.chatRouteObserver.unsubscribe(this);
}
AppRoutes.chatRouteObserver.subscribe(this, route);
_subscribedRoute = route;
}
}
@override
void didPopNext() {
super.didPopNext();
// A stacked chat above us was just popped (typical: notification tap
// opened another chat). The global ChatBloc currently points at that
// other chat's token, so our isReady predicate fails until we re-claim.
_chatBlocRef?.setToken(widget.room.token);
}
@override
void dispose() {
if (_subscribedRoute != null) {
AppRoutes.chatRouteObserver.unsubscribe(this);
}
_markAsReadFinal();
_chatBlocRef?.leaveChat(widget.room.token);
_searchTextController.dispose();
super.dispose();
}
/// Defensive final mark-as-read so a back-out before the long-poll
/// could fire doesn't leave the room as unread. Skipped when the bloc
/// has already moved on to another chat — the response data there
/// belongs to a different room, and writing its max-id as our marker
/// would regress our server cursor.
void _markAsReadFinal() {
final state = _chatBlocRef?.state.data;
if (state == null) return;
if (state.currentToken != widget.room.token) return;
final response = state.chatResponse;
if (response == null) return;
var maxId = 0;
for (final m in response.data) {
if (m.id > maxId) maxId = m.id;
}
if (maxId == 0) return;
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId);
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId));
}
@override
void didUpdateWidget(covariant ChatView oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -30,9 +30,6 @@ RichObjectString? _attachedFile(GetChatResponseObject bubbleData) {
return file;
}
/// Long-press / double-tap options dialog for a single chat message bubble.
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
/// this file owns the modal interactions (react, reply, copy, delete, ...).
void showChatMessageOptionsDialog(
BuildContext context, {
required GetRoomResponseObject chatData,
@@ -183,10 +180,12 @@ void _openOrCreateDirectChat(
}
void switchToChat(GetRoomResponseObject room) {
// Pop the current ChatView before swapping the global ChatBloc token —
// otherwise the previous group chat stays mounted in the back-stack and
// would render empty after a back-swipe (currentToken no longer matches).
Navigator.of(context).popUntil((route) => route.isFirst);
// Pop the previous ChatView first — otherwise it stays in the
// back-stack with a now-mismatched currentToken and renders empty
// on back-swipe. Stop at popups so an open dialog stays alive.
Navigator.of(
context,
).popUntil((route) => route.isFirst || route is PopupRoute);
AppRoutes.openChatByToken(context, room.token);
}
+13 -16
View File
@@ -7,9 +7,10 @@ import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/account_data.dart';
import '../../../../notification/notification_tasks.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart';
@@ -17,7 +18,6 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart';
import '../chat_view.dart';
import '../talk_navigator.dart';
class ChatTile extends StatefulWidget {
@@ -61,13 +61,11 @@ class _ChatTileState extends State<ChatTile> {
void _refreshList() => context.read<ChatListBloc>().refresh();
Future<void> _setCurrentAsRead() async {
await SetReadMarker(
widget.data.token,
true,
setReadMarkerParams: SetReadMarkerParams(
lastReadMessage: widget.data.lastMessage.id,
),
).run();
final token = widget.data.token;
final lastId = widget.data.lastMessage.id;
context.read<ChatListBloc>().markRoomAsRead(token, lastId);
unawaited(NotificationTasks.clearNotificationsForChat(token));
await context.read<ChatBloc>().sendServerReadMarker(token, lastId);
if (!mounted) return;
_refreshList();
}
@@ -154,18 +152,17 @@ class _ChatTileState extends State<ChatTile> {
return;
}
if (selfUsername == null) return;
unawaited(_setCurrentAsRead());
final view = ChatView(
// openChatView is the single entry point for opening a chat —
// it handles optimistic mark-as-read, tray cleanup, push, and
// setToken in one place so the notification-tap path gets the
// same treatment as a tile tap.
AppRoutes.openChatView(
context,
room: widget.data,
selfId: selfUsername!,
avatar: circleAvatar,
);
TalkNavigator.pushSplitView(
context,
view,
overrideToSingleSubScreen: true,
);
context.read<ChatBloc>().setToken(widget.data.token);
},
onLongPress: () {
if (widget.disableContextActions) return;
@@ -78,34 +78,53 @@ class HighlightedLinkify extends StatefulWidget {
}
class _HighlightedLinkifyState extends State<HighlightedLinkify> {
final List<TapGestureRecognizer> _recognizers = [];
// Cached per link text — search rebuilds keystroke-by-keystroke
// would otherwise churn allocate/dispose. Pruned via [_seenLinkKeys].
final Map<String, TapGestureRecognizer> _recognizers = {};
final Set<String> _seenLinkKeys = {};
@override
void dispose() {
for (final r in _recognizers) {
for (final r in _recognizers.values) {
r.dispose();
}
_recognizers.clear();
super.dispose();
}
TapGestureRecognizer _recognizerFor(LinkableElement el) {
final key = el.text;
final existing = _recognizers[key];
if (existing != null) {
// Refresh onTap so a parent rebuild's new closure is picked up.
existing.onTap = () => widget.onOpen?.call(el);
return existing;
}
final created = TapGestureRecognizer()
..onTap = () => widget.onOpen?.call(el);
_recognizers[key] = created;
return created;
}
void _pruneUnseen() {
final stale = _recognizers.keys
.where((k) => !_seenLinkKeys.contains(k))
.toList(growable: false);
for (final k in stale) {
_recognizers.remove(k)?.dispose();
}
}
@override
Widget build(BuildContext context) {
for (final r in _recognizers) {
r.dispose();
}
_recognizers.clear();
_seenLinkKeys.clear();
final defaultStyle = widget.style ??
Theme.of(context).textTheme.bodyMedium ??
DefaultTextStyle.of(context).style;
// Start from the surrounding text style so links inherit font family,
// size, weight, etc., then layer the link-specific color and underline
// on top. (Going the other way around — link style as base — used to
// work because TextStyle.copyWith treats `null` as "leave unchanged",
// so the explicit `color: null, decoration: null` were silently
// ignored and the merge pulled defaultStyle's color/decoration over
// the blue + underline. Result: links rendered in body-text color
// with no underline.)
// Default first, link style on top — reversing the merge silently
// drops link color/underline because TextStyle.merge treats explicit
// nulls in the overlay as "leave unchanged".
final linkStyle = defaultStyle.merge(
widget.linkStyle ??
const TextStyle(
@@ -124,9 +143,8 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
for (final el in elements) {
if (el is LinkableElement) {
final recognizer = TapGestureRecognizer()
..onTap = () => widget.onOpen?.call(el);
_recognizers.add(recognizer);
_seenLinkKeys.add(el.text);
final recognizer = _recognizerFor(el);
spans.addAll(
buildHighlightedSpans(
text: el.text,
@@ -147,6 +165,8 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
}
}
_pruneUnseen();
return Text.rich(TextSpan(children: spans));
}
}
@@ -19,6 +19,7 @@ class CustomEventEditDialog extends StatefulWidget {
final DateTime? initialEnd;
final String? initialTitle;
final String? initialDescription;
final bool? initialAllDay;
const CustomEventEditDialog({
this.existingEvent,
@@ -26,6 +27,7 @@ class CustomEventEditDialog extends StatefulWidget {
this.initialEnd,
this.initialTitle,
this.initialDescription,
this.initialAllDay,
super.key,
});
@@ -78,12 +80,17 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
}
return;
}
_isAllDay = false;
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd;
final clamped = _clampToVisibleWindow(rawStart, rawEnd);
_startTime = clamped.$1;
_endTime = clamped.$2;
_isAllDay = widget.initialAllDay ?? false;
if (_isAllDay) {
_startTime = _defaultStart;
_endTime = _defaultEnd;
} else {
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd;
final clamped = _clampToVisibleWindow(rawStart, rawEnd);
_startTime = clamped.$1;
_endTime = clamped.$2;
}
}
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
@@ -11,7 +10,6 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/unimplemented_dialog.dart';
class WebuntisLessonSheet {
static void show(
@@ -72,7 +70,7 @@ class WebuntisLessonSheet {
}).toList(),
),
_roomTile(context, state, lesson),
_teacherTile(context, lesson),
_teacherTile(lesson),
if ((lesson.activityType ?? '').trim().isNotEmpty)
ListTile(
leading: const Icon(Icons.abc),
@@ -120,14 +118,15 @@ class WebuntisLessonSheet {
final name = firstNonEmpty([resolved.name, r.name, '?']);
final longname = firstNonEmpty([resolved.longName, r.longname, '']);
final building = resolved.building.trim();
return LessonFormatter.formatLine(
final main = LessonFormatter.formatLine(
name,
longname: longname,
extra: (building.isNotEmpty && building != '?') ? building : null,
);
final sub = (longname.isNotEmpty && longname != name) ? longname : null;
return (main: main, sub: sub);
}).toList();
return _listTile(
return _listTileWithSubs(
icon: Icons.room,
label: lesson.ro.length == 1 ? 'Raum' : 'Räume',
entries: entries,
@@ -135,39 +134,63 @@ class WebuntisLessonSheet {
);
}
static Widget _teacherTile(
BuildContext context,
GetTimetableResponseObject lesson,
) {
final trailing = Visibility(
visible: !kReleaseMode,
child: IconButton(
icon: const Icon(Icons.textsms_outlined),
onPressed: () => UnimplementedDialog.show(context),
),
);
static Widget _teacherTile(GetTimetableResponseObject lesson) {
if (lesson.te.isEmpty) {
return ListTile(
leading: const Icon(Icons.person),
title: const Text('Lehrkraft: ?'),
trailing: trailing,
return const ListTile(
leading: Icon(Icons.person),
title: Text('Lehrkraft: ?'),
);
}
final entries = lesson.te.map((t) {
final base = LessonFormatter.formatLine(
final main = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?',
longname: t.longname,
);
final orgname = (t.orgname ?? '').trim();
return orgname.isEmpty ? base : '$base · ehemals $orgname';
return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname');
}).toList();
return _listTile(
return _listTileWithSubs(
icon: Icons.person,
label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
entries: entries,
);
}
static Widget _listTileWithSubs({
required IconData icon,
required String label,
required List<({String main, String? sub})> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
final e = entries.first;
return ListTile(
leading: Icon(icon),
title: Text('$label: ${e.main}'),
subtitle: e.sub != null ? Text(e.sub!) : null,
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries
.expand<Widget>(
(e) => [
Text(e.main),
if (e.sub != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(e.sub!),
),
],
)
.toList(),
),
trailing: trailing,
);
}
@@ -37,40 +37,10 @@ class AppointmentTile extends StatelessWidget {
borderRadius: _radius,
color: color,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
_AdaptiveTitle(
text: appointment.subject,
fontSize: kAppointmentTitleFontSize,
minFontSize: kAppointmentTitleMinFontSize,
fontWeight: FontWeight.w500,
),
if (isCustom) ...[
if (description.isNotEmpty)
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: _WrappingBody(
text: description,
fontSize: kAppointmentBodyFontSize,
lineHeight: kAppointmentBodyLineHeight,
),
),
),
] else ...[
for (final line
in description
.split('\n')
.where((p) => p.isNotEmpty)
.take(2))
_ScaledLine(
text: line,
fontSize: kAppointmentBodyFontSize,
),
],
],
child: _TileContent(
title: appointment.subject,
description: description,
isCustom: isCustom,
),
),
),
@@ -96,6 +66,91 @@ class AppointmentTile extends StatelessWidget {
}
}
/// Picks how many lines fit into the calendar slot's height. Title gets
/// first dibs; if not even one minimum-size title line fits, the column
/// collapses to keep the slot from overflowing.
class _TileContent extends StatelessWidget {
final String title;
final String description;
final bool isCustom;
const _TileContent({
required this.title,
required this.description,
required this.isCustom,
});
@override
Widget build(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final titleLineHeight = scaler.scale(kAppointmentTitleMinFontSize) * 1.1;
final bodyLineHeight = scaler.scale(kAppointmentBodyFontSize) * 1.1;
final titleWidget = _AdaptiveTitle(
text: title,
fontSize: kAppointmentTitleFontSize,
minFontSize: kAppointmentTitleMinFontSize,
fontWeight: FontWeight.w500,
);
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxHeight;
// Slot too short for even one min-size title line — drop text
// entirely; the coloured rectangle is enough.
if (available < titleLineHeight) return const SizedBox.shrink();
final remaining =
(available - titleLineHeight).clamp(0.0, double.infinity);
final bodyLineCapacity = (remaining / bodyLineHeight).floor();
if (isCustom) {
if (description.isEmpty || bodyLineCapacity <= 0) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [titleWidget],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
titleWidget,
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: _WrappingBody(
text: description,
fontSize: kAppointmentBodyFontSize,
lineHeight: kAppointmentBodyLineHeight,
),
),
),
],
);
}
final maxBodyLines = bodyLineCapacity.clamp(0, 2);
final lines = description
.split('\n')
.where((p) => p.isNotEmpty)
.take(maxBodyLines)
.toList(growable: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
titleWidget,
for (final line in lines)
_ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
],
);
},
);
}
}
/// Renders the appointment title. Scales down to fit the available width via
/// [FittedBox], but never below [minFontSize] — when even the minimum size
/// overflows, the text is rendered at [minFontSize] with an ellipsis.
+27 -39
View File
@@ -26,9 +26,8 @@ class FileViewer extends StatefulWidget {
final String path;
final bool openExternal;
/// When set, enables the in-app actions "An Chat senden" and "In Dateien
/// speichern" — these need a server-side reference, not the local cache
/// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer).
/// Enables in-app "An Chat senden" / "In Dateien speichern" — these
/// need a server-side reference instead of the local cache path.
final RemoteFileRef? remoteFile;
const FileViewer({
@@ -56,8 +55,6 @@ const Set<String> _imageExtensions = {
'wbmp',
};
/// Video container formats whose playback the platform decoders (ExoPlayer
/// on Android, AVPlayer on iOS) handle out of the box.
const Set<String> _videoExtensions = {
'mp4',
'm4v',
@@ -67,9 +64,8 @@ const Set<String> _videoExtensions = {
'3gp',
};
/// Audio formats playable through the same `video_player` pipeline. Some
/// (ogg/opus/flac) work on Android only — iOS will surface an init error
/// which we catch and surface as a friendly fallback.
/// ogg/opus/flac are Android-only; iOS init errors fall through to the
/// "format not supported" message.
const Set<String> _audioExtensions = {
'mp3',
'm4a',
@@ -81,9 +77,7 @@ const Set<String> _audioExtensions = {
'opus',
};
/// Extensions whose contents we render directly as plain text. Anything
/// outside this list still gets a content-based fallback check (see
/// [_looksLikeText]) so generic "what is this file" cases work too.
/// Unknown extensions still get a content sniff via [_looksLikeText].
const Set<String> _textExtensions = {
'txt', 'md', 'markdown', 'rst', 'log',
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
@@ -104,10 +98,7 @@ const Set<String> _textExtensions = {
'srt', 'vtt',
};
/// Reads up to 8 KB and decides whether the bytes look like UTF-8 text.
/// NUL bytes and non-decodable sequences disqualify the file. Used as a
/// fallback for unknown extensions so plain text files without a familiar
/// suffix still open in the in-app viewer.
/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
Future<bool> _looksLikeText(String path) async {
final file = File(path);
RandomAccessFile? raf;
@@ -126,10 +117,8 @@ Future<bool> _looksLikeText(String path) async {
}
}
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
/// ancestor RenderTransform (from the page-push animation) is still mid-layout.
/// We wait for the route's enter animation to complete before mounting it.
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
/// animation. Defer until the route enter animation completes.
class _DeferredPdfViewer extends StatefulWidget {
const _DeferredPdfViewer({required this.path});
final String path;
@@ -189,8 +178,6 @@ class _FileViewerState extends State<FileViewer> {
settings.val().fileViewSettings.alwaysOpenExternally ||
widget.openExternal;
if (openExternal) {
// Settings or popup explicitly chose "open externally" — fire and
// forget, then pop back. Same one-shot behaviour as the old viewer.
WidgetsBinding.instance.addPostFrameCallback(
(_) => _openExternallyAndPop(),
);
@@ -254,7 +241,22 @@ class _FileViewerState extends State<FileViewer> {
break;
case FileViewingActions.save:
try {
final bytes = await File(widget.path).readAsBytes();
final source = File(widget.path);
final size = await source.length();
// file_picker has no path/stream save API, so the whole file
// gets loaded into RAM. Cap big media; user falls back to share.
const maxBytes = 200 * 1024 * 1024;
if (size > maxBytes) {
if (!mounted) return;
InfoDialog.show(
context,
'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), '
'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.',
title: 'Speichern nicht möglich',
);
return;
}
final bytes = await source.readAsBytes();
final saved = await FilePicker.saveFile(
fileName: widget.path.split('/').last,
bytes: bytes,
@@ -279,8 +281,6 @@ class _FileViewerState extends State<FileViewer> {
List<_ActionDescriptor> _availableActions() => [
_ActionDescriptor(
action: FileViewingActions.openExternal,
// iOS opens the system share sheet (square-with-arrow icon), Android
// the standard app picker; mirror that visually and verbally.
icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
),
@@ -440,8 +440,7 @@ class _FileViewerState extends State<FileViewer> {
}
final payload = snapshot.data!;
final lines = const LineSplitter().convert(payload.content);
// Reserve gutter width by the digit count of the highest line number,
// so the gutter stays stable as the user scrolls down.
// Stable gutter width — sized by the highest line number's digit count.
final gutterWidth = (lines.length.toString().length * 9.0) + 16;
return SelectionArea(
child: Scrollbar(
@@ -545,8 +544,7 @@ class _FileViewerState extends State<FileViewer> {
final raf = await file.open();
try {
final bytes = await raf.read(_textViewMaxBytes);
// Truncated payloads cannot be reliably re-formatted (parser will
// choke on the dangling tail), so they stay raw.
// Truncated payloads stay raw — a parser would choke on the dangling tail.
return _TextPayload(
content: utf8.decode(bytes, allowMalformed: true),
truncated: true,
@@ -556,9 +554,7 @@ class _FileViewerState extends State<FileViewer> {
}
}
/// Re-indents JSON so dumped/minified payloads from the server are easier
/// to read. Falls through to the original text on parse errors so we
/// never destroy the user's content.
/// Falls through to the original text on parse errors.
String _maybePrettify(String content, String ext) {
if (ext != 'json') return content;
try {
@@ -587,10 +583,6 @@ class _TextPayload {
const _TextPayload({required this.content, required this.truncated});
}
/// Plays back a local file via `video_player`. Renders the standard Chewie
/// controls for video files; audio files get a centered icon plus a custom
/// transport row (slider, time, play/pause), since Chewie's chrome is
/// designed around a video frame.
class _MediaPlayer extends StatefulWidget {
final String path;
final bool isAudio;
@@ -780,10 +772,6 @@ class _AudioControls extends StatelessWidget {
}
}
/// One row in the text viewer: line number on the left (not selectable so
/// it never ends up in copied selections), monospace content on the right.
/// Odd-numbered lines get a slightly tinted background so long files are
/// easier to scan.
class _CodeLine extends StatelessWidget {
final int number;
final String text;
+89 -47
View File
@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
@@ -29,15 +30,48 @@ class _AvatarPayload {
_AvatarPayload(this.bytes, this.isSvg);
}
final Map<String, Future<_AvatarPayload?>> _avatarCache = {};
class _AvatarCacheEntry {
final _AvatarPayload? payload;
final DateTime fetchedAt;
_AvatarCacheEntry(this.payload, this.fetchedAt);
}
// LRU via LinkedHashMap insertion order + remove-on-hit. TTL so
// server-side avatar updates become visible within a session.
const int _kAvatarCacheMax = 256;
const Duration _kAvatarCacheTtl = Duration(minutes: 30);
// Pending map dedups concurrent mounts onto a single HTTP call.
final LinkedHashMap<String, _AvatarCacheEntry> _resolvedAvatars =
LinkedHashMap<String, _AvatarCacheEntry>();
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
_AvatarCacheEntry? _readAvatarCache(String url) {
final entry = _resolvedAvatars.remove(url);
if (entry == null) return null;
if (DateTime.now().difference(entry.fetchedAt) > _kAvatarCacheTtl) {
return null;
}
// Re-insert at the tail so it counts as most-recently-used.
_resolvedAvatars[url] = entry;
return entry;
}
void _writeAvatarCache(String url, _AvatarPayload? payload) {
_resolvedAvatars.remove(url);
_resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now());
while (_resolvedAvatars.length > _kAvatarCacheMax) {
_resolvedAvatars.remove(_resolvedAvatars.keys.first);
}
}
class _UserAvatarState extends State<UserAvatar> {
late Future<_AvatarPayload?> _payload;
_AvatarPayload? _payload;
@override
void initState() {
super.initState();
_payload = _load();
_attach();
}
@override
@@ -46,7 +80,7 @@ class _UserAvatarState extends State<UserAvatar> {
if (oldWidget.id != widget.id ||
oldWidget.isGroup != widget.isGroup ||
oldWidget.size != widget.size) {
_payload = _load();
_attach();
}
}
@@ -58,9 +92,21 @@ class _UserAvatarState extends State<UserAvatar> {
return 'https://$host/avatar/${widget.id}/${widget.size}';
}
Future<_AvatarPayload?> _load() {
void _attach() {
final url = _url();
return _avatarCache.putIfAbsent(url, () => _fetch(url));
final cached = _readAvatarCache(url);
if (cached != null) {
_payload = cached.payload;
return;
}
_payload = null;
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
pending.then((p) {
_writeAvatarCache(url, p);
_pendingAvatars.remove(url);
if (!mounted || _url() != url) return;
setState(() => _payload = p);
});
}
Future<_AvatarPayload?> _fetch(String url) async {
@@ -97,49 +143,45 @@ class _UserAvatarState extends State<UserAvatar> {
Widget build(BuildContext context) {
final radius = widget.size.toDouble();
final theme = Theme.of(context);
final payload = _payload;
return FutureBuilder<_AvatarPayload?>(
future: _payload,
builder: (context, snapshot) {
final payload = snapshot.data;
Widget content;
if (payload == null) {
content = Icon(
widget.isGroup ? Icons.group : Icons.person,
size: radius,
color: Colors.white,
);
} else if (payload.isSvg) {
content = SvgPicture.memory(
payload.bytes,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
);
} else {
content = Image.memory(
payload.bytes,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
gaplessPlayback: true,
);
}
return CircleAvatar(
radius: radius,
backgroundColor: theme.primaryColor,
foregroundColor: Colors.white,
child: ClipOval(
child: SizedBox(
width: radius * 2,
height: radius * 2,
child: content,
),
),
Widget content;
if (payload != null) {
if (payload.isSvg) {
content = SvgPicture.memory(
payload.bytes,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
);
},
} else {
content = Image.memory(
payload.bytes,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
gaplessPlayback: true,
);
}
} else {
content = Icon(
widget.isGroup ? Icons.group : Icons.person,
size: radius,
color: Colors.white,
);
}
return CircleAvatar(
radius: radius,
backgroundColor: theme.primaryColor,
foregroundColor: Colors.white,
child: ClipOval(
child: SizedBox(
width: radius * 2,
height: radius * 2,
child: content,
),
),
);
}
}
+5
View File
@@ -40,6 +40,11 @@ dependencies:
flutter_linkify: ^6.0.0
linkify: ^5.0.0
flutter_local_notifications: ^21.0.0
# Cancels FCM-rendered notifications by their server-set tag
# (Android NotificationManager.cancel, iOS removeDeliveredNotifications via
# apns-collapse-id). Used to dismiss a chat's notification when the user
# opens or marks the chat read.
eraser: ^3.0.0
scrollable_positioned_list: ^0.3.8
flutter_split_view: ^0.1.2
flutter_svg: ^2.0.10