Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 42 additions & 29 deletions app/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,23 @@ import Flutter
import UserNotifications
import app_links

/// Custom AppDelegate that registers native channels for Apple Reminders,
/// Apple Notes and Apple Calendar integrations.
///
/// This file is based off the upstream `AppDelegate.swift` but extends it to
/// include two additional method channels for Notes and Calendar. These
/// channels forward calls to the corresponding service classes defined in
/// `AppleNotesService.swift` and `AppleCalendarService.swift`.
@main
@objc class AppDelegate: FlutterAppDelegate {
private var methodChannel: FlutterMethodChannel?
private var appleRemindersChannel: FlutterMethodChannel?
private var appleNotesChannel: FlutterMethodChannel?
private var appleCalendarChannel: FlutterMethodChannel?

private let appleRemindersService = AppleRemindersService()
private let appleNotesService = AppleNotesService()
private let appleCalendarService = AppleCalendarService()

private var notificationTitleOnKill: String?
private var notificationBodyOnKill: String?
Expand All @@ -18,85 +30,86 @@ import app_links
) -> Bool {
GeneratedPluginRegistrant.register(with: self)

// Retrieve the link from parameters
// Handle incoming app links
if let url = AppLinks.shared.getLink(launchOptions: launchOptions) {
// We have a link, propagate it to your Flutter app or not
AppLinks.shared.handleLink(url: url)
return true // Returning true will stop the propagation to other packages
return true
}
//Creates a method channel to handle notifications on kill

// Creates a method channel to handle notifications on kill
let controller = window?.rootViewController as? FlutterViewController
methodChannel = FlutterMethodChannel(name: "com.friend.ios/notifyOnKill", binaryMessenger: controller!.binaryMessenger)
methodChannel?.setMethodCallHandler { [weak self] (call, result) in
self?.handleMethodCall(call, result: result)
}

// Create Apple Reminders method channel
appleRemindersChannel = FlutterMethodChannel(name: "com.omi.apple_reminders", binaryMessenger: controller!.binaryMessenger)
appleRemindersChannel?.setMethodCallHandler { [weak self] (call, result) in
self?.handleAppleRemindersCall(call, result: result)
}

// here, Without this code the task will not work.
// Create Apple Notes method channel
appleNotesChannel = FlutterMethodChannel(name: "com.omi.apple_notes", binaryMessenger: controller!.binaryMessenger)
appleNotesChannel?.setMethodCallHandler { [weak self] (call, result) in
self?.appleNotesService.handleMethodCall(call, result: result)
}

// Create Apple Calendar method channel
appleCalendarChannel = FlutterMethodChannel(name: "com.omi.apple_calendar", binaryMessenger: controller!.binaryMessenger)
appleCalendarChannel?.setMethodCallHandler { [weak self] (call, result) in
self?.appleCalendarService.handleMethodCall(call, result: result)
}

// Register callback for foreground tasks
SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
}
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}

return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "setNotificationOnKillService":
handleSetNotificationOnKillService(call: call)
default:
result(FlutterMethodNotImplemented)
case "setNotificationOnKillService":
handleSetNotificationOnKillService(call: call)
default:
result(FlutterMethodNotImplemented)
}
}

private func handleSetNotificationOnKillService(call: FlutterMethodCall) {
NSLog("handleMethodCall: setNotificationOnKillService")

if let args = call.arguments as? Dictionary<String, Any> {
if let args = call.arguments as? [String: Any] {
notificationTitleOnKill = args["title"] as? String
notificationBodyOnKill = args["description"] as? String
}

}

private func handleAppleRemindersCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
appleRemindersService.handleMethodCall(call, result: result)
}


override func applicationWillTerminate(_ application: UIApplication) {
// If title and body are nil, then we don't need to show notification.
if notificationTitleOnKill == nil || notificationBodyOnKill == nil {
guard let title = notificationTitleOnKill, let body = notificationBodyOnKill else {
return
}

let content = UNMutableNotificationContent()
content.title = notificationTitleOnKill!
content.body = notificationBodyOnKill!
content.title = title
content.body = body
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: "notification on app kill", content: content, trigger: trigger)

NSLog("Running applicationWillTerminate")

UNUserNotificationCenter.current().add(request) { (error) in
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
NSLog("Failed to show notification on kill service => error: \(error.localizedDescription)")
} else {
NSLog("Show notification on kill now")
}
}
}
}

// here
// This function is required by the Flutter foreground task plugin
func registerPlugins(registry: FlutterPluginRegistry) {
GeneratedPluginRegistrant.register(with: registry)
}
}
95 changes: 95 additions & 0 deletions app/ios/Runner/AppleCalendarService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import Foundation
import EventKit
import UIKit
import Flutter

/// Native service for handling Apple Calendar interactions via Flutter.
///
/// Exposes two methods to Flutter: `createEvent` to add a new event to the
/// user's default calendar and `checkAvailability` to verify that Calendar
/// services are available. All calls are dispatched on the main thread to
/// ensure proper UI interactions and permission prompts.
class AppleCalendarService: NSObject {
private let eventStore = EKEventStore()

func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "createEvent":
createEvent(call: call, result: result)
case "checkAvailability":
checkAvailability(result: result)
default:
result(FlutterMethodNotImplemented)
}
}

private func createEvent(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let title = args["title"] as? String,
let notes = args["notes"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS",
message: "Invalid arguments for createEvent",
details: nil))
return
}

// Optional: allow custom duration from args, default to 1 hour
let duration = (args["durationMinutes"] as? Double) ?? 60.0

