Solution 1:

It's intuitive to think of a modal as the view component of a state. Take a state definition with a view template, a controller and maybe some resolves. Each of those features also applies to the definition of a modal. Go a step further and link state entry to opening the modal and state exit to closing the modal, and if you can encapsulate all of the plumbing then you have a mechanism that can be used just like a state with ui-sref or $state.go for entry and the back button or more modal-specific triggers for exit.

I've studied this fairly extensively, and my approach was to create a modal state provider that could be used analogously to $stateProvider when configuring a module to define states that were bound to modals. At the time, I was specifically interested in unifying control over modal dismissal through state and modal events which gets more complicated than what you're asking for, so here is a simplified example.

The key is making the modal the responsibility of the state and using hooks that modal provides to keep the state in sync with independent interactions that modal supports through the scope or its UI.

.provider('modalState', function($stateProvider) {
    var provider = this;
    this.$get = function() {
        return provider;
    }
    this.state = function(stateName, options) {
        var modalInstance;
        $stateProvider.state(stateName, {
            url: options.url,
            onEnter: function($modal, $state) {
                modalInstance = $modal.open(options);
                modalInstance.result['finally'](function() {
                    modalInstance = null;
                    if ($state.$current.name === stateName) {
                        $state.go('^');
                    }
                });
            },
            onExit: function() {
                if (modalInstance) {
                    modalInstance.close();
                }
            }
        });
    };
})

State entry launches the modal. State exit closes it. The modal might close on its own (ex: via backdrop click), so you have to observe that and update the state.

The benefit of this approach is that your app continues to interact mainly with states and state-related concepts. If you later decide to turn the modal into a conventional view or vice-versa, then very little code needs to change.

Solution 2:

Here is a provider that improves @nathan-williams solution by passing resolve section down to the controller:

.provider('modalState', ['$stateProvider', function($stateProvider) {
  var provider = this;

  this.$get = function() {
    return provider;
  }

  this.state = function(stateName, options) {
    var modalInstance;

    options.onEnter = onEnter;
    options.onExit = onExit;
    if (!options.resolve) options.resolve = [];

    var resolveKeys = angular.isArray(options.resolve) ? options.resolve : Object.keys(options.resolve);
    $stateProvider.state(stateName, omit(options, ['template', 'templateUrl', 'controller', 'controllerAs']));

    onEnter.$inject = ['$uibModal', '$state', '$timeout'].concat(resolveKeys);
    function onEnter($modal, $state, $timeout) {
      options.resolve = {};

      for (var i = onEnter.$inject.length - resolveKeys.length; i < onEnter.$inject.length; i++) {
        (function(key, val) {
          options.resolve[key] = function() { return val }
        })(onEnter.$inject[i], arguments[i]);
      }

      $timeout(function() { // to let populate $stateParams
        modalInstance = $modal.open(options);
        modalInstance.result.finally(function() {
          $timeout(function() { // to let populate $state.$current
            if ($state.$current.name === stateName)
              $state.go(options.parent || '^');
          });
        });
      });
    }

    function onExit() {
      if (modalInstance)
        modalInstance.close();
    }

    return provider;
  }
}]);

function omit(object, forbidenKeys) {
  var prunedObject = {};
  for (var key in object)
    if (forbidenKeys.indexOf(key) === -1)
      prunedObject[key] = object[key];
  return prunedObject;
}

then use it like that:

.config(['modalStateProvider', function(modalStateProvider) {
  modalStateProvider
    .state('...', {
      url: '...',
      templateUrl: '...',
      controller: '...',
      resolve: {
        ...
      }
    })
}]);

Solution 3:

I answered a similar question, and provided an example here:

Modal window with custom URL in AngularJS

Has a complete working HTML and a link to plunker.