Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ae3f7bb83 | |||
| 8c76f2d816 | |||
| c46f14f6a6 | |||
| b2b00d321e | |||
| 1a11b9ac60 | |||
| a0bc46f522 | |||
| 1458d8ce49 | |||
| 6ae396e605 | |||
| ed2badfd35 | |||
| 1ff57b29f9 | |||
| c50a850ac9 | |||
| 15833f3685 | |||
| bf28a678c9 | |||
| 14090b96f4 | |||
| 8e6b1877cc | |||
| 9accb488f2 | |||
| 79a6d9a594 | |||
| 7d02e70459 | |||
| 4c190de479 |
@@ -73,16 +73,34 @@
|
||||
android:resource="@xml/timetable_week_widget_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility?hl=en and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<!-- Required so url_launcher / can_launch can actually see browsers,
|
||||
mail clients and dialers under Android 11+ package-visibility rules
|
||||
(otherwise UrlLauncher logs "component name for ... is null" and
|
||||
link taps in Talk silently do nothing). The PROCESS_TEXT intent is
|
||||
needed by io.flutter.plugin.text.ProcessTextPlugin (selection
|
||||
menu).
|
||||
See https://developer.android.com/training/package-visibility -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="mailto"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="tel"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- Workmanager periodic widget refresh needs to reschedule after device
|
||||
|
||||
@@ -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" />
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: [:]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -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>
|
||||
@@ -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 1–8 oben (~15 Min).
|
||||
- Auf physischem iPhone testen — Simulator-Share-Sheet ist eingeschränkt.
|
||||
@@ -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,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 1–2 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 1–5 oben in Xcode durchklicken (10–15 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.
|
||||
@@ -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)
|
||||
@@ -135,7 +134,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
|
||||
@@ -170,9 +168,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)
|
||||
}
|
||||
@@ -344,27 +339,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
|
||||
|
||||
@@ -131,7 +131,6 @@ struct TimetableWeekView: View {
|
||||
return TimeGridView(
|
||||
lessons: lessonsForDay,
|
||||
periods: data.periods,
|
||||
anchorDate: day,
|
||||
hourHeight: hourHeight,
|
||||
showRoom: !subjectOnly,
|
||||
showTeacher: !subjectOnly,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'search_files_response.dart';
|
||||
|
||||
/// Wraps the Nextcloud OCS Search Provider API for the `files` provider.
|
||||
/// Endpoint: `/ocs/v2.php/search/providers/files/search`.
|
||||
class SearchFiles {
|
||||
Future<SearchFilesResponse> run({
|
||||
required String term,
|
||||
int limit = 50,
|
||||
int? cursor,
|
||||
}) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'search/providers/files/search',
|
||||
queryParameters: {
|
||||
'term': term,
|
||||
'limit': limit.toString(),
|
||||
if (cursor != null) 'cursor': cursor.toString(),
|
||||
},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception(
|
||||
'Files search failed with ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final ocs = decoded['ocs'] as Map<String, dynamic>;
|
||||
final data = ocs['data'] as Map<String, dynamic>;
|
||||
return SearchFilesResponse.fromJson(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../webdav/queries/list_files/cacheable_file.dart';
|
||||
|
||||
part 'search_files_response.g.dart';
|
||||
|
||||
/// Subset of the OCS Search Provider API response we actually consume.
|
||||
/// The provider (`files`) returns one object per match plus pagination state.
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class SearchFilesResponse {
|
||||
final String name;
|
||||
final bool isPaginated;
|
||||
final int? cursor;
|
||||
final List<SearchFilesEntry> entries;
|
||||
|
||||
SearchFilesResponse({
|
||||
required this.name,
|
||||
required this.isPaginated,
|
||||
required this.cursor,
|
||||
required this.entries,
|
||||
});
|
||||
|
||||
factory SearchFilesResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$SearchFilesResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SearchFilesResponseToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SearchFilesEntry {
|
||||
final String title;
|
||||
final String? subline;
|
||||
final String? icon;
|
||||
final String? resourceUrl;
|
||||
final Map<String, dynamic>? attributes;
|
||||
|
||||
SearchFilesEntry({
|
||||
required this.title,
|
||||
this.subline,
|
||||
this.icon,
|
||||
this.resourceUrl,
|
||||
this.attributes,
|
||||
});
|
||||
|
||||
factory SearchFilesEntry.fromJson(Map<String, dynamic> json) =>
|
||||
_$SearchFilesEntryFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SearchFilesEntryToJson(this);
|
||||
|
||||
/// Heuristic — the files provider sets icon classes containing "folder" for
|
||||
/// directories. Falls back to false when missing or unrecognised.
|
||||
bool get isDirectory => (icon ?? '').toLowerCase().contains('folder');
|
||||
|
||||
String? _stringAttribute(String key) {
|
||||
final raw = attributes?[key];
|
||||
return raw is String && raw.isNotEmpty ? raw : null;
|
||||
}
|
||||
|
||||
String? _dirFromResourceUrl() {
|
||||
final url = resourceUrl;
|
||||
if (url == null) return null;
|
||||
return Uri.tryParse(url)?.queryParameters['dir'];
|
||||
}
|
||||
|
||||
/// Reconstructs the WebDAV-relative path used elsewhere (matching
|
||||
/// [CacheableFile.path] — no leading slash, trailing slash for
|
||||
/// directories). Prefers the explicit `path` attribute set by Nextcloud's
|
||||
/// files search provider (28+); falls back to the `dir` query parameter
|
||||
/// in [resourceUrl]. Returns `null` when neither is available — `subline`
|
||||
/// is intentionally **not** parsed because it is localized UI text
|
||||
/// ("in {folder}"), not a path, and using it produced bogus duplicate
|
||||
/// folder headers like "/in Alte-Notebooks".
|
||||
String? get webdavPath {
|
||||
final attrPath = _stringAttribute('path');
|
||||
if (attrPath != null) {
|
||||
final stripped = attrPath.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return isDirectory ? '$stripped/' : stripped;
|
||||
}
|
||||
final dir = _dirFromResourceUrl();
|
||||
if (dir != null) {
|
||||
final stripped = dir.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
final base = stripped.isEmpty ? title : '$stripped/$title';
|
||||
return isDirectory ? '$base/' : base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
CacheableFile? toCacheable() {
|
||||
final path = webdavPath;
|
||||
if (path == null) return null;
|
||||
return CacheableFile(path: path, isDirectory: isDirectory, name: title);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_files_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
|
||||
SearchFilesResponse(
|
||||
name: json['name'] as String,
|
||||
isPaginated: json['isPaginated'] as bool,
|
||||
cursor: (json['cursor'] as num?)?.toInt(),
|
||||
entries: (json['entries'] as List<dynamic>)
|
||||
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SearchFilesResponseToJson(
|
||||
SearchFilesResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'isPaginated': instance.isPaginated,
|
||||
'cursor': instance.cursor,
|
||||
'entries': instance.entries.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
|
||||
SearchFilesEntry(
|
||||
title: json['title'] as String,
|
||||
subline: json['subline'] as String?,
|
||||
icon: json['icon'] as String?,
|
||||
resourceUrl: json['resourceUrl'] as String?,
|
||||
attributes: json['attributes'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
|
||||
<String, dynamic>{
|
||||
'title': instance.title,
|
||||
'subline': instance.subline,
|
||||
'icon': instance.icon,
|
||||
'resourceUrl': instance.resourceUrl,
|
||||
'attributes': instance.attributes,
|
||||
};
|
||||
@@ -66,7 +66,7 @@ class GetChatResponseObject {
|
||||
|
||||
static GetChatResponseObject getDateDummy(int timestamp) {
|
||||
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
||||
return getTextDummy(elementDate.formatDate());
|
||||
return getTextDummy(elementDate.formatDateRelativeShort());
|
||||
}
|
||||
|
||||
static GetChatResponseObject getTextDummy(String text) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
||||
super.onNetworkData,
|
||||
super.onError,
|
||||
required String path,
|
||||
super.renew = false,
|
||||
}) : super(
|
||||
cacheTime: RequestCache.cacheNothing,
|
||||
cacheTime: _cacheTimeFor(path),
|
||||
loader: () => ListFiles(ListFilesParams(path)).run(),
|
||||
fromJson: ListFilesResponse.fromJson,
|
||||
onUpdate: onUpdate,
|
||||
@@ -25,6 +26,44 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
||||
start(_documentId(path));
|
||||
}
|
||||
|
||||
/// The Nextcloud root listing is significantly slower than subfolders on
|
||||
/// our instance and frequently returns HTTP 500. Since its content rarely
|
||||
/// changes, the root payload is cached for a full day so app-resume and
|
||||
/// connectivity-change auto-refetch triggers do not re-hit the slow root
|
||||
/// endpoint within the same day. To avoid a long wait on the very first
|
||||
/// open of the Files page, `prefetchRootListing` (called from `main`)
|
||||
/// kicks off an async warm-up fetch in the background while the user is
|
||||
/// still on the launch screen / other modules. Subfolders keep the
|
||||
/// previous "always refetch on visit" TTL because their content changes
|
||||
/// more often. Explicit user refreshes (rename, delete, copy/move,
|
||||
/// upload) bypass the TTL via the inherited [renew] flag or via
|
||||
/// [invalidate].
|
||||
static int _cacheTimeFor(String path) {
|
||||
final stripped = path.replaceAll('/', '').trim();
|
||||
return stripped.isEmpty
|
||||
? RequestCache.cacheDay
|
||||
: RequestCache.cacheNothing;
|
||||
}
|
||||
|
||||
/// Triggers a root-listing fetch in the background if no cached payload
|
||||
/// exists yet. Intended to be called once after login from `main` so the
|
||||
/// (slow) root listing is already populated by the time the user
|
||||
/// navigates to the Files module.
|
||||
///
|
||||
/// No-ops when a cached root payload is already present in localstore —
|
||||
/// the regular TTL handling in [RequestCache] takes over from there.
|
||||
static Future<void> prefetchRootListing() async {
|
||||
const rootPath = '';
|
||||
final cached = await Localstore.instance
|
||||
.collection(RequestCache.collection)
|
||||
.doc(_documentId(rootPath))
|
||||
.get();
|
||||
if (cached != null) return;
|
||||
// Drive the same code path as a regular fetch so the result lands in
|
||||
// the cache; we don't care about the in-memory callback here.
|
||||
ListFilesCache(path: rootPath, onUpdate: (_) {});
|
||||
}
|
||||
|
||||
static String _documentId(String path) {
|
||||
final cacheName = md5
|
||||
.convert(utf8.encode('MarianumMobile-$path'))
|
||||
|
||||
+55
-55
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -46,4 +46,18 @@ extension DateTimeFormatting on DateTime {
|
||||
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
||||
|
||||
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
|
||||
|
||||
String formatDateRelativeShort({DateTime? now}) {
|
||||
final reference = now ?? DateTime.now();
|
||||
final today = DateTime(reference.year, reference.month, reference.day);
|
||||
final self = DateTime(year, month, day);
|
||||
final diff = today.difference(self).inDays;
|
||||
|
||||
if (diff == 0) return 'Heute';
|
||||
if (diff == 1) return 'Gestern';
|
||||
if (diff > 1 && diff <= 6) {
|
||||
return Jiffy.parseFromDateTime(this).format(pattern: 'EEEE');
|
||||
}
|
||||
return formatDate();
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -17,11 +17,13 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
|
||||
import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
|
||||
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';
|
||||
@@ -91,6 +93,20 @@ Future<void> main() async {
|
||||
),
|
||||
);
|
||||
|
||||
// Warm up the Nextcloud root listing in the background while the user is
|
||||
// still on the launch screen / other modules — the root endpoint is slow
|
||||
// on our instance, so kicking it off early means the Files page already
|
||||
// has data ready by the time the user navigates to it. No-op when a
|
||||
// cached payload is already present, so this does not undo the day-long
|
||||
// root cache TTL.
|
||||
if (AccountData().isPopulated()) {
|
||||
unawaited(
|
||||
ListFilesCache.prefetchRootListing().onError(
|
||||
(e, _) => log('Files root prefetch failed: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (kReleaseMode) {
|
||||
ErrorWidget.builder = (error) => Material(
|
||||
color: Colors.white,
|
||||
@@ -138,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(),
|
||||
@@ -184,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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -37,7 +37,9 @@ class FilesBloc
|
||||
Future<void> refresh() async {
|
||||
add(RefetchStarted<FilesState>());
|
||||
final path = innerState?.currentPath ?? initialPath;
|
||||
await _query(path);
|
||||
// Explicit user action — bypass the cache TTL so the root listing also
|
||||
// refetches even though it is otherwise cached for a day.
|
||||
await _query(path, renew: true);
|
||||
}
|
||||
|
||||
Future<void> setPath(List<String> path) async {
|
||||
@@ -52,7 +54,7 @@ class FilesBloc
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> _query(List<String> path) async {
|
||||
Future<void> _query(List<String> path, {bool renew = false}) async {
|
||||
final pathString = path.isEmpty ? '/' : path.join('/');
|
||||
|
||||
// Drop late results when [setPath] has navigated elsewhere or when the
|
||||
@@ -71,6 +73,7 @@ class FilesBloc
|
||||
try {
|
||||
listing = await repo.data.listFiles(
|
||||
pathString,
|
||||
renew: renew,
|
||||
onCacheData: (cached) {
|
||||
if (isStale()) return;
|
||||
// Cached payload arrives before the network call settles. Surface it
|
||||
|
||||
@@ -11,16 +11,23 @@ class FilesDataProvider {
|
||||
/// network call is still pending. The Future itself resolves once both the
|
||||
/// cache lookup and the network attempt have settled, throwing if no payload
|
||||
/// could be obtained at all.
|
||||
///
|
||||
/// Pass [renew] for explicit user-triggered reloads (pull-to-refresh, after
|
||||
/// a rename / delete / move / upload). It bypasses the per-path TTL in
|
||||
/// [ListFilesCache] so the root listing — which is otherwise cached for a
|
||||
/// full day — still refetches when the user actively asks for it.
|
||||
Future<ListFilesResponse> listFiles(
|
||||
String path, {
|
||||
void Function(ListFilesResponse)? onCacheData,
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) => resolveFromCache<ListFilesResponse>(
|
||||
(onUpdate, onError) => ListFilesCache(
|
||||
path: path,
|
||||
onUpdate: onUpdate,
|
||||
onCacheData: onCacheData,
|
||||
onError: onError,
|
||||
renew: renew,
|
||||
),
|
||||
onError: onError,
|
||||
operationName: 'listFiles',
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../../utils/cache_invalidation_bus.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'data/sort_options.dart';
|
||||
import 'files_upload_dialog.dart';
|
||||
import 'search/files_search_delegate.dart';
|
||||
import 'widgets/add_file_menu.dart';
|
||||
import 'widgets/clipboard_banner.dart';
|
||||
import 'widgets/file_element.dart';
|
||||
@@ -117,6 +118,15 @@ class _FilesViewState extends State<_FilesView> {
|
||||
});
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Suchen',
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () async {
|
||||
final delegate = FilesSearchDelegate(pathScope: widget.path);
|
||||
await showSearch<void>(context: context, delegate: delegate);
|
||||
delegate.disposeController();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/search/search_files.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../utils/debouncer.dart';
|
||||
import 'local_cache_search.dart';
|
||||
|
||||
/// Holds the live state of a Files-search session: current query, the latest
|
||||
/// local-cache hits (synchronous), the latest server hits (asynchronous,
|
||||
/// debounced), and loading/error flags. Notifies listeners whenever any of
|
||||
/// these change so the UI can rebuild incrementally as results stream in.
|
||||
class FilesSearchController extends ChangeNotifier {
|
||||
FilesSearchController({List<String>? initialPathScope})
|
||||
: _pathScope = List<String>.from(initialPathScope ?? const []);
|
||||
|
||||
static const Duration _serverDebounce = Duration(seconds: 1);
|
||||
final String _debounceTag =
|
||||
'files-search-${DateTime.now().microsecondsSinceEpoch}';
|
||||
final SearchFiles _api = SearchFiles();
|
||||
|
||||
String _query = '';
|
||||
List<String> _pathScope;
|
||||
List<CacheableFile> _cacheResults = const [];
|
||||
List<CacheableFile> _serverResults = const [];
|
||||
bool _serverLoading = false;
|
||||
Object? _serverError;
|
||||
int _serverEpoch = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Guards against the race where the search delegate is closed (and the
|
||||
/// controller disposed) while a debounced cache scan or server call is
|
||||
/// still in flight: their late `notifyListeners()` would otherwise throw
|
||||
/// on a disposed `ChangeNotifier`.
|
||||
void _safeNotify() {
|
||||
if (_disposed) return;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String get query => _query;
|
||||
List<String> get pathScope => List.unmodifiable(_pathScope);
|
||||
bool get isScoped => _pathScope.isNotEmpty;
|
||||
List<CacheableFile> get cacheResults => _cacheResults;
|
||||
List<CacheableFile> get serverResults => _serverResults;
|
||||
bool get serverLoading => _serverLoading;
|
||||
Object? get serverError => _serverError;
|
||||
|
||||
/// Combined, deduplicated result list (cache hits first, then any
|
||||
/// server-only hits) — handy for empty-state checks. Dedup key is the
|
||||
/// WebDAV path.
|
||||
List<CacheableFile> get combinedResults {
|
||||
if (_cacheResults.isEmpty) return _serverResults;
|
||||
if (_serverResults.isEmpty) return _cacheResults;
|
||||
final seen = <String>{for (final f in _cacheResults) f.path};
|
||||
return [
|
||||
..._cacheResults,
|
||||
..._serverResults.where((f) => seen.add(f.path)),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> setQuery(String value) async {
|
||||
if (value == _query) return;
|
||||
_query = value;
|
||||
// Bumping the epoch up front invalidates any in-flight server call from
|
||||
// a previous query, so its late response cannot toggle `_serverLoading`
|
||||
// off while a fresh search is queued behind the debounce.
|
||||
final epoch = ++_serverEpoch;
|
||||
if (_query.trim().isEmpty) {
|
||||
Debouncer.cancel(_debounceTag);
|
||||
_cacheResults = const [];
|
||||
_serverResults = const [];
|
||||
_serverLoading = false;
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
return;
|
||||
}
|
||||
// Show loading immediately — even before the (typically fast) cache
|
||||
// scan resolves — so the indicator is visible the moment the user
|
||||
// starts typing rather than after the first await hop.
|
||||
_serverLoading = true;
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
|
||||
final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
_scheduleServerCall();
|
||||
}
|
||||
|
||||
/// Drops the path filter and re-runs the current search globally. Used by
|
||||
/// the empty-state "Im Hauptverzeichnis suchen" button.
|
||||
Future<void> searchEverywhere() async {
|
||||
if (!isScoped) return;
|
||||
_pathScope = const [];
|
||||
final epoch = ++_serverEpoch;
|
||||
if (_query.trim().isEmpty) {
|
||||
_safeNotify();
|
||||
return;
|
||||
}
|
||||
_serverLoading = true;
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
|
||||
final cacheHits = await searchLocalCaches(_query);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
_scheduleServerCall();
|
||||
}
|
||||
|
||||
/// Re-runs the current server query immediately, bypassing the debounce.
|
||||
/// Wired to the `LoadableStateErrorScreen` "Erneut versuchen" button.
|
||||
void retry() {
|
||||
if (_query.trim().isEmpty) return;
|
||||
++_serverEpoch;
|
||||
Debouncer.cancel(_debounceTag);
|
||||
_serverLoading = true;
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
_runServerCall();
|
||||
}
|
||||
|
||||
void _scheduleServerCall() {
|
||||
Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall);
|
||||
}
|
||||
|
||||
Future<void> _runServerCall() async {
|
||||
final epoch = _serverEpoch;
|
||||
final term = _query;
|
||||
final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/';
|
||||
try {
|
||||
final response = await _api.run(term: term);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_serverResults = response.entries
|
||||
.map((e) => e.toCacheable())
|
||||
.whereType<CacheableFile>()
|
||||
.where((f) => scopePrefix.isEmpty || f.path.startsWith(scopePrefix))
|
||||
.toList();
|
||||
_serverLoading = false;
|
||||
_serverError = null;
|
||||
_safeNotify();
|
||||
} on Object catch (e) {
|
||||
if (epoch != _serverEpoch) return;
|
||||
_serverResults = const [];
|
||||
_serverLoading = false;
|
||||
_serverError = e;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
Debouncer.cancel(_debounceTag);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'files_search_controller.dart';
|
||||
import 'files_search_results.dart';
|
||||
|
||||
/// Material `SearchDelegate` for the Files module — opens via the magnifier
|
||||
/// in `FilesPage`'s AppBar (mirroring `SearchMarianumMessages`). Owns one
|
||||
/// [FilesSearchController]; cache + server hits stream into the result list
|
||||
/// as the user types.
|
||||
class FilesSearchDelegate extends SearchDelegate<void> {
|
||||
final FilesSearchController _controller;
|
||||
|
||||
FilesSearchDelegate({required List<String> pathScope})
|
||||
: _controller = FilesSearchController(initialPathScope: pathScope),
|
||||
super(searchFieldLabel: 'Dateien suchen');
|
||||
|
||||
/// Must be called by the host widget after `showSearch` returns so the
|
||||
/// controller's listeners and pending debounce timers are released.
|
||||
void disposeController() => _controller.dispose();
|
||||
|
||||
@override
|
||||
List<Widget>? buildActions(BuildContext context) => [
|
||||
if (query.isNotEmpty)
|
||||
IconButton(
|
||||
tooltip: 'Suche leeren',
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
query = '';
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget? buildLeading(BuildContext context) => IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => close(context, null),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget buildResults(BuildContext context) {
|
||||
_controller.setQuery(query);
|
||||
return FilesSearchResults(
|
||||
controller: _controller,
|
||||
onResultTap: () => close(context, null),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildSuggestions(BuildContext context) {
|
||||
_controller.setQuery(query);
|
||||
return FilesSearchResults(
|
||||
controller: _controller,
|
||||
onResultTap: () => close(context, null),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart';
|
||||
import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart';
|
||||
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart';
|
||||
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart';
|
||||
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart';
|
||||
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart';
|
||||
import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
import '../../../../widget/placeholder_view.dart';
|
||||
import '../widgets/file_element.dart';
|
||||
import 'files_search_controller.dart';
|
||||
|
||||
/// Renders the live state of a [FilesSearchController]. Wraps everything in a
|
||||
/// `LoadableStateBloc` module so the search reuses the standard primary /
|
||||
/// background loading and error views from the rest of the app.
|
||||
class FilesSearchResults extends StatelessWidget {
|
||||
final FilesSearchController controller;
|
||||
final VoidCallback? onResultTap;
|
||||
|
||||
const FilesSearchResults({
|
||||
required this.controller,
|
||||
this.onResultTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocModule<LoadableStateBloc, LoadableStateState>(
|
||||
create: (_) => LoadableStateBloc(),
|
||||
child: (context, bloc, _) {
|
||||
bloc.reFetch = controller.retry;
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) => _buildBody(context),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
if (controller.query.trim().isEmpty) {
|
||||
return const PlaceholderView(
|
||||
icon: Icons.search,
|
||||
text: 'Tippen, um in Dateien zu suchen.',
|
||||
);
|
||||
}
|
||||
final combined = controller.combinedResults;
|
||||
final hasContent = combined.isNotEmpty;
|
||||
final hasError = controller.serverError != null;
|
||||
final isLoading = controller.serverLoading;
|
||||
|
||||
final showPrimaryLoading = isLoading && !hasContent;
|
||||
final showBackgroundLoading = isLoading && hasContent;
|
||||
final showErrorScreen = hasError && !hasContent && !isLoading;
|
||||
final showErrorBar = hasError && hasContent;
|
||||
final showEmpty = !hasContent && !hasError && !isLoading;
|
||||
|
||||
final errorMessage = hasError ? errorToUserMessage(controller.serverError) : null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
LoadableStateErrorBar(
|
||||
visible: showErrorBar,
|
||||
hasContent: hasContent,
|
||||
message: errorMessage,
|
||||
),
|
||||
// Background loading sits *outside* the result Stack so the linear
|
||||
// progress bar is not painted over by the opaque ListView/ListTiles
|
||||
// when cache hits are already on screen and the server is still
|
||||
// working. The widget collapses to zero height when invisible.
|
||||
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
|
||||
LoadableStateErrorScreen(
|
||||
visible: showErrorScreen,
|
||||
message: errorMessage,
|
||||
),
|
||||
if (showEmpty) _emptyState(context),
|
||||
if (hasContent) _resultList(context, combined),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyState(BuildContext context) => PlaceholderView(
|
||||
icon: Icons.search_off_outlined,
|
||||
text: 'Keine Treffer gefunden.',
|
||||
button: controller.isScoped
|
||||
? FilledButton.icon(
|
||||
onPressed: controller.searchEverywhere,
|
||||
icon: const Icon(Icons.travel_explore),
|
||||
label: const Text('Im Hauptverzeichnis suchen'),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
Widget _resultList(BuildContext context, List<CacheableFile> combined) {
|
||||
final groups = _groupByParent(combined);
|
||||
final orderedKeys = groups.keys.toList()..sort();
|
||||
final items = <Widget>[];
|
||||
for (final folder in orderedKeys) {
|
||||
final segments = _segmentsOf(folder);
|
||||
items.add(
|
||||
_FolderHeader(
|
||||
folder: folder,
|
||||
onOpen: () {
|
||||
onResultTap?.call();
|
||||
AppRoutes.openFolder(context, segments);
|
||||
},
|
||||
),
|
||||
);
|
||||
for (final file in groups[folder]!) {
|
||||
items.add(
|
||||
FileElement(
|
||||
file,
|
||||
segments,
|
||||
controller.retry,
|
||||
highlight: controller.query,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListView(padding: EdgeInsets.zero, children: items);
|
||||
}
|
||||
|
||||
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
|
||||
final map = <String, List<CacheableFile>>{};
|
||||
for (final file in files) {
|
||||
map.putIfAbsent(_parentOf(file), () => []).add(file);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
String _parentOf(CacheableFile file) {
|
||||
final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
final segments = stripped.split('/');
|
||||
if (segments.length <= 1) return '/';
|
||||
segments.removeLast();
|
||||
return '/${segments.join('/')}';
|
||||
}
|
||||
|
||||
List<String> _segmentsOf(String folder) {
|
||||
final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
if (stripped.isEmpty) return const [];
|
||||
return stripped.split('/');
|
||||
}
|
||||
}
|
||||
|
||||
class _FolderHeader extends StatelessWidget {
|
||||
final String folder;
|
||||
final VoidCallback onOpen;
|
||||
const _FolderHeader({required this.folder, required this.onOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 38,
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
folder,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Ordner öffnen',
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.folder_open_outlined),
|
||||
onPressed: onOpen,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:localstore/localstore.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import '../../../../api/request_cache.dart';
|
||||
|
||||
/// Document key prefix used by `ListFilesCache._documentId`.
|
||||
const String _folderCachePrefix = 'wd-folder-';
|
||||
|
||||
/// Scans every cached folder listing in Localstore and returns files/folders
|
||||
/// whose name contains [query] (case-insensitive).
|
||||
///
|
||||
/// [pathScope] restricts results to entries whose WebDAV path starts with
|
||||
/// the given folder. Pass an empty list (or null) to search globally.
|
||||
///
|
||||
/// [docs] is an injection seam for tests — production callers leave it null
|
||||
/// so the helper reads from the real Localstore.
|
||||
Future<List<CacheableFile>> searchLocalCaches(
|
||||
String query, {
|
||||
List<String>? pathScope,
|
||||
Map<String, dynamic>? docs,
|
||||
}) async {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final needle = trimmed.toLowerCase();
|
||||
final scopePrefix = pathScope == null || pathScope.isEmpty
|
||||
? ''
|
||||
: '${pathScope.join('/')}/';
|
||||
|
||||
final raw =
|
||||
docs ??
|
||||
await Localstore.instance.collection(RequestCache.collection).get();
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
|
||||
final results = <String, CacheableFile>{};
|
||||
for (final entry in raw.entries) {
|
||||
final docKey = entry.key.split('/').last;
|
||||
if (!docKey.startsWith(_folderCachePrefix)) continue;
|
||||
|
||||
final value = entry.value;
|
||||
if (value is! Map) continue;
|
||||
final json = value['json'];
|
||||
if (json is! String) continue;
|
||||
|
||||
final ListFilesResponse listing;
|
||||
try {
|
||||
listing = ListFilesResponse.fromJson(
|
||||
jsonDecode(json) as Map<String, dynamic>,
|
||||
);
|
||||
} on Object {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final file in listing.files) {
|
||||
if (!file.name.toLowerCase().contains(needle)) continue;
|
||||
if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) {
|
||||
continue;
|
||||
}
|
||||
results[file.path] ??= file;
|
||||
}
|
||||
}
|
||||
return results.values.toList();
|
||||
}
|
||||
@@ -14,13 +14,25 @@ import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/info_dialog.dart';
|
||||
import '../../talk/widgets/highlighted_linkify.dart';
|
||||
import 'file_details_sheet.dart';
|
||||
|
||||
class FileElement extends StatefulWidget {
|
||||
final CacheableFile file;
|
||||
final List<String> path;
|
||||
final void Function() refetch;
|
||||
const FileElement(this.file, this.path, this.refetch, {super.key});
|
||||
|
||||
/// When non-null, occurrences of this string in the file name are visually
|
||||
/// highlighted in the tile title. Used by the Files search delegate.
|
||||
final String? highlight;
|
||||
|
||||
const FileElement(
|
||||
this.file,
|
||||
this.path,
|
||||
this.refetch, {
|
||||
this.highlight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FileElement> createState() => _FileElementState();
|
||||
@@ -118,7 +130,7 @@ class _FileElementState extends State<FileElement> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _subtitle() {
|
||||
Widget? _subtitle() {
|
||||
final status = _job?.status.value;
|
||||
if (status is DownloadInProgress) {
|
||||
return Row(
|
||||
@@ -135,10 +147,16 @@ class _FileElementState extends State<FileElement> {
|
||||
],
|
||||
);
|
||||
}
|
||||
final modified = widget.file.modifiedAt ?? DateTime.now();
|
||||
return widget.file.isDirectory
|
||||
? Text('geändert ${modified.formatRelative()}')
|
||||
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
|
||||
final modified = widget.file.modifiedAt;
|
||||
final size = widget.file.size;
|
||||
if (widget.file.isDirectory) {
|
||||
if (modified == null) return null;
|
||||
return Text('geändert ${modified.formatRelative()}');
|
||||
}
|
||||
if (size == null && modified == null) return null;
|
||||
if (size == null) return Text(modified!.formatRelative());
|
||||
if (modified == null) return Text(filesize(size));
|
||||
return Text('${filesize(size)}, ${modified.formatRelative()}');
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
@@ -328,12 +346,36 @@ class _FileElementState extends State<FileElement> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _title(BuildContext context) {
|
||||
final base =
|
||||
Theme.of(context).textTheme.bodyLarge ??
|
||||
DefaultTextStyle.of(context).style;
|
||||
if (widget.highlight == null || widget.highlight!.trim().isEmpty) {
|
||||
return Text(
|
||||
widget.file.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: buildHighlightedSpans(
|
||||
text: widget.file.name,
|
||||
query: widget.highlight,
|
||||
baseStyle: base,
|
||||
),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
leading: CenteredLeading(
|
||||
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
|
||||
),
|
||||
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
title: _title(context),
|
||||
subtitle: _subtitle(),
|
||||
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
|
||||
onTap: _onTap,
|
||||
|
||||
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
|
||||
initialDescription: event.description,
|
||||
initialStart: event.start,
|
||||
initialEnd: event.end,
|
||||
initialAllDay: event.isAllDay,
|
||||
),
|
||||
barrierDismissible: false,
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart';
|
||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||
import 'search_marianum_messages.dart';
|
||||
|
||||
class MarianumMessageListView extends StatelessWidget {
|
||||
const MarianumMessageListView({super.key});
|
||||
@@ -16,7 +17,25 @@ class MarianumMessageListView extends StatelessWidget {
|
||||
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
|
||||
create: (context) => MarianumMessageBloc(),
|
||||
child: (context, bloc, state) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Marianum Message')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Marianum Message'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
final list = bloc.state.data?.messageList;
|
||||
if (list == null) return;
|
||||
showSearch(
|
||||
context: context,
|
||||
delegate: SearchMarianumMessages(
|
||||
base: list.base,
|
||||
messages: list.messages,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LoadableStateConsumer<MarianumMessageBloc, MarianumMessageState>(
|
||||
child: (state, loading) => ListView.builder(
|
||||
itemCount: state.messageList.messages.length,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
|
||||
class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
|
||||
final String base;
|
||||
final List<MarianumMessage> messages;
|
||||
|
||||
SearchMarianumMessages({required this.base, required this.messages});
|
||||
|
||||
List<MarianumMessage> _matches() {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return messages;
|
||||
return messages.where((m) {
|
||||
return m.name.toLowerCase().contains(q) ||
|
||||
m.date.toLowerCase().contains(q);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
List<Widget>? buildActions(BuildContext context) => [
|
||||
if (query.isNotEmpty)
|
||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget? buildLeading(BuildContext context) => IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => close(context, null),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget buildResults(BuildContext context) {
|
||||
final matches = _matches();
|
||||
if (matches.isEmpty) {
|
||||
return const PlaceholderView(
|
||||
icon: Icons.search_off_outlined,
|
||||
text: 'Keine Treffer',
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: matches.length,
|
||||
itemBuilder: (_, i) {
|
||||
final message = matches[i];
|
||||
return ListTile(
|
||||
leading: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Icon(Icons.newspaper)],
|
||||
),
|
||||
title: Text(message.name, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text('vom ${message.date}'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () {
|
||||
close(context, message);
|
||||
AppRoutes.openMarianumMessage(context, base, message);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildSuggestions(BuildContext context) => buildResults(context);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
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';
|
||||
import 'data/chat_search_controller.dart';
|
||||
import 'details/chat_info.dart';
|
||||
import 'talk_navigator.dart';
|
||||
import 'widgets/chat_bubble.dart';
|
||||
import 'widgets/chat_search_app_bar.dart';
|
||||
import 'widgets/chat_textfield.dart';
|
||||
|
||||
class ChatView extends StatefulWidget {
|
||||
@@ -31,15 +40,201 @@ class ChatView extends StatefulWidget {
|
||||
State<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
final ScrollController _listController = ScrollController();
|
||||
class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final TextEditingController _searchTextController = TextEditingController();
|
||||
final Map<int, int> _matchIndices = {};
|
||||
|
||||
bool _searchActive = false;
|
||||
String _searchQuery = '';
|
||||
List<ChatSearchMatch> _matches = const [];
|
||||
int _activeMatchIndex = 0;
|
||||
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);
|
||||
if (widget.room.token != oldWidget.room.token && _searchActive) {
|
||||
_exitSearchMode();
|
||||
}
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
context.read<ChatBloc>().setToken(widget.room.token);
|
||||
}
|
||||
|
||||
void _enterSearchMode() {
|
||||
setState(() {
|
||||
_searchActive = true;
|
||||
_searchQuery = '';
|
||||
_matches = const [];
|
||||
_activeMatchIndex = 0;
|
||||
_matchesComputedFor = null;
|
||||
_matchesComputedQuery = null;
|
||||
_searchTextController.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _exitSearchMode() {
|
||||
setState(() {
|
||||
_searchActive = false;
|
||||
_searchQuery = '';
|
||||
_matches = const [];
|
||||
_activeMatchIndex = 0;
|
||||
_matchIndices.clear();
|
||||
_matchesComputedFor = null;
|
||||
_matchesComputedQuery = null;
|
||||
_searchTextController.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchChanged(String q) {
|
||||
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
|
||||
setState(() {
|
||||
_searchQuery = q;
|
||||
_activeMatchIndex = 0;
|
||||
if (chatResponse != null) {
|
||||
_recomputeMatches(chatResponse);
|
||||
} else {
|
||||
_matches = const [];
|
||||
_matchesComputedFor = null;
|
||||
_matchesComputedQuery = null;
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _matches.isNotEmpty) _scrollToActiveMatch();
|
||||
});
|
||||
}
|
||||
|
||||
void _recomputeMatches(GetChatResponse response) {
|
||||
_matches = ChatSearchController.findMatches(response, _searchQuery);
|
||||
_activeMatchIndex = _activeMatchIndex.clamp(
|
||||
0,
|
||||
math.max(0, _matches.length - 1),
|
||||
);
|
||||
_matchesComputedFor = response;
|
||||
_matchesComputedQuery = _searchQuery;
|
||||
}
|
||||
|
||||
void _goToPreviousMatch() {
|
||||
if (_matches.isEmpty) return;
|
||||
setState(() {
|
||||
_activeMatchIndex = (_activeMatchIndex + 1) % _matches.length;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _scrollToActiveMatch(),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToNextMatch() {
|
||||
if (_matches.isEmpty) return;
|
||||
setState(() {
|
||||
_activeMatchIndex =
|
||||
(_activeMatchIndex - 1 + _matches.length) % _matches.length;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _scrollToActiveMatch(),
|
||||
);
|
||||
}
|
||||
|
||||
void _scrollToActiveMatch() {
|
||||
if (_matches.isEmpty) return;
|
||||
if (!_itemScrollController.isAttached) return;
|
||||
final id = _matches[_activeMatchIndex].messageId;
|
||||
final idx = _matchIndices[id];
|
||||
if (idx == null) return;
|
||||
_itemScrollController.scrollTo(
|
||||
index: idx,
|
||||
alignment: 0.4,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildMessages(GetChatResponse response) {
|
||||
if (_searchActive &&
|
||||
(response != _matchesComputedFor ||
|
||||
_searchQuery != _matchesComputedQuery)) {
|
||||
_recomputeMatches(response);
|
||||
}
|
||||
|
||||
final matchIds = _matches.map((m) => m.messageId).toSet();
|
||||
final activeId = _matches.isNotEmpty
|
||||
? _matches[_activeMatchIndex].messageId
|
||||
: null;
|
||||
final highlightQuery =
|
||||
_searchActive && _searchQuery.trim().isNotEmpty ? _searchQuery : null;
|
||||
|
||||
final messages = <Widget>[];
|
||||
final chronologicalMatchIndex = <int, int>{};
|
||||
var lastDate = DateTime.now();
|
||||
for (final element in response.sortByTimestamp()) {
|
||||
final elementDate = DateTime.fromMillisecondsSinceEpoch(
|
||||
@@ -48,6 +243,7 @@ class _ChatViewState extends State<ChatView> {
|
||||
|
||||
if (element.systemMessage.contains('reaction')) continue;
|
||||
if (element.systemMessage.contains('poll_voted')) continue;
|
||||
if (element.systemMessage.contains('message_deleted')) continue;
|
||||
final commonRead = int.parse(
|
||||
response.headers?['x-chat-last-common-read'] ?? '0',
|
||||
);
|
||||
@@ -65,17 +261,31 @@ class _ChatViewState extends State<ChatView> {
|
||||
);
|
||||
}
|
||||
|
||||
final isMatch = matchIds.contains(element.id);
|
||||
final highlight = isMatch
|
||||
? (element.id == activeId
|
||||
? SearchHighlight.active
|
||||
: SearchHighlight.secondary)
|
||||
: SearchHighlight.none;
|
||||
|
||||
if (isMatch) chronologicalMatchIndex[element.id] = messages.length;
|
||||
|
||||
messages.add(
|
||||
ChatBubble(
|
||||
context: context,
|
||||
isSender:
|
||||
element.actorId == widget.selfId &&
|
||||
element.messageType == GetRoomResponseObjectMessageType.comment,
|
||||
(element.messageType ==
|
||||
GetRoomResponseObjectMessageType.comment ||
|
||||
element.messageType ==
|
||||
GetRoomResponseObjectMessageType.deletedComment),
|
||||
bubbleData: element,
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
isRead: element.id <= commonRead,
|
||||
selfId: widget.selfId,
|
||||
highlightQuery: highlightQuery,
|
||||
matchHighlight: highlight,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -94,32 +304,60 @@ class _ChatViewState extends State<ChatView> {
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
),
|
||||
);
|
||||
chronologicalMatchIndex.updateAll((_, v) => v + 1);
|
||||
}
|
||||
|
||||
final total = messages.length;
|
||||
_matchIndices
|
||||
..clear()
|
||||
..addEntries(
|
||||
chronologicalMatchIndex.entries.map(
|
||||
(e) => MapEntry(e.key, (total - 1) - e.value),
|
||||
),
|
||||
);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: const Color(0xffefeae2),
|
||||
appBar: ClickableAppBar(
|
||||
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
widget.avatar,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.room.displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
appBar: _searchActive
|
||||
? ChatSearchAppBar(
|
||||
controller: _searchTextController,
|
||||
matchCount: _matches.length,
|
||||
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
|
||||
onChanged: _onSearchChanged,
|
||||
onClose: _exitSearchMode,
|
||||
onPrevious: _matches.isEmpty ? null : _goToPreviousMatch,
|
||||
onNext: _matches.isEmpty ? null : _goToNextMatch,
|
||||
)
|
||||
: ClickableAppBar(
|
||||
onTap: () =>
|
||||
TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
widget.avatar,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.room.displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'In Chat suchen',
|
||||
onPressed: _enterSearchMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
@@ -137,11 +375,16 @@ class _ChatViewState extends State<ChatView> {
|
||||
isReady: (state) =>
|
||||
state.chatResponse != null &&
|
||||
state.currentToken == widget.room.token,
|
||||
child: (state, _) => ListView(
|
||||
reverse: true,
|
||||
controller: _listController,
|
||||
children: _buildMessages(state.chatResponse!).reversed.toList(),
|
||||
),
|
||||
child: (state, _) {
|
||||
final items =
|
||||
_buildMessages(state.chatResponse!).reversed.toList();
|
||||
return ScrollablePositionedList.builder(
|
||||
reverse: true,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, idx) => items[idx],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ColoredBox(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../utils/url_opener.dart';
|
||||
import '../widgets/highlighted_linkify.dart';
|
||||
|
||||
class ChatMessage {
|
||||
String originalMessage;
|
||||
@@ -27,8 +27,13 @@ class ChatMessage {
|
||||
);
|
||||
}
|
||||
|
||||
Widget getWidget() {
|
||||
var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen);
|
||||
Widget getWidget({String? highlightQuery, TextStyle? style}) {
|
||||
var contentWidget = HighlightedLinkify(
|
||||
text: content,
|
||||
onOpen: UrlOpener.onOpen,
|
||||
highlight: highlightQuery,
|
||||
style: style,
|
||||
);
|
||||
|
||||
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
|
||||
return ListTile(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
|
||||
class ChatSearchMatch {
|
||||
final int messageId;
|
||||
final int timestamp;
|
||||
|
||||
const ChatSearchMatch({required this.messageId, required this.timestamp});
|
||||
}
|
||||
|
||||
class ChatSearchController {
|
||||
static List<ChatSearchMatch> findMatches(
|
||||
GetChatResponse response,
|
||||
String query,
|
||||
) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return const [];
|
||||
|
||||
final matches = <ChatSearchMatch>[];
|
||||
for (final element in response.sortByTimestamp()) {
|
||||
if (element.systemMessage.contains('reaction')) continue;
|
||||
if (element.systemMessage.contains('poll_voted')) continue;
|
||||
if (element.systemMessage.contains('message_deleted')) continue;
|
||||
|
||||
final haystackText = RichObjectStringProcessor.parseToString(
|
||||
element.message,
|
||||
element.messageParameters,
|
||||
).toLowerCase();
|
||||
|
||||
var matched = haystackText.contains(q);
|
||||
if (!matched &&
|
||||
element.messageType != GetRoomResponseObjectMessageType.system) {
|
||||
matched = element.actorDisplayName.toLowerCase().contains(q);
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
matches.add(
|
||||
ChatSearchMatch(
|
||||
messageId: element.id,
|
||||
timestamp: element.timestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class BubbleStyle {
|
||||
const BubbleStyle({
|
||||
this.color,
|
||||
this.borderWidth = 0,
|
||||
this.borderColor,
|
||||
this.elevation = 0,
|
||||
this.margin = const BubbleEdges.only(),
|
||||
this.padding = const BubbleEdges.all(8),
|
||||
@@ -37,12 +38,25 @@ class BubbleStyle {
|
||||
|
||||
final Color? color;
|
||||
final double borderWidth;
|
||||
final Color? borderColor;
|
||||
final double elevation;
|
||||
final BubbleEdges margin;
|
||||
final BubbleEdges padding;
|
||||
final Alignment alignment;
|
||||
final BubbleNip nip;
|
||||
final double borderRadius;
|
||||
|
||||
BubbleStyle copyWith({double? borderWidth, Color? borderColor}) => BubbleStyle(
|
||||
color: color,
|
||||
borderWidth: borderWidth ?? this.borderWidth,
|
||||
borderColor: borderColor ?? this.borderColor,
|
||||
elevation: elevation,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
alignment: alignment,
|
||||
nip: nip,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
/// The "nip" is faked by flattening one corner so the bubble anchors to
|
||||
@@ -88,7 +102,7 @@ class Bubble extends StatelessWidget {
|
||||
borderRadius: radius,
|
||||
border: style.borderWidth > 0
|
||||
? Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
color: style.borderColor ?? Theme.of(context).dividerColor,
|
||||
width: style.borderWidth,
|
||||
)
|
||||
: null,
|
||||
|
||||
@@ -18,6 +18,9 @@ import 'bubble.dart';
|
||||
import 'chat_bubble_poll.dart';
|
||||
import 'chat_bubble_reactions.dart';
|
||||
import 'chat_message_options_dialog.dart';
|
||||
import 'highlighted_linkify.dart';
|
||||
|
||||
enum SearchHighlight { none, secondary, active }
|
||||
|
||||
class ChatBubble extends StatefulWidget {
|
||||
final BuildContext context;
|
||||
@@ -33,6 +36,9 @@ class ChatBubble extends StatefulWidget {
|
||||
|
||||
final void Function({bool renew}) refetch;
|
||||
|
||||
final String? highlightQuery;
|
||||
final SearchHighlight matchHighlight;
|
||||
|
||||
const ChatBubble({
|
||||
required this.context,
|
||||
required this.isSender,
|
||||
@@ -41,6 +47,8 @@ class ChatBubble extends StatefulWidget {
|
||||
required this.refetch,
|
||||
this.isRead = false,
|
||||
this.selfId,
|
||||
this.highlightQuery,
|
||||
this.matchHighlight = SearchHighlight.none,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -140,15 +148,53 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
).asDialog(context);
|
||||
}
|
||||
|
||||
bool get _rendersAsCommentBubble =>
|
||||
widget.bubbleData.messageType ==
|
||||
GetRoomResponseObjectMessageType.comment ||
|
||||
widget.bubbleData.messageType ==
|
||||
GetRoomResponseObjectMessageType.deletedComment;
|
||||
|
||||
TextStyle? _messageTextStyle(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
switch (widget.bubbleData.messageType) {
|
||||
case GetRoomResponseObjectMessageType.system:
|
||||
return theme.textTheme.bodySmall;
|
||||
case GetRoomResponseObjectMessageType.deletedComment:
|
||||
return theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.hintColor,
|
||||
fontStyle: FontStyle.italic,
|
||||
);
|
||||
case GetRoomResponseObjectMessageType.comment:
|
||||
case GetRoomResponseObjectMessageType.voiceMessage:
|
||||
case GetRoomResponseObjectMessageType.command:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
BubbleStyle _getStyle() {
|
||||
final styles = ChatBubbleStyles(context);
|
||||
if (widget.bubbleData.messageType !=
|
||||
GetRoomResponseObjectMessageType.comment) {
|
||||
return styles.getSystemStyle();
|
||||
final BubbleStyle base;
|
||||
if (!_rendersAsCommentBubble) {
|
||||
base = styles.getSystemStyle();
|
||||
} else {
|
||||
base = widget.isSender
|
||||
? styles.getSelfStyle(false)
|
||||
: styles.getRemoteStyle(false);
|
||||
}
|
||||
switch (widget.matchHighlight) {
|
||||
case SearchHighlight.none:
|
||||
return base;
|
||||
case SearchHighlight.secondary:
|
||||
return base.copyWith(
|
||||
borderWidth: 1.5,
|
||||
borderColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.45),
|
||||
);
|
||||
case SearchHighlight.active:
|
||||
return base.copyWith(
|
||||
borderWidth: 3,
|
||||
borderColor: Theme.of(context).colorScheme.primary,
|
||||
);
|
||||
}
|
||||
return widget.isSender
|
||||
? styles.getSelfStyle(false)
|
||||
: styles.getRemoteStyle(false);
|
||||
}
|
||||
|
||||
void _showOptionsDialog() => showChatMessageOptionsDialog(
|
||||
@@ -159,6 +205,18 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
onRefetch: widget.refetch,
|
||||
);
|
||||
|
||||
/// True only for messages whose body has a meaningful tap action (poll
|
||||
/// dialog or file download/cancel). For plain text messages we leave
|
||||
/// `onTap: null` on the bubble's `GestureDetector` so its
|
||||
/// `TapGestureRecognizer` does not enter the gesture arena — otherwise
|
||||
/// it competes with (and blocks) the per-link `TapGestureRecognizer`s
|
||||
/// that `HighlightedLinkify` attaches to URL spans.
|
||||
bool get _hasTapAction {
|
||||
final obj = message.originalData?['object'];
|
||||
if (obj?.type == RichObjectStringObjectType.talkPoll) return true;
|
||||
return message.file != null;
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
final obj = message.originalData?['object'];
|
||||
if (obj?.type == RichObjectStringObjectType.talkPoll) {
|
||||
@@ -186,25 +244,36 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
originalData: widget.bubbleData.messageParameters,
|
||||
);
|
||||
final showActorDisplayName =
|
||||
widget.bubbleData.messageType ==
|
||||
GetRoomResponseObjectMessageType.comment &&
|
||||
_rendersAsCommentBubble &&
|
||||
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||
final showBubbleTime =
|
||||
widget.bubbleData.messageType !=
|
||||
GetRoomResponseObjectMessageType.system &&
|
||||
widget.bubbleData.messageType !=
|
||||
GetRoomResponseObjectMessageType.deletedComment;
|
||||
GetRoomResponseObjectMessageType.system;
|
||||
|
||||
final parent = widget.bubbleData.parent;
|
||||
final actorBaseStyle = TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
final actorText = Text(
|
||||
widget.bubbleData.actorDisplayName,
|
||||
textAlign: TextAlign.start,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: actorBaseStyle,
|
||||
);
|
||||
final actorWidget = (widget.highlightQuery?.trim().isNotEmpty ?? false)
|
||||
? Text.rich(
|
||||
TextSpan(
|
||||
children: buildHighlightedSpans(
|
||||
text: widget.bubbleData.actorDisplayName,
|
||||
query: widget.highlightQuery,
|
||||
baseStyle: actorBaseStyle,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: actorText;
|
||||
|
||||
final timeText = Text(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
@@ -245,15 +314,19 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
},
|
||||
onLongPress: _showOptionsDialog,
|
||||
onDoubleTap: _showOptionsDialog,
|
||||
onTap: _onTap,
|
||||
onTap: _hasTapAction ? _onTap : null,
|
||||
child: Transform.translate(
|
||||
offset: _position,
|
||||
child: Bubble(
|
||||
style: _getStyle(),
|
||||
child: _BubbleContent(
|
||||
actorText: actorText,
|
||||
actorWidget: actorWidget,
|
||||
timeText: timeText,
|
||||
messageWidget: message.getWidget(),
|
||||
messageWidget: message.getWidget(
|
||||
highlightQuery: widget.highlightQuery,
|
||||
style: _messageTextStyle(context),
|
||||
),
|
||||
parent: parent,
|
||||
bubbleData: widget.bubbleData,
|
||||
isSender: widget.isSender,
|
||||
@@ -282,6 +355,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
|
||||
class _BubbleContent extends StatelessWidget {
|
||||
final Text actorText;
|
||||
final Widget actorWidget;
|
||||
final Text timeText;
|
||||
final Widget messageWidget;
|
||||
final GetChatResponseObject? parent;
|
||||
@@ -298,6 +372,7 @@ class _BubbleContent extends StatelessWidget {
|
||||
|
||||
const _BubbleContent({
|
||||
required this.actorText,
|
||||
required this.actorWidget,
|
||||
required this.timeText,
|
||||
required this.messageWidget,
|
||||
required this.parent,
|
||||
@@ -323,7 +398,7 @@ class _BubbleContent extends StatelessWidget {
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText),
|
||||
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: showBubbleTime ? 18 : 0,
|
||||
|
||||
@@ -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,
|
||||
@@ -140,12 +137,22 @@ void showChatMessageOptionsDialog(
|
||||
},
|
||||
),
|
||||
if (canDelete)
|
||||
AsyncListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Nachricht löschen'),
|
||||
onPressed: () async {
|
||||
await DeleteMessage(chatData.token, bubbleData.id).run();
|
||||
if (sheetCtx.mounted) sheetCtx.read<ChatBloc>().refresh();
|
||||
onTap: () {
|
||||
ConfirmDialog(
|
||||
title: 'Nachricht löschen?',
|
||||
content: 'Die Nachricht wird für alle Teilnehmer gelöscht.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirmAsync: () async {
|
||||
await DeleteMessage(chatData.token, bubbleData.id).run();
|
||||
if (!sheetCtx.mounted) return;
|
||||
final bloc = sheetCtx.read<ChatBloc>();
|
||||
Navigator.of(sheetCtx).pop();
|
||||
bloc.refresh();
|
||||
},
|
||||
).asDialog(sheetCtx);
|
||||
},
|
||||
),
|
||||
DebugTile(sheetCtx).jsonData(bubbleData.toJson()),
|
||||
@@ -173,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final TextEditingController controller;
|
||||
final int matchCount;
|
||||
final int activeIndex;
|
||||
final ValueChanged<String> onChanged;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onPrevious;
|
||||
final VoidCallback? onNext;
|
||||
|
||||
const ChatSearchAppBar({
|
||||
required this.controller,
|
||||
required this.matchCount,
|
||||
required this.activeIndex,
|
||||
required this.onChanged,
|
||||
required this.onClose,
|
||||
required this.onPrevious,
|
||||
required this.onNext,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final counterText = matchCount == 0
|
||||
? '0/0'
|
||||
: '${activeIndex + 1}/$matchCount';
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onClose,
|
||||
),
|
||||
title: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'In Chat suchen…',
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: onChanged,
|
||||
),
|
||||
actions: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
counterText,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_up),
|
||||
onPressed: onPrevious,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_down),
|
||||
onPressed: onNext,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:linkify/linkify.dart' as linkify_pkg;
|
||||
|
||||
const TextStyle kSearchHighlightStyle = TextStyle(
|
||||
backgroundColor: Color(0xFFFFD54F),
|
||||
color: Colors.black,
|
||||
);
|
||||
|
||||
List<TextSpan> buildHighlightedSpans({
|
||||
required String text,
|
||||
required String? query,
|
||||
required TextStyle baseStyle,
|
||||
TextStyle highlightStyle = kSearchHighlightStyle,
|
||||
GestureRecognizer? recognizer,
|
||||
}) {
|
||||
final q = query?.trim().toLowerCase();
|
||||
if (q == null || q.isEmpty) {
|
||||
return [TextSpan(text: text, style: baseStyle, recognizer: recognizer)];
|
||||
}
|
||||
|
||||
final spans = <TextSpan>[];
|
||||
final lower = text.toLowerCase();
|
||||
var cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
final hit = lower.indexOf(q, cursor);
|
||||
if (hit < 0) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: text.substring(cursor),
|
||||
style: baseStyle,
|
||||
recognizer: recognizer,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (hit > cursor) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: text.substring(cursor, hit),
|
||||
style: baseStyle,
|
||||
recognizer: recognizer,
|
||||
),
|
||||
);
|
||||
}
|
||||
final end = hit + q.length;
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: text.substring(hit, end),
|
||||
style: baseStyle.merge(highlightStyle),
|
||||
recognizer: recognizer,
|
||||
),
|
||||
);
|
||||
cursor = end;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
class HighlightedLinkify extends StatefulWidget {
|
||||
final String text;
|
||||
final String? highlight;
|
||||
final LinkCallback? onOpen;
|
||||
final TextStyle? style;
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
const HighlightedLinkify({
|
||||
required this.text,
|
||||
this.highlight,
|
||||
this.onOpen,
|
||||
this.style,
|
||||
this.linkStyle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HighlightedLinkify> createState() => _HighlightedLinkifyState();
|
||||
}
|
||||
|
||||
class _HighlightedLinkifyState extends State<HighlightedLinkify> {
|
||||
// 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.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) {
|
||||
_seenLinkKeys.clear();
|
||||
|
||||
final defaultStyle = widget.style ??
|
||||
Theme.of(context).textTheme.bodyMedium ??
|
||||
DefaultTextStyle.of(context).style;
|
||||
// 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(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
);
|
||||
const linkHighlight = TextStyle(
|
||||
backgroundColor: Color(0xFFFFD54F),
|
||||
color: Colors.black,
|
||||
decoration: TextDecoration.underline,
|
||||
);
|
||||
|
||||
final elements = linkify_pkg.linkify(widget.text);
|
||||
final spans = <InlineSpan>[];
|
||||
|
||||
for (final el in elements) {
|
||||
if (el is LinkableElement) {
|
||||
_seenLinkKeys.add(el.text);
|
||||
final recognizer = _recognizerFor(el);
|
||||
spans.addAll(
|
||||
buildHighlightedSpans(
|
||||
text: el.text,
|
||||
query: widget.highlight,
|
||||
baseStyle: linkStyle,
|
||||
highlightStyle: linkHighlight,
|
||||
recognizer: recognizer,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
spans.addAll(
|
||||
buildHighlightedSpans(
|
||||
text: el.text,
|
||||
query: widget.highlight,
|
||||
baseStyle: defaultStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_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.
|
||||
|
||||
+702
-159
@@ -1,29 +1,33 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../routing/app_routes.dart';
|
||||
import '../share_intent/remote_file_ref.dart';
|
||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'app_progress_indicator.dart';
|
||||
import 'centered_leading.dart';
|
||||
import 'info_dialog.dart';
|
||||
import 'placeholder_view.dart';
|
||||
import 'share_position_origin.dart';
|
||||
|
||||
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({
|
||||
@@ -39,10 +43,82 @@ class FileViewer extends StatefulWidget {
|
||||
|
||||
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
||||
|
||||
/// 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.
|
||||
enum _FileKind { image, svg, pdf, text, video, audio, unknown }
|
||||
|
||||
const Set<String> _imageExtensions = {
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'webp',
|
||||
'gif',
|
||||
'bmp',
|
||||
'wbmp',
|
||||
};
|
||||
|
||||
const Set<String> _videoExtensions = {
|
||||
'mp4',
|
||||
'm4v',
|
||||
'mov',
|
||||
'webm',
|
||||
'mkv',
|
||||
'3gp',
|
||||
};
|
||||
|
||||
/// ogg/opus/flac are Android-only; iOS init errors fall through to the
|
||||
/// "format not supported" message.
|
||||
const Set<String> _audioExtensions = {
|
||||
'mp3',
|
||||
'm4a',
|
||||
'aac',
|
||||
'wav',
|
||||
'flac',
|
||||
'ogg',
|
||||
'oga',
|
||||
'opus',
|
||||
};
|
||||
|
||||
/// Unknown extensions still get a content sniff via [_looksLikeText].
|
||||
const Set<String> _textExtensions = {
|
||||
'txt', 'md', 'markdown', 'rst', 'log',
|
||||
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
|
||||
'csv', 'tsv', 'tab',
|
||||
'ini', 'conf', 'cfg', 'env', 'properties',
|
||||
'html', 'htm', 'xhtml',
|
||||
'css', 'scss', 'sass', 'less',
|
||||
'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx',
|
||||
'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift',
|
||||
'py', 'rb', 'pl', 'lua', 'r',
|
||||
'go', 'rs', 'zig',
|
||||
'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm',
|
||||
'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
'sql', 'graphql', 'gql',
|
||||
'gitignore', 'gitattributes', 'editorconfig', 'dockerignore',
|
||||
'dockerfile', 'makefile', 'cmake',
|
||||
'tex', 'bib',
|
||||
'srt', 'vtt',
|
||||
};
|
||||
|
||||
/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
|
||||
Future<bool> _looksLikeText(String path) async {
|
||||
final file = File(path);
|
||||
RandomAccessFile? raf;
|
||||
try {
|
||||
final length = await file.length();
|
||||
if (length == 0) return true;
|
||||
raf = await file.open();
|
||||
final sample = await raf.read(min(length, 8192));
|
||||
if (sample.contains(0)) return false;
|
||||
utf8.decode(sample);
|
||||
return true;
|
||||
} on Object {
|
||||
return false;
|
||||
} finally {
|
||||
await raf?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -82,7 +158,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_ready) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
return SfPdfViewer.file(File(widget.path));
|
||||
}
|
||||
@@ -93,13 +169,30 @@ class _FileViewerState extends State<FileViewer> {
|
||||
|
||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||
late bool openExternal;
|
||||
Future<_FileKind>? _fileKind;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
openExternal =
|
||||
settings.val().fileViewSettings.alwaysOpenExternally ||
|
||||
widget.openExternal;
|
||||
super.initState();
|
||||
if (openExternal) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _openExternallyAndPop(),
|
||||
);
|
||||
} else {
|
||||
_fileKind = _detectKind();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openExternallyAndPop() async {
|
||||
final result = await OpenFilex.open(widget.path);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
if (result.type != ResultType.done) {
|
||||
InfoDialog.show(context, result.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -108,167 +201,617 @@ class _FileViewerState extends State<FileViewer> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<_FileKind> _detectKind() async {
|
||||
final ext = widget.path.split('.').last.toLowerCase();
|
||||
if (_imageExtensions.contains(ext)) return _FileKind.image;
|
||||
if (ext == 'svg') return _FileKind.svg;
|
||||
if (ext == 'pdf') return _FileKind.pdf;
|
||||
if (_videoExtensions.contains(ext)) return _FileKind.video;
|
||||
if (_audioExtensions.contains(ext)) return _FileKind.audio;
|
||||
if (_textExtensions.contains(ext)) return _FileKind.text;
|
||||
if (await _looksLikeText(widget.path)) return _FileKind.text;
|
||||
return _FileKind.unknown;
|
||||
}
|
||||
|
||||
Future<void> _handleAction(FileViewingActions value) async {
|
||||
switch (value) {
|
||||
case FileViewingActions.openExternal:
|
||||
AppRoutes.openFileViewer(
|
||||
context,
|
||||
widget.path,
|
||||
openExternal: true,
|
||||
remoteFile: widget.remoteFile,
|
||||
);
|
||||
break;
|
||||
case FileViewingActions.sendToChat:
|
||||
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
|
||||
break;
|
||||
case FileViewingActions.saveToCloud:
|
||||
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
|
||||
break;
|
||||
case FileViewingActions.share:
|
||||
unawaited(
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(widget.path)],
|
||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case FileViewingActions.save:
|
||||
try {
|
||||
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,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (saved != null) {
|
||||
InfoDialog.show(context, 'Datei gespeichert.');
|
||||
}
|
||||
} on Object catch (e) {
|
||||
if (!mounted) return;
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Speichern fehlgeschlagen: $e',
|
||||
copyable: true,
|
||||
title: 'Fehler',
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<_ActionDescriptor> _availableActions() => [
|
||||
_ActionDescriptor(
|
||||
action: FileViewingActions.openExternal,
|
||||
icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
|
||||
label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
|
||||
),
|
||||
if (widget.remoteFile != null) ...[
|
||||
const _ActionDescriptor(
|
||||
action: FileViewingActions.sendToChat,
|
||||
icon: Icons.chat_bubble_outline,
|
||||
label: 'An Talk-Chat senden',
|
||||
),
|
||||
const _ActionDescriptor(
|
||||
action: FileViewingActions.saveToCloud,
|
||||
icon: Icons.cloud_outlined,
|
||||
label: 'In Cloud speichern',
|
||||
),
|
||||
],
|
||||
const _ActionDescriptor(
|
||||
action: FileViewingActions.share,
|
||||
icon: Icons.share_outlined,
|
||||
label: 'Teilen',
|
||||
),
|
||||
const _ActionDescriptor(
|
||||
action: FileViewingActions.save,
|
||||
icon: Icons.save_alt_outlined,
|
||||
label: 'Speichern',
|
||||
),
|
||||
];
|
||||
|
||||
AppBar _appbar({
|
||||
List<Widget> actions = const [],
|
||||
bool showActionsMenu = true,
|
||||
}) => AppBar(
|
||||
title: Text(widget.path.split('/').last),
|
||||
actions: [
|
||||
...actions,
|
||||
if (showActionsMenu)
|
||||
PopupMenuButton<FileViewingActions>(
|
||||
onSelected: _handleAction,
|
||||
itemBuilder: (context) => _availableActions()
|
||||
.map(
|
||||
(a) => PopupMenuItem(
|
||||
value: a.action,
|
||||
child: ListTile(
|
||||
leading: Icon(a.icon),
|
||||
title: Text(a.label),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppBar appbar({List<Widget> actions = const []}) => AppBar(
|
||||
title: Text(widget.path.split('/').last),
|
||||
if (openExternal) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.path.split('/').last)),
|
||||
body: const Center(child: AppProgressIndicator.large()),
|
||||
);
|
||||
}
|
||||
return FutureBuilder<_FileKind>(
|
||||
future: _fileKind,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Scaffold(
|
||||
appBar: _appbar(),
|
||||
body: const Center(child: AppProgressIndicator.large()),
|
||||
);
|
||||
}
|
||||
switch (snapshot.data!) {
|
||||
case _FileKind.image:
|
||||
return _buildImageView();
|
||||
case _FileKind.svg:
|
||||
return _buildSvgView();
|
||||
case _FileKind.pdf:
|
||||
return _buildPdfView();
|
||||
case _FileKind.video:
|
||||
return _buildVideoView();
|
||||
case _FileKind.audio:
|
||||
return _buildAudioView();
|
||||
case _FileKind.text:
|
||||
return _buildTextView();
|
||||
case _FileKind.unknown:
|
||||
return _buildUnknownView();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageView() => Scaffold(
|
||||
appBar: _appbar(
|
||||
actions: [
|
||||
...actions,
|
||||
PopupMenuButton<FileViewingActions>(
|
||||
onSelected: (value) async {
|
||||
switch (value) {
|
||||
case FileViewingActions.openExternal:
|
||||
AppRoutes.openFileViewer(
|
||||
context,
|
||||
widget.path,
|
||||
openExternal: true,
|
||||
remoteFile: widget.remoteFile,
|
||||
);
|
||||
break;
|
||||
case FileViewingActions.sendToChat:
|
||||
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
|
||||
break;
|
||||
case FileViewingActions.saveToCloud:
|
||||
AppRoutes.openInternalSaveToFolder(
|
||||
context,
|
||||
widget.remoteFile!,
|
||||
);
|
||||
break;
|
||||
case FileViewingActions.share:
|
||||
unawaited(
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(widget.path)],
|
||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case FileViewingActions.save:
|
||||
try {
|
||||
final bytes = await File(widget.path).readAsBytes();
|
||||
final saved = await FilePicker.saveFile(
|
||||
fileName: widget.path.split('/').last,
|
||||
bytes: bytes,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (saved != null) {
|
||||
InfoDialog.show(context, 'Datei gespeichert.');
|
||||
}
|
||||
} on Object catch (e) {
|
||||
if (!context.mounted) return;
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Speichern fehlgeschlagen: $e',
|
||||
copyable: true,
|
||||
title: 'Fehler',
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
photoViewController.rotation += pi / 2;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => <PopupMenuEntry<FileViewingActions>>[
|
||||
const PopupMenuItem(
|
||||
value: FileViewingActions.openExternal,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.open_in_new),
|
||||
title: Text('Extern öffnen'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
if (widget.remoteFile != null) ...[
|
||||
const PopupMenuItem(
|
||||
value: FileViewingActions.sendToChat,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.chat_bubble_outline),
|
||||
title: Text('An Talk-Chat senden'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: FileViewingActions.saveToCloud,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.cloud_outlined),
|
||||
title: Text('In Cloud speichern'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
const PopupMenuItem(
|
||||
value: FileViewingActions.share,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.share_outlined),
|
||||
title: Text('Teilen'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: FileViewingActions.save,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.save_alt_outlined),
|
||||
title: Text('Speichern'),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
icon: const Icon(Icons.rotate_right),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
body: PhotoView(
|
||||
controller: photoViewController,
|
||||
maxScale: 3.0,
|
||||
minScale: 0.1,
|
||||
imageProvider: Image.file(File(widget.path)).image,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) {
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'webp':
|
||||
case 'gif':
|
||||
return Scaffold(
|
||||
appBar: appbar(
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
photoViewController.rotation += pi / 2;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.rotate_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
body: PhotoView(
|
||||
controller: photoViewController,
|
||||
maxScale: 3.0,
|
||||
minScale: 0.1,
|
||||
imageProvider: Image.file(File(widget.path)).image,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
Widget _buildSvgView() => Scaffold(
|
||||
appBar: _appbar(),
|
||||
backgroundColor: Colors.white,
|
||||
body: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 8,
|
||||
child: Center(
|
||||
child: SvgPicture.file(
|
||||
File(widget.path),
|
||||
placeholderBuilder: (_) =>
|
||||
const Center(child: AppProgressIndicator.large()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildPdfView() =>
|
||||
Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
|
||||
|
||||
Widget _buildVideoView() => Scaffold(
|
||||
appBar: _appbar(),
|
||||
backgroundColor: Colors.black,
|
||||
body: _MediaPlayer(path: widget.path, isAudio: false),
|
||||
);
|
||||
|
||||
Widget _buildAudioView() => Scaffold(
|
||||
appBar: _appbar(),
|
||||
body: _MediaPlayer(
|
||||
path: widget.path,
|
||||
isAudio: true,
|
||||
filename: widget.path.split('/').last,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildTextView() => Scaffold(
|
||||
appBar: _appbar(),
|
||||
body: FutureBuilder<_TextPayload>(
|
||||
future: _readTextPayload(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
final payload = snapshot.data!;
|
||||
final lines = const LineSplitter().convert(payload.content);
|
||||
// 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(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (payload.truncated)
|
||||
SliverToBoxAdapter(
|
||||
child: SelectionContainer.disabled(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'Datei ist groß — Anzeige auf die ersten ${(_textViewMaxBytes / 1024).round()} KB begrenzt.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: lines.length,
|
||||
itemBuilder: (context, i) => _CodeLine(
|
||||
number: i + 1,
|
||||
text: lines[i],
|
||||
gutterWidth: gutterWidth,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
case 'pdf':
|
||||
return Scaffold(
|
||||
appBar: appbar(),
|
||||
body: _DeferredPdfViewer(path: widget.path),
|
||||
);
|
||||
|
||||
default:
|
||||
OpenFilex.open(widget.path).then((result) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
if (result.type != ResultType.done) {
|
||||
InfoDialog.show(context, result.message);
|
||||
}
|
||||
});
|
||||
|
||||
return PlaceholderView(
|
||||
text: 'Datei extern geöffnet',
|
||||
icon: Icons.open_in_new,
|
||||
button: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Zurück'),
|
||||
Widget _buildUnknownView() {
|
||||
final theme = Theme.of(context);
|
||||
final descriptors = _availableActions();
|
||||
return Scaffold(
|
||||
appBar: _appbar(showActionsMenu: false),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.insert_drive_file_outlined, size: 60),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vorschau nicht verfügbar',
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.path.split('/').last,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Wähle eine Aktion, um mit der Datei weiterzuarbeiten.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
const SizedBox(height: 24),
|
||||
...descriptors.map(
|
||||
(d) => ListTile(
|
||||
leading: CenteredLeading(Icon(d.icon)),
|
||||
title: Text(d.label),
|
||||
onTap: () => _handleAction(d.action),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const int _textViewMaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
Future<_TextPayload> _readTextPayload() async {
|
||||
final file = File(widget.path);
|
||||
final size = await file.length();
|
||||
final ext = widget.path.split('.').last.toLowerCase();
|
||||
if (size <= _textViewMaxBytes) {
|
||||
final raw = await file.readAsString();
|
||||
return _TextPayload(content: _maybePrettify(raw, ext), truncated: false);
|
||||
}
|
||||
final raf = await file.open();
|
||||
try {
|
||||
final bytes = await raf.read(_textViewMaxBytes);
|
||||
// Truncated payloads stay raw — a parser would choke on the dangling tail.
|
||||
return _TextPayload(
|
||||
content: utf8.decode(bytes, allowMalformed: true),
|
||||
truncated: true,
|
||||
);
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Falls through to the original text on parse errors.
|
||||
String _maybePrettify(String content, String ext) {
|
||||
if (ext != 'json') return content;
|
||||
try {
|
||||
final parsed = jsonDecode(content);
|
||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||
} on Object {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionDescriptor {
|
||||
final FileViewingActions action;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
const _ActionDescriptor({
|
||||
required this.action,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
class _TextPayload {
|
||||
final String content;
|
||||
final bool truncated;
|
||||
const _TextPayload({required this.content, required this.truncated});
|
||||
}
|
||||
|
||||
class _MediaPlayer extends StatefulWidget {
|
||||
final String path;
|
||||
final bool isAudio;
|
||||
final String? filename;
|
||||
const _MediaPlayer({
|
||||
required this.path,
|
||||
required this.isAudio,
|
||||
this.filename,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_MediaPlayer> createState() => _MediaPlayerState();
|
||||
}
|
||||
|
||||
class _MediaPlayerState extends State<_MediaPlayer> {
|
||||
VideoPlayerController? _video;
|
||||
ChewieController? _chewie;
|
||||
Object? _initError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
final controller = VideoPlayerController.file(File(widget.path));
|
||||
try {
|
||||
await controller.initialize();
|
||||
} on Object catch (e) {
|
||||
await controller.dispose();
|
||||
if (!mounted) return;
|
||||
setState(() => _initError = e);
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
if (widget.isAudio) {
|
||||
controller.addListener(_onAudioTick);
|
||||
setState(() => _video = controller);
|
||||
} else {
|
||||
setState(() {
|
||||
_video = controller;
|
||||
_chewie = ChewieController(
|
||||
videoPlayerController: controller,
|
||||
autoPlay: false,
|
||||
looping: false,
|
||||
allowFullScreen: true,
|
||||
allowPlaybackSpeedChanging: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onAudioTick() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_video?.removeListener(_onAudioTick);
|
||||
_chewie?.dispose();
|
||||
_video?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_initError != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.isAudio
|
||||
? 'Audio kann nicht abgespielt werden'
|
||||
: 'Video kann nicht abgespielt werden',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_video == null) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
if (widget.isAudio) {
|
||||
return _AudioControls(
|
||||
controller: _video!,
|
||||
filename: widget.filename ?? '',
|
||||
);
|
||||
}
|
||||
return Chewie(controller: _chewie!);
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioControls extends StatelessWidget {
|
||||
final VideoPlayerController controller;
|
||||
final String filename;
|
||||
const _AudioControls({required this.controller, required this.filename});
|
||||
|
||||
String _format(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
if (d.inHours > 0) return '${d.inHours}:$m:$s';
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final value = controller.value;
|
||||
final duration = value.duration;
|
||||
final position = value.position;
|
||||
final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds;
|
||||
final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble();
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.audiotrack,
|
||||
size: 96,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
filename,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Slider(
|
||||
min: 0,
|
||||
max: maxMs.toDouble(),
|
||||
value: posMs,
|
||||
onChanged: (v) =>
|
||||
controller.seekTo(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_format(position),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
_format(duration),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FloatingActionButton(
|
||||
heroTag: 'audioPlayPause',
|
||||
onPressed: () {
|
||||
if (value.isPlaying) {
|
||||
controller.pause();
|
||||
} else {
|
||||
controller.play();
|
||||
}
|
||||
},
|
||||
child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeLine extends StatelessWidget {
|
||||
final int number;
|
||||
final String text;
|
||||
final double gutterWidth;
|
||||
const _CodeLine({
|
||||
required this.number,
|
||||
required this.text,
|
||||
required this.gutterWidth,
|
||||
});
|
||||
|
||||
static const TextStyle _codeStyle = TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isEven = number.isEven;
|
||||
return Container(
|
||||
color: isEven ? theme.colorScheme.surfaceContainerLow : null,
|
||||
padding: const EdgeInsets.only(left: 4, right: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectionContainer.disabled(
|
||||
child: SizedBox(
|
||||
width: gutterWidth,
|
||||
child: Text(
|
||||
'$number',
|
||||
textAlign: TextAlign.right,
|
||||
style: _codeStyle.copyWith(color: theme.hintColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+89
-47
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,14 @@ dependencies:
|
||||
workmanager: ^0.9.0+3
|
||||
intl: ^0.20.2
|
||||
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
|
||||
freezed_annotation: ^3.1.0
|
||||
@@ -72,6 +79,8 @@ dependencies:
|
||||
url_launcher: ^6.3.1
|
||||
enough_icalendar: ^0.17.0
|
||||
receive_sharing_intent: ^1.8.1
|
||||
video_player: ^2.9.0
|
||||
chewie: ^1.8.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -58,4 +58,53 @@ void main() {
|
||||
expect(dt.timeRangeTo(end), '09:07 - 09:52');
|
||||
});
|
||||
});
|
||||
|
||||
group('formatDateRelativeShort', () {
|
||||
final now = DateTime(2026, 5, 9, 14, 0); // Saturday
|
||||
|
||||
test('today returns "Heute"', () {
|
||||
expect(
|
||||
DateTime(2026, 5, 9, 8, 0).formatDateRelativeShort(now: now),
|
||||
'Heute',
|
||||
);
|
||||
});
|
||||
|
||||
test('yesterday returns "Gestern"', () {
|
||||
expect(
|
||||
DateTime(2026, 5, 8, 23, 30).formatDateRelativeShort(now: now),
|
||||
'Gestern',
|
||||
);
|
||||
});
|
||||
|
||||
test('2 to 6 days ago returns the German weekday name', () {
|
||||
// 2026-05-07 is a Thursday
|
||||
expect(
|
||||
DateTime(2026, 5, 7).formatDateRelativeShort(now: now),
|
||||
'Donnerstag',
|
||||
);
|
||||
// 2026-05-03 is a Sunday (6 days before Saturday 9th)
|
||||
expect(
|
||||
DateTime(2026, 5, 3).formatDateRelativeShort(now: now),
|
||||
'Sonntag',
|
||||
);
|
||||
});
|
||||
|
||||
test('7 days or more ago falls back to dd.MM.yyyy', () {
|
||||
expect(
|
||||
DateTime(2026, 5, 2).formatDateRelativeShort(now: now),
|
||||
'02.05.2026',
|
||||
);
|
||||
expect(
|
||||
DateTime(2026, 1, 1).formatDateRelativeShort(now: now),
|
||||
'01.01.2026',
|
||||
);
|
||||
});
|
||||
|
||||
test('future dates fall back to dd.MM.yyyy', () {
|
||||
expect(
|
||||
DateTime(2026, 5, 10).formatDateRelativeShort(now: now),
|
||||
'10.05.2026',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import 'package:marianum_mobile/view/pages/files/search/local_cache_search.dart';
|
||||
|
||||
CacheableFile _file({
|
||||
required String path,
|
||||
required String name,
|
||||
bool isDirectory = false,
|
||||
}) => CacheableFile(path: path, isDirectory: isDirectory, name: name);
|
||||
|
||||
Map<String, dynamic> _doc(ListFilesResponse listing) => {
|
||||
'json': jsonEncode(listing.toJson()),
|
||||
'lastupdate': 0,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('searchLocalCaches', () {
|
||||
final root = ListFilesResponse({
|
||||
_file(path: 'Documents/', name: 'Documents', isDirectory: true),
|
||||
_file(path: 'Photos/', name: 'Photos', isDirectory: true),
|
||||
_file(path: 'Reports.pdf', name: 'Reports.pdf'),
|
||||
});
|
||||
final documents = ListFilesResponse({
|
||||
_file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'),
|
||||
_file(path: 'Documents/Notes.txt', name: 'Notes.txt'),
|
||||
});
|
||||
final docs = {
|
||||
'/MarianumMobile/wd-folder-aaa': _doc(root),
|
||||
'/MarianumMobile/wd-folder-bbb': _doc(documents),
|
||||
'/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0},
|
||||
};
|
||||
|
||||
test('matches by name case-insensitively across all caches', () async {
|
||||
final hits = await searchLocalCaches('report', docs: docs);
|
||||
final paths = hits.map((f) => f.path).toSet();
|
||||
expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'});
|
||||
});
|
||||
|
||||
test('returns empty list for empty query', () async {
|
||||
expect(await searchLocalCaches(' ', docs: docs), isEmpty);
|
||||
});
|
||||
|
||||
test('respects pathScope prefix', () async {
|
||||
final hits = await searchLocalCaches(
|
||||
'report',
|
||||
pathScope: ['Documents'],
|
||||
docs: docs,
|
||||
);
|
||||
expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']);
|
||||
});
|
||||
|
||||
test('ignores non-folder cache documents', () async {
|
||||
final hits = await searchLocalCaches('anything', docs: docs);
|
||||
// Only documents starting with `wd-folder-` are scanned. The unrelated
|
||||
// `get-room-ccc` doc must not crash the helper.
|
||||
expect(hits, isEmpty);
|
||||
});
|
||||
|
||||
test('deduplicates entries that appear in multiple cached folders',
|
||||
() async {
|
||||
final shared = _file(
|
||||
path: 'Documents/Tax-Report.pdf',
|
||||
name: 'Tax-Report.pdf',
|
||||
);
|
||||
final dedupRoot = ListFilesResponse({shared});
|
||||
final dedupDocs = {
|
||||
'/MarianumMobile/wd-folder-aaa': _doc(dedupRoot),
|
||||
'/MarianumMobile/wd-folder-bbb': _doc(dedupRoot),
|
||||
};
|
||||
final hits = await searchLocalCaches('tax', docs: dedupDocs);
|
||||
expect(hits, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import 'package:marianum_mobile/view/pages/talk/data/chat_search_controller.dart';
|
||||
|
||||
GetChatResponseObject _msg({
|
||||
required int id,
|
||||
required int timestamp,
|
||||
String actorDisplayName = 'Anyone',
|
||||
String message = '',
|
||||
String systemMessage = '',
|
||||
GetRoomResponseObjectMessageType type =
|
||||
GetRoomResponseObjectMessageType.comment,
|
||||
Map<String, RichObjectString>? params,
|
||||
}) =>
|
||||
GetChatResponseObject(
|
||||
id,
|
||||
'token',
|
||||
GetRoomResponseObjectMessageActorType.user,
|
||||
'actor-id',
|
||||
actorDisplayName,
|
||||
timestamp,
|
||||
systemMessage,
|
||||
type,
|
||||
true,
|
||||
'',
|
||||
message,
|
||||
params,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
GetChatResponse _response(List<GetChatResponseObject> messages) =>
|
||||
GetChatResponse(messages.toSet());
|
||||
|
||||
void main() {
|
||||
group('ChatSearchController.findMatches', () {
|
||||
test('empty query returns no matches', () {
|
||||
final response = _response([
|
||||
_msg(id: 1, timestamp: 100, message: 'Hallo'),
|
||||
]);
|
||||
expect(ChatSearchController.findMatches(response, ''), isEmpty);
|
||||
expect(ChatSearchController.findMatches(response, ' '), isEmpty);
|
||||
});
|
||||
|
||||
test('matches message text case-insensitively', () {
|
||||
final response = _response([
|
||||
_msg(id: 1, timestamp: 100, message: 'Hallo Welt'),
|
||||
_msg(id: 2, timestamp: 200, message: 'nichts hier'),
|
||||
]);
|
||||
final matches = ChatSearchController.findMatches(response, 'WELT');
|
||||
expect(matches.length, 1);
|
||||
expect(matches.first.messageId, 1);
|
||||
});
|
||||
|
||||
test('matches actor display name', () {
|
||||
final response = _response([
|
||||
_msg(
|
||||
id: 1,
|
||||
timestamp: 100,
|
||||
actorDisplayName: 'Lisa Maier',
|
||||
message: 'irgendwas',
|
||||
),
|
||||
_msg(
|
||||
id: 2,
|
||||
timestamp: 200,
|
||||
actorDisplayName: 'Tom Weber',
|
||||
message: 'auch was',
|
||||
),
|
||||
]);
|
||||
final matches = ChatSearchController.findMatches(response, 'lisa');
|
||||
expect(matches.length, 1);
|
||||
expect(matches.first.messageId, 1);
|
||||
});
|
||||
|
||||
test('system messages match on text but not on actor', () {
|
||||
final response = _response([
|
||||
_msg(
|
||||
id: 1,
|
||||
timestamp: 100,
|
||||
actorDisplayName: 'Lisa',
|
||||
message: 'Lisa ist beigetreten',
|
||||
type: GetRoomResponseObjectMessageType.system,
|
||||
),
|
||||
]);
|
||||
// Match on text content
|
||||
expect(
|
||||
ChatSearchController.findMatches(response, 'beigetreten').length,
|
||||
1,
|
||||
);
|
||||
// Actor name alone (not in text) should not match for system messages
|
||||
final actorOnlyResponse = _response([
|
||||
_msg(
|
||||
id: 1,
|
||||
timestamp: 100,
|
||||
actorDisplayName: 'Lisa',
|
||||
message: 'jemand ist beigetreten',
|
||||
type: GetRoomResponseObjectMessageType.system,
|
||||
),
|
||||
]);
|
||||
expect(
|
||||
ChatSearchController.findMatches(actorOnlyResponse, 'lisa'),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'reaction, poll_voted and message_deleted system messages are filtered out',
|
||||
() {
|
||||
final response = _response([
|
||||
_msg(
|
||||
id: 1,
|
||||
timestamp: 100,
|
||||
message: 'Treffer',
|
||||
systemMessage: 'reaction',
|
||||
type: GetRoomResponseObjectMessageType.system,
|
||||
),
|
||||
_msg(
|
||||
id: 2,
|
||||
timestamp: 200,
|
||||
message: 'Treffer',
|
||||
systemMessage: 'poll_voted',
|
||||
type: GetRoomResponseObjectMessageType.system,
|
||||
),
|
||||
_msg(
|
||||
id: 4,
|
||||
timestamp: 250,
|
||||
message: 'Treffer',
|
||||
systemMessage: 'message_deleted',
|
||||
type: GetRoomResponseObjectMessageType.system,
|
||||
),
|
||||
_msg(id: 3, timestamp: 300, message: 'Treffer'),
|
||||
]);
|
||||
final matches = ChatSearchController.findMatches(response, 'Treffer');
|
||||
expect(matches.length, 1);
|
||||
expect(matches.first.messageId, 3);
|
||||
},
|
||||
);
|
||||
|
||||
test('rich object parameters are searchable (e.g. file names)', () {
|
||||
final response = _response([
|
||||
_msg(
|
||||
id: 1,
|
||||
timestamp: 100,
|
||||
message: '{file}',
|
||||
params: {
|
||||
'file': RichObjectString(
|
||||
RichObjectStringObjectType.file,
|
||||
'42',
|
||||
'hausaufgaben.pdf',
|
||||
null,
|
||||
null,
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
final matches = ChatSearchController.findMatches(response, 'hausaufgab');
|
||||
expect(matches.length, 1);
|
||||
expect(matches.first.messageId, 1);
|
||||
});
|
||||
|
||||
test('matches are sorted newest first', () {
|
||||
final response = _response([
|
||||
_msg(id: 1, timestamp: 100, message: 'X'),
|
||||
_msg(id: 2, timestamp: 300, message: 'X'),
|
||||
_msg(id: 3, timestamp: 200, message: 'X'),
|
||||
]);
|
||||
final matches = ChatSearchController.findMatches(response, 'x');
|
||||
expect(matches.map((m) => m.messageId).toList(), [2, 3, 1]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user