forked from firka/firka
Add deep link handling for widgets; implement navigation from widget links
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user