1
0
forked from firka/firka

Add deep link handling for widgets; implement navigation from widget links

This commit is contained in:
Horváth Gergely
2026-01-27 22:22:10 +01:00
committed by 4831c0
parent beb4127ef8
commit d617efec80
8 changed files with 210 additions and 51 deletions

View File

@@ -28,15 +28,18 @@ struct AveragesWidgetView: View {
}
var body: some View {
switch family {
case .systemSmall:
AveragesSmallView(entry: entry, localization: localization)
case .systemMedium:
AveragesMediumView(entry: entry, localization: localization)
case .systemLarge:
AveragesLargeView(entry: entry, localization: localization)
default:
AveragesMediumView(entry: entry, localization: localization)
Group {
switch family {
case .systemSmall:
AveragesSmallView(entry: entry, localization: localization)
case .systemMedium:
AveragesMediumView(entry: entry, localization: localization)
case .systemLarge:
AveragesLargeView(entry: entry, localization: localization)
default:
AveragesMediumView(entry: entry, localization: localization)
}
}
.widgetURL(URL(string: "firka://widget/grades"))
}
}

View File

@@ -28,15 +28,18 @@ struct GradesWidgetView: View {
}
var body: some View {
switch family {
case .systemSmall:
GradesSmallView(entry: entry, localization: localization)
case .systemMedium:
GradesMediumView(entry: entry, localization: localization)
case .systemLarge:
GradesLargeView(entry: entry, localization: localization)
default:
GradesMediumView(entry: entry, localization: localization)
Group {
switch family {
case .systemSmall:
GradesSmallView(entry: entry, localization: localization)
case .systemMedium:
GradesMediumView(entry: entry, localization: localization)
case .systemLarge:
GradesLargeView(entry: entry, localization: localization)
default:
GradesMediumView(entry: entry, localization: localization)
}
}
.widgetURL(URL(string: "firka://widget/grades"))
}
}

View File