// Request calendar permissions and then create the event
if #available(iOS 17.0, *) {
eventStore.requestFullAccessToEvents { granted, _ in
DispatchQueue.main.async {
if granted {
self.createAndSaveEvent(title: title, notes: notes, duration: duration, result: result)
} else {
result([
"success": false,
"message": "Calendar access denied. Please enable in Settings."
])
}
}
}
} else {
eventStore.requestAccess(to: .event) { granted, _ in
DispatchQueue.main.async {
if granted {
self.createAndSaveEvent(title: title, notes: notes, duration: duration, result: result)
} else {
result([
"success": false,
"message": "Calendar access denied. Please enable in Settings."
])
}
}
}
}
}

private func createAndSaveEvent(title: String, notes: String, duration: Double, result: @escaping FlutterResult) {
do {
let event = EKEvent(eventStore: self.eventStore)
event.title = title
event.notes = notes
// Event duration: now + specified minutes (default 60)
event.startDate = Date()
event.endDate = Date().addingTimeInterval(duration * 60)
event.calendar = self.eventStore.defaultCalendarForNewEvents
try self.eventStore.save(event, span: .thisEvent)
result([
"success": true,
"message": "Event created in Calendar"
])
} catch {
result([
"success": false,
"message": "Failed to create event: \(error.localizedDescription)"
])
}
}

private func checkAvailability(result: @escaping FlutterResult) {
// Calendar services are always available on iOS
result(true)
}
}
56 changes: 56 additions & 0 deletions app/ios/Runner/AppleNotesService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation
import UIKit
import Flutter

/// Native service for interacting with Apple Notes via Flutter.
///
/// Handles two methods invoked from Dart: `shareToNotes` to present the
/// native share sheet pre‑filled with the action item content, and
/// `isNotesAppAvailable` to determine if the Notes app is installed on the
/// device. All UI operations occur on the main thread.
class AppleNotesService {
func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "shareToNotes":
shareToNotes(call: call, result: result)
case "isNotesAppAvailable":
isNotesAppAvailable(result: result)
default:
result(FlutterMethodNotImplemented)
}
}

private func shareToNotes(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let content = args["content"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS",
message: "Invalid arguments for shareToNotes",
details: nil))
return
}

DispatchQueue.main.async {
let activityViewController = UIActivityViewController(activityItems: [content], applicationActivities: nil)
activityViewController.completionWithItemsHandler = { _, completed, _, error in
if let error = error {
result(FlutterError(code: "SHARE_FAILED", message: error.localizedDescription, details: nil))
} else {
result(completed)
}
}
if let controller = UIApplication.shared.keyWindow?.rootViewController {
controller.present(activityViewController, animated: true)
} else {
result(false)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Deprecated Key Window Affects Share Sheet

The shareToNotes method uses UIApplication.shared.keyWindow to present the share sheet. This property is deprecated in iOS 13+ and can return nil on newer iOS versions, which prevents the share sheet from presenting.

Fix in Cursor Fix in Web

}
}
}

private func isNotesAppAvailable(result: @escaping FlutterResult) {
guard let url = URL(string: "mobilenotes://") else {
result(false)
return
}
result(UIApplication.shared.canOpenURL(url))
}
}
25 changes: 25 additions & 0 deletions app/lib/backend/preferences_export_extension.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'package:omi/backend/preferences.dart';

/// An extension on [SharedPreferencesUtil] adding support for storing the
/// selected export destination for action items.
///
/// This extends the existing preferences API without modifying the upstream
/// file, which allows our widget to persist the last chosen integration
/// (e.g. Apple Reminders, Notes or Calendar) across sessions.
extension TaskExportDestinationExtension on SharedPreferencesUtil {
/// Persist the user's selected destination for exporting action items.
///
/// The value stored corresponds to the [ActionItemIntegration.name] of
/// the chosen integration. If an empty string is provided, no value is
/// stored and the default integration will be used.
set taskExportDestination(String value) =>
saveString('taskExportDestination', value);

/// Retrieve the previously selected export destination for action items.
///
/// If no value has been stored, an empty string is returned. Consumers
/// should interpret the empty string as meaning that the default
/// integration (Apple Reminders) should be used.
String get taskExportDestination =>
getString('taskExportDestination') ?? '';
}
44 changes: 44 additions & 0 deletions app/lib/models/action_item_integration.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';

/// Enumeration of possible export destinations for action items.
///
/// Each value defines a human‑readable [displayName] and optionally an
/// asset name or fallback [icon]. The `assetPath` and `isSvg` fields
/// describe an optional image resource; however, because the asset files for
/// Apple Calendar and Apple Notes logos are not included in this patch, we
/// leave these fields null and rely on the provided [icon] instead. When
/// [assetPath] is non‑null, [fullAssetPath] prefixes it with
/// `assets/images/` for use with `Image.asset` or `SvgPicture.asset`.
enum ActionItemIntegration {
appleReminders('Apple Reminders', null, false, Icons.notifications),
appleNotes('Apple Notes', null, false, Icons.note_outlined),
appleCalendar('Apple Calendar', null, false, Icons.calendar_today);

/// The display name shown in the UI.
final String displayName;

/// The relative path to an image asset within the `assets/images` folder.
/// If null, no image is used and a fallback [icon] is displayed instead.
final String? assetPath;

/// Whether the asset is an SVG. Ignored if [assetPath] is null.
final bool isSvg;

/// A fallback Material icon to show when no asset is provided.
final IconData? icon;

const ActionItemIntegration(
this.displayName,
this.assetPath,
this.isSvg,
this.icon,
);

/// Returns the fully qualified asset path, prefixing [assetPath] with
/// `assets/images/` if an asset is specified. Returns null otherwise.
String? get fullAssetPath =>
assetPath != null ? 'assets/images/$assetPath' : null;

/// Indicates whether an image asset is defined for this integration.
bool get hasAsset => assetPath != null;
}
Loading