blob: 424366d2a3d59840543114e224de3dee85eca118 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
import 'package:fuchsia_scenic_flutter/child_view_connection.dart';
import 'package:tiler/tiler.dart' show TilerModel, TileModel;
import '../utils/presenter.dart';
import '../utils/suggestion.dart';
import 'ermine_shell.dart';
import 'ermine_story.dart';
// The minimum size of a tile we support.
const _kMinTileSize = Size(320, 240);
/// Defines a collection of [ClusterModel] instances. Calls [notifyListeners] when
/// a cluster is added or deleted.
class ClustersModel extends ChangeNotifier implements ErmineShell {
/// The list of [ClusterModel] initialized to one cluster.
final List<ClusterModel> clusters = [ClusterModel()];
/// The cluster currently in view.
final ValueNotifier<ClusterModel> currentCluster =
ValueNotifier<ClusterModel>(null);
final _storyToCluster = <String, ClusterModel>{};
/// Change notifier when fullscreen is toggled for a story.
ValueNotifier<ErmineStory> fullscreenStoryNotifier = ValueNotifier(null);
/// Get the current story that is fullscreen.
ErmineStory get fullscreenStory => fullscreenStoryNotifier.value;
/// Change notifier when focus is toggled for a story.
ValueNotifier<ErmineStory> focusedStoryNotifier = ValueNotifier(null);
/// Get the story that has focus.
ErmineStory get focusedStory => focusedStoryNotifier.value;
/// Returns [true] if currentCluster is the first cluster.
bool get isFirst =>
currentCluster.value == null || currentCluster.value == clusters.first;
/// Returns [true] if currentCluster is the last cluster.
bool get isLast =>
currentCluster.value == null || currentCluster.value == clusters.last;
/// Returns [true] is there are stories running.
bool get hasStories => _storyToCluster.isNotEmpty;
/// Returns a iterable of all [Story] objects.
Iterable<ErmineStory> get stories => _storyToCluster.keys.map(getStory);
/// Maximize the story to fullscreen: it's visual state to IMMERSIVE.
void maximize(String id) {
if (fullscreenStoryNotifier.value?.id == id) {
return;
}
fullscreenStoryNotifier.value?.fullscreen = false;
fullscreenStoryNotifier.value = getStory(id)..fullscreen = true;
}
/// Restore the story to non-fullscreen mode: it's visual state to DEFAULT.
void restore(String id) {
if (fullscreenStoryNotifier.value?.id != id) {
return;
}
fullscreenStoryNotifier.value?.fullscreen = false;
fullscreenStoryNotifier.value = null;
}
/// Returns the story given it's id.
ErmineStory getStory(String id) => _storyToCluster[id]?.getStory(id);
@override
void presentStory(
ChildViewConnection connection,
ViewRef viewRef,
ViewControllerImpl viewController,
String id,
) {
// Check to see if a story already exists on screen.
ErmineStory story = getStory(id);
if (story == null) {
// This view was created by some external source.
// create a new story and present it.
story = ErmineStory.fromExternalSource(
onDelete: storyDeleted,
onChange: storyChanged,
);
_addErmineStory(story);
}
story.presentView(connection, viewRef, viewController);
}
@override
void dismissStory(ViewControllerImpl viewController) {
// Find the story with the supplied viewController.
final story =
stories.where((story) => story.viewController == viewController).first;
assert(story != null);
story.delete();
}
/// Creates and adds a [Story] to the current cluster given it's [StoryInfo],
/// [SessionShell] and [StoryController]. Returns the created instance.
@override
void storySuggested(Suggestion suggestion,
[LaunchSuggestion launchSuggestion]) {
assert(!_storyToCluster.containsKey(suggestion.id));
final story = ErmineStory.fromSuggestion(
suggestion: suggestion,
onDelete: storyDeleted,
onChange: storyChanged,
launchSuggestion: launchSuggestion,
);
_addErmineStory(story);
}
void _addErmineStory(ErmineStory story) {
// Add the story to the current cluster
currentCluster.value ??= clusters.first;
_storyToCluster[story.id] = currentCluster.value..add(story);
// If current cluster is the last cluster, add one more to allow user
// to navigate to it.
if (currentCluster.value == clusters.last) {
clusters.add(ClusterModel());
}
notifyListeners();
story.focus();
}
/// Removes the [story] from the current cluster. If this is the last story
/// in the cluster, the cluster is also removed unless it is the last cluster.
@override
void storyDeleted(ErmineStory story) {
assert(_storyToCluster.containsKey(story.id));
final cluster = _storyToCluster[story.id]..remove(story);
int index = clusters.indexOf(cluster);
// If this story was fullscreen, first restore it.
if (story == fullscreenStory) {
restore(story.id);
}
_storyToCluster.remove(story.id);
// If there are no more stories in this cluster, remove it. We keep two
// clusters alive at all times.
if (!_storyToCluster.values.contains(cluster) && clusters.length > 1) {
// Current cluster was removed, switch to another.
if (cluster == currentCluster.value) {
if (cluster == clusters.last) {
currentCluster.value = clusters[index - 1];
} else {
currentCluster.value = clusters[index + 1];
}
}
clusters.remove(cluster);
notifyListeners();
}
// If the story was also focused, set focus on next story in same cluster.
if (focusedStory == story) {
if (currentCluster.value.stories.isNotEmpty) {
currentCluster.value.stories.last.focus();
} else {
focusedStoryNotifier.value = null;
}
}
}
/// Called when any story attribute changes.
@override
void storyChanged(ErmineStory story) {
if (story != null) {
final storyCluster = _storyToCluster[story.id];
if (story.focused) {
// De-focus existing story.
if (focusedStory != story) {
focusedStoryNotifier.value?.focused = false;
}
focusedStoryNotifier.value = story;
// If this story has focus, bring its cluster on screen.
if (storyCluster != currentCluster.value) {
currentCluster.value = storyCluster;
}
// Update fullscreen story.
if (story.fullscreen) {
maximize(story.id);
} else {
restore(story.id);
}
}
notifyListeners();
}
}
/// Set's the [currentCluster] to the next cluster, if available.
void nextCluster() {
if (fullscreenStory == null && currentCluster.value != clusters.last) {
currentCluster.value =
clusters[clusters.indexOf(currentCluster.value) + 1];
}
}
/// Set's the [currentCluster] to the previous cluster, if available.
void previousCluster() {
if (fullscreenStory == null && currentCluster.value != clusters.first) {
currentCluster.value =
clusters[clusters.indexOf(currentCluster.value) - 1];
}
}
ErmineStory hitTest(Offset position, [GestureBinding gestureBinding]) {
final binding = gestureBinding ?? GestureBinding.instance;
final hitTests = HitTestResult();
binding.hitTest(hitTests, position);
if (hitTests.path.isEmpty) {
return null;
}
// Check if the topmost hit is a ChildView.
final childViewRenderBox = hitTests.path.first.target;
return currentCluster.value?.stories?.firstWhere(
(story) {
final key = GlobalObjectKey(story.childViewConnection);
final renderObject = key.currentContext?.findRenderObject();
return renderObject == childViewRenderBox;
},
orElse: () => null,
);
}
/// Finds the story whose [ViewRef] is present in the focus chain.
///
/// Shell view hierarchy places the shell view at the root and all child
/// views are siblings of each other. Thus the focus chain should only have
/// one child view in it. So if we did a set intersection of the focus chain
/// and the set of open child views, a non-empty result will point to the
/// child view that received focus.
ErmineStory findStory(List<ViewRef> focusChain) {
final focusedViews = Set<int>.from(
focusChain.map((viewRef) => viewRef.reference.handle.koid));
final childViews = Set<int>.from(
stories.map((story) => story.viewRef.reference.handle.koid));
final result = focusedViews.intersection(childViews);
if (result.isNotEmpty) {
// Result should find only one childview.
assert(result.length == 1);
return stories
.where((story) => story.viewRef.reference.handle.koid == result.first)
.first;
}
return null;
}
}
/// Defines a collection of [Story] instances, internally held in TilerModel.
/// Calls [notifyListeners] when a story is added or removed from the cluster.
class ClusterModel extends ChangeNotifier {
/// The title of the cluster.
String title = '';
/// The [TilerModel] that holds the [Tile] per [Story].
final TilerModel<ErmineStory> tilerModel = TilerModel();
ClusterModel({this.title});
final List<ErmineStory> stories = <ErmineStory>[];
final _storyToTile = <String, TileModel<ErmineStory>>{};
bool get isEmpty => tilerModel.root.isEmpty;
ErmineStory getStory(String id) => _storyToTile[id]?.content;
/// Adds a [story] to this cluster.
void add(ErmineStory story) {
assert(!_storyToTile.containsKey(story.id));
final tile = tilerModel.add(content: story, minSize: _kMinTileSize);
_storyToTile[story.id] = tile;
stories.add(story);
notifyListeners();
}
/// Removes the [story] from this cluster.
void remove(ErmineStory story) {
assert(_storyToTile.containsKey(story.id));
final tile = _storyToTile[story.id];
tilerModel.remove(tile);
_storyToTile.remove(story.id);
stories.remove(story);
notifyListeners();
}
}