@@ -90,6 +90,7 @@ struct TimetableProvider: AppIntentTimelineProvider {
private func createEntry(for configuration: TimetableWidgetIntent, date: Date) -> TimetableEntry {
let data = WidgetData.load()
let calendar = Calendar.current
guard let data = data else {
return TimetableEntry(
@@ -121,13 +122,32 @@ struct TimetableProvider: AppIntentTimelineProvider {
)
}
let entryDay = calendar.startOfDay(for: date)
var lessons = data.timetable.today
var isNextDay = false
let lastLesson = lessons.last
if let last = lastLesson, date > last.end {
if let firstTodayLesson = lessons.first {
let todayLessonDay = calendar.startOfDay(for: firstTodayLesson.start)
if entryDay > todayLessonDay {
lessons = data.timetable.tomorrow
if let firstTomorrowLesson = lessons.first {
let tomorrowLessonDay = calendar.startOfDay(for: firstTomorrowLesson.start)
isNextDay = entryDay < tomorrowLessonDay
}
} else {
let lastLesson = lessons.last
if let last = lastLesson, date > last.end {
lessons = data.timetable.tomorrow
isNextDay = true
}
}
} else {
lessons = data.timetable.tomorrow
isNextDay = true
if !lessons.isEmpty {
isNextDay = true
}
}
if lessons.isEmpty {

View File

@@ -32,26 +32,29 @@ struct TimetableWidgetView: View {
}
var body: some View {
switch entry.state {
case .onBreak:
if let breakInfo = entry.breakInfo {
BreakView(breakInfo: breakInfo, localization: localization, style: style)
}
case .loginRequired:
EmptyStateView(message: localization.string("login_required"), style: style)
case .unavailable:
EmptyStateView(message: localization.string("timetable_unavailable"), style: style)
case .noMoreLessons, .normal:
switch family {
case .systemSmall:
TimetableSmallView(entry: entry, localization: localization)
case .systemMedium:
TimetableMediumView(entry: entry, localization: localization)
case .systemLarge:
TimetableLargeView(entry: entry, localization: localization)
default:
TimetableMediumView(entry: entry, localization: localization)
Group {
switch entry.state {
case .onBreak:
if let breakInfo = entry.breakInfo {
BreakView(breakInfo: breakInfo, localization: localization, style: style)
}
case .loginRequired:
EmptyStateView(message: localization.string("login_required"), style: style)
case .unavailable:
EmptyStateView(message: localization.string("timetable_unavailable"), style: style)
case .noMoreLessons, .normal:
switch family {
case .systemSmall:
TimetableSmallView(entry: entry, localization: localization)
case .systemMedium:
TimetableMediumView(entry: entry, localization: localization)
case .systemLarge:
TimetableLargeView(entry: entry, localization: localization)
default:
TimetableMediumView(entry: entry, localization: localization)
}
}
}
.widgetURL(URL(string: "firka://widget/timetable"))
}
}

View File

@@ -4,6 +4,7 @@ import WidgetKit
struct TimetableSmallView: View {
let entry: TimetableEntry
let localization: WidgetLocalization
@Environment(\.colorScheme) var colorScheme
var style: WidgetStyleType {
(entry.configuration.style ?? .appTheme) == .liquidGlass ? .liquidGlass : .appTheme
@@ -13,6 +14,14 @@ struct TimetableSmallView: View {
(entry.configuration.displayMode ?? .current) == .current ? entry.currentLesson : entry.nextLesson
}
var liquidGlassPrimary: Color {
colorScheme == .dark ? .white : .black
}
var liquidGlassSecondary: Color {
colorScheme == .dark ? .white.opacity(0.7) : .black.opacity(0.6)
}
var body: some View {
ZStack {
WidgetBackground(style: style, colors: nil)
@@ -31,14 +40,14 @@ struct TimetableSmallView: View {
.strikethrough(lesson.isCancelled, color: .red)
.foregroundColor(lesson.isCancelled ? .red :
lesson.isSubstitution ? .orange :
(style == .liquidGlass ? .white : .primary))
(style == .liquidGlass ? liquidGlassPrimary : .primary))
.lineLimit(2)
Text(lesson.timeString)
.font(.subheadline)
.foregroundColor(lesson.isCancelled ? .red.opacity(0.8) :
lesson.isSubstitution ? .orange.opacity(0.8) :
(style == .liquidGlass ? .white.opacity(0.7) : .secondary))
(style == .liquidGlass ? liquidGlassSecondary : .secondary))
if let room = lesson.roomName {
Text(room)
@@ -47,7 +56,7 @@ struct TimetableSmallView: View {
.minimumScaleFactor(0.8)
.foregroundColor(lesson.isCancelled ? .red.opacity(0.7) :
lesson.isSubstitution ? .orange.opacity(0.7) :
(style == .liquidGlass ? .white.opacity(0.6) : .secondary))
(style == .liquidGlass ? liquidGlassSecondary : .secondary))
}
} else {
Text(localization.string("no_lessons"))
@@ -133,6 +142,7 @@ struct LessonRow: View {
let isActive: Bool
let style: WidgetStyleType
var showRoom: Bool = false
@Environment(\.colorScheme) var colorScheme
var lessonTextColor: Color? {
if lesson.isCancelled {
@@ -143,6 +153,14 @@ struct LessonRow: View {
return nil
}
var liquidGlassPrimary: Color {
colorScheme == .dark ? .white : .black
}
var liquidGlassSecondary: Color {
colorScheme == .dark ? .white.opacity(0.7) : .black.opacity(0.6)
}
var numberBackgroundColor: Color {
if lesson.isCancelled {
return Color.red.opacity(0.3)
@@ -165,21 +183,21 @@ struct LessonRow: View {
Circle()
.fill(numberBackgroundColor)
)
.foregroundColor(lessonTextColor ?? (style == .liquidGlass ? .white : .primary))
.foregroundColor(lessonTextColor ?? (style == .liquidGlass ? liquidGlassPrimary : .primary))
}
Text(lesson.displayName)
.font(.subheadline)
.fontWeight(isActive ? .semibold : .regular)
.strikethrough(lesson.isCancelled, color: .red)
.foregroundColor(lessonTextColor ?? (style == .liquidGlass ? .white : .primary))
.foregroundColor(lessonTextColor ?? (style == .liquidGlass ? liquidGlassPrimary : .primary))
.lineLimit(1)
Spacer()
Text(lesson.timeString)
.font(.caption)
.foregroundColor(lessonTextColor?.opacity(0.8) ?? (style == .liquidGlass ? .white.opacity(0.7) : .secondary))
.foregroundColor(lessonTextColor?.opacity(0.8) ?? (style == .liquidGlass ? liquidGlassSecondary : .secondary))
if showRoom, let room = lesson.roomName {
Text(room)
@@ -192,7 +210,7 @@ struct LessonRow: View {
lesson.isSubstitution ? Color.orange.opacity(0.2) :
Color.secondary.opacity(0.2))
)
.foregroundColor(lessonTextColor?.opacity(0.8) ?? (style == .liquidGlass ? .white.opacity(0.7) : .secondary))
.foregroundColor(lessonTextColor?.opacity(0.8) ?? (style == .liquidGlass ? liquidGlassSecondary : .secondary))
}
}
.padding(.vertical, 4)

View File

@@ -11,6 +11,8 @@ import BackgroundTasks
private var deviceTokenString: String?
private var notificationChannel: FlutterMethodChannel?
private var backgroundFetchChannel: FlutterMethodChannel?
private var widgetDeepLinkChannel: FlutterMethodChannel?
private var pendingWidgetDeepLink: String?
private let backgroundTaskIdentifier = "app.firka.timetable.refresh"
@@ -24,6 +26,20 @@ import BackgroundTasks
HomeWidgetMethodChannel.register(with: controller.binaryMessenger)
widgetDeepLinkChannel = FlutterMethodChannel(name: "firka.app/widget_deep_link", binaryMessenger: controller.binaryMessenger)
widgetDeepLinkChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
if call.method == "getPendingDeepLink" {
if let link = self?.pendingWidgetDeepLink {
self?.pendingWidgetDeepLink = nil
result(link)
} else {
result(nil)
}
} else {
result(FlutterMethodNotImplemented)
}
}
backgroundFetchChannel = FlutterMethodChannel(name: "firka.app/background_fetch", binaryMessenger: controller.binaryMessenger)
backgroundFetchChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
@@ -208,4 +224,14 @@ import BackgroundTasks
// Background fetch will be scheduled from Flutter side when needed
// No automatic scheduling here to give Flutter full control
}
override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
if url.scheme == "firka" && url.host == "widget" {
let path = url.path.replacingOccurrences(of: "/", with: "")
pendingWidgetDeepLink = path
widgetDeepLinkChannel?.invokeMethod("onWidgetDeepLink", arguments: path)
return true
}
return super.application(app, open: url, options: options)
}
}

View File

@@ -28,6 +28,17 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.firka.firkaa</string>
<key>CFBundleURLSchemes</key>
<array>
<string>firka</string>
</array>
</dict>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>

View File

@@ -79,6 +79,7 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
_HomeScreenState();
final PageController _pageController = PageController();
HomePage? _pendingNavigation;
Widget? toast;
bool pairingDone = false;
@@ -114,15 +115,88 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
if (action != null || route != null) {
logger.info('Navigating to timetable from notification');
setState(() {
homeScreenPage = HomePage.timetable;
_pageController.jumpToPage(HomePage.timetable.index);
});
_navigateToPage(HomePage.timetable);
}
}
});
}
void _setupWidgetDeepLinkListener() {
if (!Platform.isIOS) return;
final widgetChannel = MethodChannel('firka.app/widget_deep_link');
WidgetsBinding.instance.addPostFrameCallback((_) {
widgetChannel.invokeMethod<String>('getPendingDeepLink').then((link) {
if (link != null) {
_handleWidgetDeepLink(link);
}
});
});
widgetChannel.setMethodCallHandler((call) async {
if (call.method == 'onWidgetDeepLink') {
final link = call.arguments as String?;
if (link != null) {
_handleWidgetDeepLink(link);
}
}
});
}
void _handleWidgetDeepLink(String link) {
logger.info('Widget deep link received: $link');
HomePage targetPage;
switch (link) {
case 'timetable':
targetPage = HomePage.timetable;
break;
case 'grades':
targetPage = HomePage.grades;
break;
default:
logger.warning('Unknown widget deep link: $link');
return;
}
_navigateToPage(targetPage);
}
void _navigateToPage(HomePage targetPage) {
if (_disposed) return;
homeScreenPage = targetPage;
if (_pageController.hasClients) {
setState(() {
_pageController.jumpToPage(targetPage.index);
});
} else {
_pendingNavigation = targetPage;
WidgetsBinding.instance.addPostFrameCallback((_) {
_applyPendingNavigation();
});
}
}
void _applyPendingNavigation() {
if (_disposed || _pendingNavigation == null) return;
if (_pageController.hasClients) {
final target = _pendingNavigation!;
_pendingNavigation = null;
setState(() {
homeScreenPage = target;
_pageController.jumpToPage(target.index);
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
_applyPendingNavigation();
});
}
}
void prefetch() async {
if (_prefetched) return;
@@ -298,6 +372,7 @@ class _HomeScreenState extends FirkaState<HomeScreen> {
});
_setupNotificationListener();
_setupWidgetDeepLinkListener();
prefetch();
_preloadImages();