Search code examples
androidflutterdartflutter-layoutflutter-getx

How do I route to a different screen, in Flutter, by using GetX and a top level Callback Handler?


Introduction

In order to route to a different screen in Flutter, the GetX package is used to simplify contextless routing. Contextless routing is needed, because a switch to a different screen needs to be able to happen in a top level callback handler that isn't used in a Widget and therefore doesn't have a BuildContext. The callback handler I'm using originates from the caller: ^0.0.4 package and on the event that a phone call ends, the home page needs to be routed to a different screen. I'm using an Android emulator, in Android Studio Arctic Fox | 2020.3.1 Patch 3 , on a Windows 10 desktop.

Program Flow and Errors

On startup, the home page appears. A call is then placed by the user, in the app, and handled by the flutter_phone_direct_caller: ^2.1.0 and contacts_service: ^0.6.3 packages in separate files to main.dart. When a phone call ends, I expect a statement to be printed to the console that includes the number of the contact and the duration of the call, as well as the route to complete to a different screen. The print statement functions correctly (and so the callback handler initialization works), however, the route doesn't happen and the error message below appears:

E/flutter (20421): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: You are trying to use contextless navigation without
E/flutter (20421):       a GetMaterialApp or Get.key.
E/flutter (20421):       If you are testing your app, you can use:
E/flutter (20421):       [Get.testMode = true], or if you are running your app on
E/flutter (20421):       a physical device or emulator, you must exchange your [MaterialApp]
E/flutter (20421):       for a [GetMaterialApp].
E/flutter (20421):       
E/flutter (20421): #0      GetNavigation.global (package:get/get_navigation/src/extension_navigation.dart:1094:7)
E/flutter (20421): #1      GetNavigation.toNamed (package:get/get_navigation/src/extension_navigation.dart:592:12)
E/flutter (20421): #2      callerCallbackHandler (package:phone_app/main.dart:22:11)
E/flutter (20421): #3      _callbackDispatcher.<anonymous closure> (package:caller/caller.dart:129:19)
E/flutter (20421): #4      _callbackDispatcher.<anonymous closure> (package:caller/caller.dart:99:43)
E/flutter (20421): #5      MethodChannel._handleAsMethodCall (package:flutter/src/services/platform_channel.dart:386:55)
E/flutter (20421): #6      MethodChannel.setMethodCallHandler.<anonymous closure> (package:flutter/src/services/platform_channel.dart:379:34)
E/flutter (20421): #7      _DefaultBinaryMessenger.setMessageHandler.<anonymous closure> (package:flutter/src/services/binding.dart:379:35)
E/flutter (20421): #8      _DefaultBinaryMessenger.setMessageHandler.<anonymous closure> (package:flutter/src/services/binding.dart:376:46)
E/flutter (20421): #9      _invoke2.<anonymous closure> (dart:ui/hooks.dart:205:15)
E/flutter (20421): #10     _rootRun (dart:async/zone.dart:1428:13)
E/flutter (20421): #11     _CustomZone.run (dart:async/zone.dart:1328:19)
E/flutter (20421): #12     _CustomZone.runGuarded (dart:async/zone.dart:1236:7)
E/flutter (20421): #13     _invoke2 (dart:ui/hooks.dart:204:10)
E/flutter (20421): #14     _ChannelCallbackRecord.invoke (dart:ui/channel_buffers.dart:42:5)
E/flutter (20421): #15     _Channel.push (dart:ui/channel_buffers.dart:132:31)
E/flutter (20421): #16     ChannelBuffers.push (dart:ui/channel_buffers.dart:329:17)
E/flutter (20421): #17     PlatformDispatcher._dispatchPlatformMessage (dart:ui/platform_dispatcher.dart:544:22)
E/flutter (20421): #18     _dispatchPlatformMessage (dart:ui/hooks.dart:92:31)

Attempted Solution

I am using contextless navigation with GetMaterialApp and so I suspect that the problem could be that the callback handler is being called before GetMaterialApp is returned, which causes the Navigator to not be initialized yet. For this reason, I've tried to only call the route after the GetMaterialApp Widget has finished building by using: WidgetsBinding.instance?.addPostFrameCallback((timeStamp) => Get.toNamed('/testScreen'));. I've ensured that a WidgetsBinding has been created through the statement: WidgetsFlutterBinding.ensureInitialized();. In the previous case: the route never happens, which I assume is due to the instance of WidgetsBinding being null, and no error message appears. I don't know how I would get the callback handler to execute code only after the build has finished, assuming that this is the problem causing the above error. I have not seen other cases of code implementing GetX routing within a top level callback handler.

Sample Code

The contents of my main.dart file are shown below, which includes the main function, the MyApp class with it's build Widget and named routes, the initialization of the callback handler and the callback handler itself:

import 'package:caller/caller.dart';
import 'package:flutter/material.dart';
import 'package:phone_app/screens/home.dart';
import 'package:phone_app/screens/test_screen.dart';
import 'package:get/get.dart';

/// Defines a callback that will handle all background incoming events
///
/// The duration will only have a value if the current event is `CallerEvent.callEnded`
Future<void> callerCallbackHandler(
    CallerEvent event,
    String number,
    int? duration,
    ) async {
  print("New event received from native $event");
  switch (event) {
    case CallerEvent.callEnded:
      print('[ Caller ] Ended a call with number $number and duration $duration');
      Get.toNamed('/testScreen');
      break;
    case CallerEvent.onMissedCall:
      print('[ Caller ] Missed a call from number $number');
      break;
    case CallerEvent.onIncomingCallAnswered:
      print('[ Caller ] Accepted call from number $number');
      break;
    case CallerEvent.onIncomingCallReceived:
      print('[ Caller ] Phone is ringing with number $number');
      break;
  }
}

Future<void> initialize() async {
  /// Check if the user has granted permissions
  final permission = await Caller.checkPermission();
  print('Caller permission $permission');

  /// If not, then request user permission to access the Call State
  if (!permission) {
    Caller.requestPermissions();
  } else {
    Caller.initialize(callerCallbackHandler);
  }
}

void main() {
  WidgetsFlutterBinding.ensureInitialized(); //used when Flutter needs to call native code before calling runApp (sets up internal state of MethodChannels)

  initialize(); //checks for and requests call prompt permissions

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp( //MaterialApp is a child of GetMaterial app (which is used to display dialogs, routes, snackbars, etc. without passing a context)
      debugShowCheckedModeBanner: false,
      title: 'Phone calls',
      theme: ThemeData(
        primarySwatch: Colors.purple,
      ),
      initialRoute: '/',
      getPages: [
        GetPage(
            name: '/',
            page: () => const MyHomePage(title: 'Phone Calls')
        ),
        GetPage(
            name: '/testScreen',
            page: () => const TestScreen()
        ),
      ],
    );
  }
}

Solution

  • Upon closer inspection, the caller: ^0.0.4 package uses a Dart isolate separate from the main isolate and therefore the callerCallbackHandler method runs outside of the context of the application. This makes it impossible to update the application state or execute UI impacting logic. As a workaround, I decided to implement a native Android BroadcastReceiver which picks up the changing phone state and routes to the specified screen. The BroadcastReceiver also starts the Flutter application programmatically if it is currently closed.