[login_shell] move userpicker_base_shell
Moves the userpicker_bash_shell out of topaz
and into experiences. This is an initial step of a
soft transition which just puts the code in place.
There are no references to this location in the
tree so the workstation build will still compile.
Change-Id: Idc920def9796cfdfcbeb2ca17cf777697091324f
diff --git a/session_shells/ermine/login_shell/BUILD.gn b/session_shells/ermine/login_shell/BUILD.gn
new file mode 100644
index 0000000..447d7ad
--- /dev/null
+++ b/session_shells/ermine/login_shell/BUILD.gn
@@ -0,0 +1,50 @@
+# Copyright 2017 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("//topaz/runtime/flutter_runner/flutter_app.gni")
+
+flutter_app("userpicker_base_shell") {
+ main_dart = "lib/main.dart"
+
+ meta = [
+ {
+ path = rebase_path("meta/userpicker_base_shell.cmx")
+ dest = "userpicker_base_shell.cmx"
+ },
+ ]
+
+ package_name = "userpicker_base_shell"
+
+ manifest = "pubspec.yaml"
+
+ sources = [
+ "authentication_overlay.dart",
+ "authentication_overlay_model.dart",
+ "authentication_ui_context_impl.dart",
+ "circular_button.dart",
+ "clock.dart",
+ "time_stringer.dart",
+ "user_list.dart",
+ "user_picker_base_shell_model.dart",
+ "user_picker_base_shell_screen.dart",
+ "user_picker_screen.dart",
+ ]
+
+ deps = [
+ "//sdk/fidl/fuchsia.modular",
+ "//sdk/fidl/fuchsia.modular.auth",
+ "//sdk/fidl/fuchsia.timezone",
+ "//sdk/fidl/fuchsia.ui.input",
+ "//sdk/fidl/fuchsia.ui.views",
+ "//third_party/dart-pkg/git/flutter/packages/flutter",
+ "//third_party/dart-pkg/pub/http",
+ "//topaz/lib/base_shell:lib.base_shell",
+ "//topaz/lib/settings:lib.settings",
+ "//topaz/public/dart/fuchsia_logger",
+ "//topaz/public/dart/fuchsia_scenic_flutter",
+ "//topaz/public/dart/widgets:lib.widgets",
+ "//topaz/public/lib/device/dart",
+ "//zircon/system/fidl/fuchsia-device-manager",
+ ]
+}
diff --git a/session_shells/ermine/login_shell/README.md b/session_shells/ermine/login_shell/README.md
new file mode 100644
index 0000000..76b12ff
--- /dev/null
+++ b/session_shells/ermine/login_shell/README.md
@@ -0,0 +1,6 @@
+# User-picker Base Shell
+
+This directory contains an example implementation of base shell written in
+Flutter that can be used to choose from a list of users to login.
+
+This is started by basemgr unless overidden from command line.
diff --git a/session_shells/ermine/login_shell/analysis_options.yaml b/session_shells/ermine/login_shell/analysis_options.yaml
new file mode 100644
index 0000000..54917c0
--- /dev/null
+++ b/session_shells/ermine/login_shell/analysis_options.yaml
@@ -0,0 +1,5 @@
+# Copyright 2017 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.
+
+include: ../../../analysis_options.yaml
diff --git a/session_shells/ermine/login_shell/lib/authentication_overlay.dart b/session_shells/ermine/login_shell/lib/authentication_overlay.dart
new file mode 100644
index 0000000..ae4c170
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/authentication_overlay.dart
@@ -0,0 +1,60 @@
+// Copyright 2017 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:fuchsia_scenic_flutter/child_view.dart' show ChildView;
+import 'package:flutter/material.dart';
+import 'package:lib.widgets/model.dart';
+
+import 'authentication_overlay_model.dart';
+
+/// Signature for callback used to indicate that the user cancelled the authorization
+/// flow.
+typedef AuthenticationCancelCallback = void Function();
+
+/// Displays the authentication window.
+class AuthenticationOverlay extends StatelessWidget {
+ /// Constructs an authentication overlay that calls the provided callback if the
+ /// user cancels the login flow.
+ const AuthenticationOverlay({AuthenticationCancelCallback onCancel})
+ : _onCancel = onCancel;
+
+ /// The callback that is triggered when the user taps outside of the child view,
+ /// cancelling the authorization flow.
+ final AuthenticationCancelCallback _onCancel;
+
+ @override
+ Widget build(BuildContext context) =>
+ ScopedModelDescendant<AuthenticationOverlayModel>(
+ builder: (
+ BuildContext context,
+ Widget child,
+ AuthenticationOverlayModel model,
+ ) =>
+ AnimatedBuilder(
+ animation: model.animation,
+ builder: (BuildContext context, Widget child) => Offstage(
+ offstage: model.animation.isDismissed,
+ child: Opacity(
+ opacity: model.animation.value,
+ child: child,
+ ),
+ ),
+ child: Stack(
+ fit: StackFit.passthrough,
+ children: <Widget>[
+ GestureDetector(
+ onTap: _onCancel,
+ ),
+ FractionallySizedBox(
+ widthFactor: 0.75,
+ heightFactor: 0.75,
+ child: ChildView(
+ connection: model.childViewConnection,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
diff --git a/session_shells/ermine/login_shell/lib/authentication_overlay_model.dart b/session_shells/ermine/login_shell/lib/authentication_overlay_model.dart
new file mode 100644
index 0000000..ac146ae
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/authentication_overlay_model.dart
@@ -0,0 +1,62 @@
+// Copyright 2017 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:fidl_fuchsia_ui_views/fidl_async.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:fuchsia_scenic_flutter/child_view_connection.dart'
+ show ChildViewConnection;
+import 'package:lib.widgets/model.dart';
+
+/// Manages the connection and animation of the authentication window.
+class AuthenticationOverlayModel extends Model implements TickerProvider {
+ ChildViewConnection _childViewConnection;
+ AnimationController _transitionAnimation;
+ CurvedAnimation _curvedTransitionAnimation;
+
+ /// Constructor.
+ AuthenticationOverlayModel() {
+ _transitionAnimation = AnimationController(
+ value: 0.0,
+ duration: Duration(seconds: 1),
+ vsync: this,
+ );
+ _curvedTransitionAnimation = CurvedAnimation(
+ parent: _transitionAnimation,
+ curve: Curves.fastOutSlowIn,
+ reverseCurve: Curves.fastOutSlowIn,
+ );
+ }
+
+ /// If not null, returns the handle of the current requested overlay.
+ ChildViewConnection get childViewConnection => _childViewConnection;
+
+ /// The animation controlling the fading in and out of the authentication
+ /// overlay.
+ CurvedAnimation get animation => _curvedTransitionAnimation;
+
+ /// Starts showing an overlay over all other content.
+ void onStartOverlay(ViewHolderToken overlayViewHolderToken) {
+ _childViewConnection = ChildViewConnection(
+ overlayViewHolderToken,
+ onAvailable: (ChildViewConnection connection) {
+ connection.requestFocus();
+ },
+ onUnavailable: (ChildViewConnection connection) {
+ _transitionAnimation.reverse();
+ },
+ );
+ _transitionAnimation.forward();
+ notifyListeners();
+ }
+
+ /// Stops showing a previously started overlay.
+ void onStopOverlay() {
+ _transitionAnimation.reverse();
+ notifyListeners();
+ }
+
+ @override
+ Ticker createTicker(TickerCallback onTick) => Ticker(onTick);
+}
diff --git a/session_shells/ermine/login_shell/lib/authentication_ui_context_impl.dart b/session_shells/ermine/login_shell/lib/authentication_ui_context_impl.dart
new file mode 100644
index 0000000..1c9ad39
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/authentication_ui_context_impl.dart
@@ -0,0 +1,33 @@
+// Copyright 2018 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:fidl_fuchsia_auth/fidl_async.dart';
+import 'package:fidl_fuchsia_ui_views/fidl_async.dart';
+import 'package:flutter/widgets.dart';
+
+/// Called when an authentication overlay needs to be started.
+typedef OnStartOverlay = void Function(ViewHolderToken viewHolderToken);
+
+/// An [AuthenticationUiContext] which calls its callbacks to show an overlay.
+class AuthenticationUiContextImpl extends AuthenticationUiContext {
+ /// Called when an aunthentication overlay needs to be started.
+ final OnStartOverlay _onStartOverlay;
+
+ /// Called when an aunthentication overlay needs to be stopped.
+ final VoidCallback _onStopOverlay;
+
+ /// Builds an AuthenticationUiContext that takes |ViewHolderToken| callbacks
+ /// to start and stop an authentication display overlay.
+ AuthenticationUiContextImpl(
+ {OnStartOverlay onStartOverlay, VoidCallback onStopOverlay})
+ : _onStartOverlay = onStartOverlay,
+ _onStopOverlay = onStopOverlay;
+
+ @override
+ Future<void> startOverlay(ViewHolderToken viewHolderToken) async =>
+ _onStartOverlay?.call(viewHolderToken);
+
+ @override
+ Future<void> stopOverlay() async => _onStopOverlay?.call();
+}
diff --git a/session_shells/ermine/login_shell/lib/circular_button.dart b/session_shells/ermine/login_shell/lib/circular_button.dart
new file mode 100644
index 0000000..9ff5eae
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/circular_button.dart
@@ -0,0 +1,33 @@
+// Copyright 2017 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/material.dart';
+import 'package:meta/meta.dart';
+
+/// A button that is circular
+class CircularButton extends StatelessWidget {
+ /// Callback that is fired when the button is tapped
+ final VoidCallback onTap;
+
+ /// The icon to show in the button
+ final IconData icon;
+
+ /// Constructor
+ const CircularButton({@required this.icon, this.onTap})
+ : assert(icon != null);
+
+ @override
+ Widget build(BuildContext context) => Material(
+ type: MaterialType.circle,
+ elevation: 2.0,
+ color: Colors.grey[200],
+ child: InkWell(
+ onTap: () => onTap?.call(),
+ child: Container(
+ padding: EdgeInsets.all(12.0),
+ child: Icon(icon),
+ ),
+ ),
+ );
+}
diff --git a/session_shells/ermine/login_shell/lib/clock.dart b/session_shells/ermine/login_shell/lib/clock.dart
new file mode 100644
index 0000000..98c04d7
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/clock.dart
@@ -0,0 +1,41 @@
+// Copyright 2017 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 'dart:math';
+
+import 'package:flutter/material.dart';
+
+import 'time_stringer.dart';
+
+/// System Clock in the Base Shell
+class Clock extends StatelessWidget {
+ final TimeStringer _time = TimeStringer();
+
+ @override
+ Widget build(BuildContext context) {
+ return LayoutBuilder(
+ builder: (BuildContext context, BoxConstraints constraints) {
+ return AnimatedBuilder(
+ animation: _time,
+ builder: (BuildContext context, Widget child) {
+ return Container(
+ child: Text(
+ _time.timeOnly,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: min(
+ constraints.maxWidth / 6.0,
+ constraints.maxHeight / 6.0,
+ ),
+ fontWeight: FontWeight.w200,
+ letterSpacing: 4.0,
+ ),
+ ),
+ );
+ },
+ );
+ },
+ );
+ }
+}
diff --git a/session_shells/ermine/login_shell/lib/main.dart b/session_shells/ermine/login_shell/lib/main.dart
new file mode 100644
index 0000000..8db8b1c
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/main.dart
@@ -0,0 +1,172 @@
+// Copyright 2017 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:fidl_fuchsia_netstack/fidl_async.dart';
+import 'package:flutter/material.dart';
+import 'package:fuchsia_logger/logger.dart';
+import 'package:fuchsia_services/services.dart';
+import 'package:lib.base_shell/base_shell_widget.dart';
+import 'package:lib.base_shell/netstack_model.dart';
+import 'package:lib.widgets/application.dart';
+import 'package:lib.widgets/model.dart';
+import 'package:meta/meta.dart';
+import 'package:fidl_fuchsia_sys/fidl_async.dart';
+
+import 'authentication_overlay.dart';
+import 'authentication_overlay_model.dart';
+import 'authentication_ui_context_impl.dart';
+import 'user_picker_base_shell_model.dart';
+import 'user_picker_base_shell_screen.dart';
+
+const double _kMousePointerElevation = 800.0;
+const double _kIndicatorElevation = _kMousePointerElevation - 1.0;
+
+/// The main base shell widget.
+BaseShellWidget<UserPickerBaseShellModel> _baseShellWidget;
+
+void main() {
+ setupLogger(name: 'userpicker_base_shell');
+ StartupContext startupContext = StartupContext.fromStartupInfo();
+ final launcherProxy = LauncherProxy();
+ startupContext.incoming.connectToService(launcherProxy);
+
+ NetstackProxy netstackProxy = NetstackProxy();
+ startupContext.incoming.connectToService(netstackProxy);
+
+ NetstackModel netstackModel = NetstackModel(netstack: netstackProxy)..start();
+
+ _OverlayModel wifiInfoOverlayModel = _OverlayModel();
+
+ final AuthenticationOverlayModel authModel = AuthenticationOverlayModel();
+
+ UserPickerBaseShellModel userPickerBaseShellModel = UserPickerBaseShellModel(
+ onBaseShellStopped: () {
+ netstackProxy.ctrl.close();
+ netstackModel.dispose();
+ },
+ onLogin: () {
+ wifiInfoOverlayModel.showing = false;
+ },
+ onWifiTapped: () {
+ wifiInfoOverlayModel.showing = !wifiInfoOverlayModel.showing;
+ },
+ );
+
+ Widget mainWidget = Stack(
+ fit: StackFit.passthrough,
+ children: <Widget>[
+ UserPickerBaseShellScreen(
+ launcher: launcherProxy,
+ ),
+ ScopedModel<AuthenticationOverlayModel>(
+ model: authModel,
+ child: AuthenticationOverlay(),
+ ),
+ ],
+ );
+
+ Widget app = mainWidget;
+
+ List<OverlayEntry> overlays = <OverlayEntry>[
+ OverlayEntry(
+ builder: (BuildContext context) => MediaQuery(
+ data: MediaQueryData(),
+ child: FocusScope(
+ node: FocusScopeNode(),
+ autofocus: true,
+ child: app,
+ ),
+ ),
+ ),
+ OverlayEntry(
+ builder: (BuildContext context) => ScopedModel<_OverlayModel>(
+ model: wifiInfoOverlayModel,
+ child: _WifiInfo(
+ wifiWidget: ApplicationWidget(
+ url:
+ 'fuchsia-pkg://fuchsia.com/wifi_settings#meta/wifi_settings.cmx',
+ launcher: launcherProxy,
+ ),
+ ),
+ ),
+ ),
+ ];
+
+ _baseShellWidget = BaseShellWidget<UserPickerBaseShellModel>(
+ startupContext: startupContext,
+ baseShellModel: userPickerBaseShellModel,
+ authenticationUiContext: AuthenticationUiContextImpl(
+ onStartOverlay: authModel.onStartOverlay,
+ onStopOverlay: authModel.onStopOverlay),
+ child: LayoutBuilder(
+ builder: (BuildContext context, BoxConstraints constraints) =>
+ (constraints.biggest == Size.zero)
+ ? const Offstage()
+ : ScopedModel<NetstackModel>(
+ model: netstackModel,
+ child: Overlay(initialEntries: overlays),
+ ),
+ ),
+ );
+
+ runApp(_baseShellWidget);
+
+ _baseShellWidget.advertise();
+}
+
+class _WifiInfo extends StatelessWidget {
+ final Widget wifiWidget;
+
+ const _WifiInfo({@required this.wifiWidget}) : assert(wifiWidget != null);
+
+ @override
+ Widget build(BuildContext context) => ScopedModelDescendant<_OverlayModel>(
+ builder: (
+ BuildContext context,
+ Widget child,
+ _OverlayModel model,
+ ) =>
+ Offstage(
+ offstage: !model.showing,
+ child: Stack(
+ children: <Widget>[
+ Listener(
+ behavior: HitTestBehavior.opaque,
+ onPointerDown: (PointerDownEvent event) {
+ model.showing = false;
+ },
+ ),
+ Center(
+ child: FractionallySizedBox(
+ widthFactor: 0.75,
+ heightFactor: 0.75,
+ child: Container(
+ margin: EdgeInsets.all(8.0),
+ child: PhysicalModel(
+ color: Colors.grey[900],
+ elevation: _kIndicatorElevation,
+ borderRadius: BorderRadius.circular(8.0),
+ child: wifiWidget,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
+
+class _OverlayModel extends Model {
+ bool _showing = false;
+
+ set showing(bool showing) {
+ if (_showing != showing) {
+ _showing = showing;
+ notifyListeners();
+ }
+ }
+
+ bool get showing => _showing;
+}
diff --git a/session_shells/ermine/login_shell/lib/time_stringer.dart b/session_shells/ermine/login_shell/lib/time_stringer.dart
new file mode 100644
index 0000000..b35a06d
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/time_stringer.dart
@@ -0,0 +1,93 @@
+// Copyright 2016 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 'dart:async';
+
+import 'package:flutter/widgets.dart';
+import 'package:intl/intl.dart';
+
+final DateFormat _kTimeOnlyDateFormat = DateFormat('h:mm', 'en_US');
+final DateFormat _kDateOnlyDateFormat = DateFormat('EEEE MMM d', 'en_US');
+final DateFormat _kShortStringDateFormat = DateFormat('h:mm', 'en_US');
+final DateFormat _kLongStringDateFormat = DateFormat('EEEE h:mm', 'en_US');
+final DateFormat _kMeridiemOnlyFormat = DateFormat('a', 'en_US');
+
+/// Creates time strings and notifies when they change.
+class TimeStringer extends Listenable {
+ final Set<VoidCallback> _listeners = <VoidCallback>{};
+ Timer _timer;
+ int _offsetMinutes = 0;
+
+ /// [listener] will be called whenever [shortString] or [longString] have
+ /// changed.
+ @override
+ void addListener(VoidCallback listener) {
+ _listeners.add(listener);
+ if (_listeners.length == 1) {
+ _scheduleTimer();
+ }
+ }
+
+ /// [listener] will no longer be called whenever [shortString] or [longString]
+ /// have changed.
+ @override
+ void removeListener(VoidCallback listener) {
+ _listeners.remove(listener);
+ if (_listeners.isEmpty) {
+ _timer?.cancel();
+ _timer = null;
+ }
+ }
+
+ /// Returns the time only (eg. '10:34').
+ String get timeOnly => _kTimeOnlyDateFormat
+ .format(
+ DateTime.now(),
+ )
+ .toUpperCase();
+
+ /// Returns the date only (eg. 'MONDAY AUG 3').
+ String get dateOnly => _kDateOnlyDateFormat
+ .format(
+ DateTime.now(),
+ )
+ .toUpperCase();
+
+ /// Returns a short version of the time (eg. '10:34').
+ String get shortString =>
+ _kShortStringDateFormat.format(DateTime.now()).toLowerCase();
+
+ /// Returns a long version of the time including the day (eg. 'Monday 10:34').
+ String get longString =>
+ _kLongStringDateFormat.format(DateTime.now()).toLowerCase();
+
+ /// Returns the meridiem (eg. 'AM')
+ String get meridiem =>
+ _kMeridiemOnlyFormat.format(DateTime.now()).toUpperCase();
+
+ /// Returns the offset, in minutes.
+ int get offsetMinutes => _offsetMinutes;
+
+ set offsetMinutes(int offsetMinutes) {
+ if (_offsetMinutes != offsetMinutes) {
+ _offsetMinutes = offsetMinutes;
+ _notifyListeners();
+ }
+ }
+
+ void _scheduleTimer() {
+ _timer?.cancel();
+ _timer =
+ Timer(Duration(seconds: 61 - DateTime.now().second), () {
+ _notifyListeners();
+ _scheduleTimer();
+ });
+ }
+
+ void _notifyListeners() {
+ for (VoidCallback listener in _listeners.toList()) {
+ listener();
+ }
+ }
+}
diff --git a/session_shells/ermine/login_shell/lib/user_list.dart b/session_shells/ermine/login_shell/lib/user_list.dart
new file mode 100644
index 0000000..919e021
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/user_list.dart
@@ -0,0 +1,388 @@
+// Copyright 2017 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:fidl_fuchsia_modular_auth/fidl_async.dart';
+import 'package:flutter/material.dart';
+import 'package:lib.widgets/model.dart';
+import 'package:lib.widgets/widgets.dart';
+
+import 'user_picker_base_shell_model.dart';
+
+const double _kUserAvatarSizeLarge = 56.0;
+const double _kUserAvatarSizeSmall = 48.0;
+const double _kButtonWidthLarge = 128.0;
+const double _kButtonWidthSmall = 116.0;
+const double _kButtonFontSizeLarge = 16.0;
+const double _kButtonFontSizeSmall = 14.0;
+
+const TextStyle _kTextStyle = TextStyle(
+ color: Colors.white,
+ fontSize: 10.0,
+ letterSpacing: 1.0,
+ fontWeight: FontWeight.w300,
+);
+
+final BorderRadius _kButtonBorderRadiusPhone =
+ BorderRadius.circular(_kUserAvatarSizeSmall / 2.0);
+final BorderRadius _kButtonBorderRadiusLarge =
+ BorderRadius.circular(_kUserAvatarSizeLarge / 2.0);
+
+/// Shows the list of users and allows the user to add new users
+class UserList extends StatelessWidget {
+ /// True if login should be disabled.
+ final bool loginDisabled;
+
+ /// Constructor.
+ const UserList({this.loginDisabled = false});
+
+ Widget _buildUserCircle({
+ Account account,
+ VoidCallback onTap,
+ bool isSmall,
+ }) {
+ double size = isSmall ? _kUserAvatarSizeSmall : _kUserAvatarSizeLarge;
+ return GestureDetector(
+ onTap: () => onTap?.call(),
+ child: Container(
+ height: size,
+ width: size,
+ child: Alphatar.fromNameAndUrl(
+ name: account.displayName,
+ avatarUrl: _getImageUrl(account),
+ size: size,
+ ),
+ ),
+ );
+ }
+
+ Widget _buildIconButton({
+ VoidCallback onTap,
+ bool isSmall,
+ IconData icon,
+ }) {
+ double size = isSmall ? _kUserAvatarSizeSmall : _kUserAvatarSizeLarge;
+ return _buildUserActionButton(
+ onTap: () => onTap?.call(),
+ width: size,
+ isSmall: isSmall,
+ child: Center(
+ child: Icon(
+ icon,
+ color: Colors.white,
+ size: size / 2.0,
+ ),
+ ),
+ );
+ }
+
+ Widget _buildUserActionButton({
+ Widget child,
+ VoidCallback onTap,
+ bool isSmall,
+ double width,
+ bool isDisabled = false,
+ }) {
+ return GestureDetector(
+ onTap: isDisabled ? null : () => onTap?.call(),
+ child: Container(
+ height: isSmall ? _kUserAvatarSizeSmall : _kUserAvatarSizeLarge,
+ width: width ?? (isSmall ? _kButtonWidthSmall : _kButtonWidthLarge),
+ alignment: FractionalOffset.center,
+ margin: EdgeInsets.only(left: 16.0),
+ decoration: BoxDecoration(
+ borderRadius:
+ isSmall ? _kButtonBorderRadiusPhone : _kButtonBorderRadiusLarge,
+ border: Border.all(
+ color: isDisabled ? Colors.grey : Colors.white,
+ width: 1.0,
+ ),
+ ),
+ child: child,
+ ),
+ );
+ }
+
+ Widget _buildExpandedUserActions({
+ UserPickerBaseShellModel model,
+ bool isSmall,
+ }) {
+ double fontSize = isSmall ? _kButtonFontSizeSmall : _kButtonFontSizeLarge;
+
+ if (loginDisabled) {
+ return Row(
+ children: <Widget>[
+ _buildUserActionButton(
+ child: Text(
+ 'RESET',
+ style: TextStyle(
+ fontSize: fontSize,
+ color: Colors.white,
+ ),
+ ),
+ onTap: model.resetTapped,
+ isSmall: isSmall,
+ ),
+ _buildUserActionButton(
+ child: Text(
+ 'WIFI',
+ style: TextStyle(
+ fontSize: fontSize,
+ color: Colors.white,
+ ),
+ ),
+ onTap: model.wifiTapped,
+ isSmall: isSmall,
+ ),
+ _buildUserActionButton(
+ child: Text(
+ 'LOGIN DISABLED => No SessionShell configured',
+ style: TextStyle(
+ fontSize: fontSize,
+ color: Colors.white,
+ ),
+ ),
+ onTap: () {},
+ isSmall: isSmall,
+ isDisabled: true,
+ ),
+ ],
+ );
+ }
+ return Row(
+ children: <Widget>[
+ _buildIconButton(
+ onTap: () => model.hideUserActions(),
+ isSmall: isSmall,
+ icon: Icons.close,
+ ),
+ _buildUserActionButton(
+ child: Text(
+ 'RESET',
+ style: TextStyle(
+ fontSize: fontSize,
+ color: Colors.white,
+ ),
+ ),
+ onTap: model.resetTapped,
+ isSmall: isSmall,
+ ),
+ _buildUserActionButton(
+ child: Text(
+ 'WIFI',
+ style: TextStyle(
+ fontSize: fontSize,
+ color: Colors.white,
+ ),
+ ),
+ onTap: model.wifiTapped,
+ isSmall: isSmall,
+ ),
+ _buildUserActionButton(
+ child: Text(
+ 'LOGIN',
+ style: TextStyle(
+ fontSize: fontSize,
+ color: Colors.white,
+ ),
+ ),
+ onTap: () {
+ model
+ ..createAndLoginUser()
+ ..hideUserActions();
+ },
+ isSmall: isSmall,
+ ),
+ _buildUserActionButton(
+ child: Text(
+ 'GUEST',
+ style: TextStyle(
+ fontSize: fontSize,
+ color: Colors.white,
+ ),
+ ),
+ onTap: () {
+ model
+ ..login(null)
+ ..hideUserActions();
+ },
+ isSmall: isSmall,
+ ),
+ ],
+ );
+ }
+
+ String _getImageUrl(Account account) {
+ if (account.imageUrl == null) {
+ return null;
+ }
+ Uri uri = Uri.parse(account.imageUrl);
+ if (uri.queryParameters['sz'] != null) {
+ Map<String, dynamic> queryParameters = Map<String, dynamic>.from(
+ uri.queryParameters,
+ );
+ queryParameters['sz'] = '160';
+ uri = uri.replace(queryParameters: queryParameters);
+ }
+ return uri.toString();
+ }
+
+ Widget _buildUserEntry({
+ Account account,
+ VoidCallback onTap,
+ bool removable = true,
+ bool isSmall,
+ UserPickerBaseShellModel model,
+ }) {
+ Widget userCard = _buildUserCircle(
+ account: account,
+ onTap: onTap,
+ isSmall: isSmall,
+ );
+
+ if (!removable) {
+ return userCard;
+ }
+
+ Widget userImage = LongPressDraggable<Account>(
+ child: userCard,
+ feedback: userCard,
+ data: account,
+ childWhenDragging: Opacity(opacity: 0.0, child: userCard),
+ feedbackOffset: Offset.zero,
+ dragAnchor: DragAnchor.child,
+ maxSimultaneousDrags: 1,
+ onDragStarted: () => model.addDraggedUser(account),
+ onDraggableCanceled: (_, __) => model.removeDraggedUser(account),
+ );
+
+ if (model.showingUserActions) {
+ return Padding(
+ padding: EdgeInsets.only(left: 16.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: <Widget>[
+ Padding(
+ padding: EdgeInsets.only(bottom: 8.0),
+ child: Text(
+ account.displayName,
+ textAlign: TextAlign.center,
+ maxLines: 1,
+ style: _kTextStyle,
+ ),
+ ),
+ userImage,
+ ],
+ ),
+ );
+ } else {
+ return Padding(
+ padding: EdgeInsets.only(left: 16.0),
+ child: userImage,
+ );
+ }
+ }
+
+ Widget _buildUserList(UserPickerBaseShellModel model) {
+ return LayoutBuilder(
+ builder: (BuildContext context, BoxConstraints constraints) {
+ List<Widget> children = <Widget>[];
+
+ bool isSmall =
+ constraints.maxWidth < 600.0 || constraints.maxHeight < 600.0;
+
+ if (model.showingUserActions) {
+ children.add(
+ Align(
+ alignment: FractionalOffset.bottomCenter,
+ child: _buildExpandedUserActions(
+ model: model,
+ isSmall: isSmall,
+ ),
+ ),
+ );
+ } else {
+ children.add(
+ Align(
+ alignment: FractionalOffset.bottomCenter,
+ child: _buildIconButton(
+ onTap: model.showUserActions,
+ isSmall: isSmall,
+ icon: Icons.add,
+ ),
+ ),
+ );
+ }
+
+ children.addAll(
+ model.accounts.map(
+ (Account account) => Align(
+ alignment: FractionalOffset.bottomCenter,
+ child: _buildUserEntry(
+ account: account,
+ onTap: () {
+ model
+ ..login(account.id)
+ ..hideUserActions();
+ },
+ isSmall: isSmall,
+ model: model,
+ ),
+ ),
+ ),
+ );
+
+ return Container(
+ height: (isSmall ? _kUserAvatarSizeSmall : _kUserAvatarSizeLarge) +
+ 24.0 +
+ (model.showingUserActions ? 24.0 : 0.0),
+ child: AnimatedOpacity(
+ duration: Duration(milliseconds: 250),
+ opacity: model.showingRemoveUserTarget ? 0.0 : 1.0,
+ child: ListView(
+ padding: EdgeInsets.only(
+ bottom: 24.0,
+ right: 24.0,
+ ),
+ scrollDirection: Axis.horizontal,
+ reverse: true,
+ physics: BouncingScrollPhysics(),
+ shrinkWrap: true,
+ children: children,
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) =>
+ ScopedModelDescendant<UserPickerBaseShellModel>(builder: (
+ BuildContext context,
+ Widget child,
+ UserPickerBaseShellModel model,
+ ) {
+ if (model.showingLoadingSpinner) {
+ return Stack(
+ fit: StackFit.passthrough,
+ children: <Widget>[
+ Center(
+ child: Container(
+ width: 64.0,
+ height: 64.0,
+ child: FuchsiaSpinner(),
+ ),
+ ),
+ ],
+ );
+ } else {
+ return Stack(
+ fit: StackFit.passthrough,
+ children: <Widget>[
+ _buildUserList(model),
+ ],
+ );
+ }
+ });
+}
diff --git a/session_shells/ermine/login_shell/lib/user_picker_base_shell_model.dart b/session_shells/ermine/login_shell/lib/user_picker_base_shell_model.dart
new file mode 100644
index 0000000..decbdb0
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/user_picker_base_shell_model.dart
@@ -0,0 +1,201 @@
+// Copyright 2017 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 'dart:async';
+
+import 'package:fidl/fidl.dart';
+import 'package:fidl_fuchsia_cobalt/fidl_async.dart' as cobalt;
+import 'package:fidl_fuchsia_device_manager/fidl_async.dart' as devmgr;
+import 'package:fidl_fuchsia_modular_auth/fidl_async.dart';
+import 'package:fidl_fuchsia_sys/fidl_async.dart';
+import 'package:fidl_fuchsia_ui_policy/fidl_async.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:lib.base_shell/base_model.dart';
+import 'package:lib.widgets/model.dart';
+import 'package:zircon/zircon.dart';
+import 'package:fuchsia_logger/logger.dart';
+
+export 'package:lib.widgets/model.dart'
+ show ScopedModel, ScopedModelDescendant, ModelFinder;
+
+/// Function signature for GetPresentationMode callback
+typedef GetPresentationModeCallback = void Function(PresentationMode mode);
+
+const Duration _kShowLoadingSpinnerDelay = Duration(milliseconds: 500);
+
+/// Model that provides common state
+class UserPickerBaseShellModel extends CommonBaseShellModel
+ with TickerProviderModelMixin
+ implements
+ ServiceProvider,
+ KeyboardCaptureListenerHack,
+ PointerCaptureListenerHack {
+ /// Called when the base shell stops.
+ final VoidCallback onBaseShellStopped;
+
+ /// Called when wifi is tapped.
+ final VoidCallback onWifiTapped;
+
+ /// Called when a user is logging in.
+ final VoidCallback onLogin;
+
+ bool _showingUserActions = false;
+ bool _addingUser = false;
+ bool _loadingChildView = false;
+ final Set<Account> _draggedUsers = <Account>{};
+
+ /// Constructor
+ UserPickerBaseShellModel({
+ this.onBaseShellStopped,
+ this.onWifiTapped,
+ this.onLogin,
+ cobalt.Logger logger,
+ }) : super(logger);
+
+ @override
+ void onStop() {
+ onBaseShellStopped?.call();
+ super.dispose();
+ super.onStop();
+ }
+
+ /// Refreshes the list of users.
+ @override
+ Future<void> refreshUsers() async {
+ _updateShowLoadingSpinner();
+ notifyListeners();
+ await super.refreshUsers();
+ _updateShowLoadingSpinner();
+ notifyListeners();
+ }
+
+ /// Call when wifi is tapped.
+ void wifiTapped() {
+ onWifiTapped?.call();
+ }
+
+ /// Call when reset is tapped.
+ void resetTapped() async {
+ final ChannelPair channels = ChannelPair();
+ if (channels.status != 0) {
+ log.severe('Unable to create channels: $channels.status');
+ return;
+ }
+
+ int status = System.connectToService(
+ '/svc/${devmgr.Administrator.$serviceName}',
+ channels.second.passHandle());
+ if (status != 0 ) {
+ channels.first.close();
+ log.severe('Unable to connect to device administrator service: $status');
+ return;
+ }
+
+ final devmgr.AdministratorProxy admin = devmgr.AdministratorProxy();
+ admin.ctrl.bind(InterfaceHandle<devmgr.Administrator>(channels.first));
+
+ status = await admin.suspend(devmgr.suspendFlagReboot);
+ if (status != 0) {
+ log.severe('Reboot call failed with status: $status');
+ }
+
+ admin.ctrl.close();
+ }
+
+ /// Create a new user and login with that user
+ @override
+ Future createAndLoginUser() async {
+ _addingUser = true;
+ _updateShowLoadingSpinner();
+
+ await super.createAndLoginUser();
+
+ _addingUser = false;
+ _updateShowLoadingSpinner();
+ notifyListeners();
+ }
+
+ /// Login with given user
+ @override
+ Future<void> login(String accountId) async {
+ _loadingChildView = true;
+ _updateShowLoadingSpinner();
+ notifyListeners();
+
+ await super.login(accountId);
+
+ _loadingChildView = false;
+ _updateShowLoadingSpinner();
+ notifyListeners();
+ }
+
+ /// Show advanced user actions such as:
+ /// * Guest login
+ /// * Create new account
+ void showUserActions() {
+ _showingUserActions = true;
+ notifyListeners();
+ }
+
+ /// Hide advanced user actions such as:
+ void hideUserActions() {
+ _showingUserActions = false;
+ notifyListeners();
+ }
+
+ /// Add a user to the list of dragged users
+ void addDraggedUser(Account account) {
+ _draggedUsers.add(account);
+ notifyListeners();
+ }
+
+ /// Remove a user from the list of dragged users
+ void removeDraggedUser(Account account) {
+ _draggedUsers.remove(account);
+ notifyListeners();
+ }
+
+ /// Show the loading spinner if true
+ bool get showingLoadingSpinner => _showingLoadingSpinner;
+
+ /// Show the system clock if true
+ bool get showingClock => !showingLoadingSpinner && _draggedUsers.isEmpty;
+
+ /// If true, show advanced user actions
+ bool get showingUserActions => _showingUserActions;
+
+ /// If true, show the remove user target
+ bool get showingRemoveUserTarget => _draggedUsers.isNotEmpty;
+
+ /// Returns true the add user dialog is showing
+ bool get addingUser => _addingUser;
+
+ /// Returns true if we are "loading" the child view
+ bool get loadingChildView => _loadingChildView;
+
+ bool _showingLoadingSpinner = true;
+ Timer _showLoadingSpinnerTimer;
+
+ void _updateShowLoadingSpinner() {
+ if (accounts == null || _addingUser || _loadingChildView) {
+ if (_showingLoadingSpinner == null) {
+ _showLoadingSpinnerTimer = Timer(
+ _kShowLoadingSpinnerDelay,
+ () {
+ _showingLoadingSpinner = true;
+ _showLoadingSpinnerTimer = null;
+ notifyListeners();
+ },
+ );
+ }
+ } else {
+ _showLoadingSpinnerTimer?.cancel();
+ _showLoadingSpinnerTimer = null;
+ _showingLoadingSpinner = false;
+ notifyListeners();
+ }
+ }
+}
diff --git a/session_shells/ermine/login_shell/lib/user_picker_base_shell_screen.dart b/session_shells/ermine/login_shell/lib/user_picker_base_shell_screen.dart
new file mode 100644
index 0000000..61e9947
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/user_picker_base_shell_screen.dart
@@ -0,0 +1,45 @@
+// Copyright 2017 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:fidl_fuchsia_sys/fidl_async.dart';
+import 'package:flutter/material.dart';
+import 'package:meta/meta.dart';
+
+import 'clock.dart';
+import 'user_picker_base_shell_model.dart';
+import 'user_picker_screen.dart';
+
+/// The root widget which displays all the other windows of this app.
+class UserPickerBaseShellScreen extends StatelessWidget {
+ /// Launcher to launch the kernel panic module if needed.
+ final Launcher launcher;
+
+ /// Constructor.
+ const UserPickerBaseShellScreen({
+ @required this.launcher,
+ Key key,
+ }) : super(key: key);
+ @override
+ Widget build(BuildContext context) {
+ return ScopedModelDescendant<UserPickerBaseShellModel>(
+ builder: (
+ BuildContext context,
+ Widget child,
+ UserPickerBaseShellModel model,
+ ) {
+ List<Widget> stackChildren = <Widget>[
+ UserPickerScreen(),
+ Align(
+ alignment: FractionalOffset.center,
+ child: Offstage(
+ offstage: !model.showingClock,
+ child: Clock(),
+ ),
+ ),
+ ];
+
+ return Stack(fit: StackFit.expand, children: stackChildren);
+ },
+ );
+ }
+}
diff --git a/session_shells/ermine/login_shell/lib/user_picker_screen.dart b/session_shells/ermine/login_shell/lib/user_picker_screen.dart
new file mode 100644
index 0000000..144c05f
--- /dev/null
+++ b/session_shells/ermine/login_shell/lib/user_picker_screen.dart
@@ -0,0 +1,192 @@
+// Copyright 2017 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 'dart:ui' show lerpDouble;
+
+import 'package:fidl_fuchsia_modular_auth/fidl_async.dart';
+import 'package:flutter/material.dart';
+
+import 'user_list.dart';
+import 'user_picker_base_shell_model.dart';
+
+const double _kRemovalTargetSize = 112.0;
+
+/// Displays a [UserList] a shutdown button, a new user button, the
+/// fuchsia logo, and a background image.
+class UserPickerScreen extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return ScopedModelDescendant<UserPickerBaseShellModel>(
+ builder: (
+ BuildContext context,
+ Widget child,
+ UserPickerBaseShellModel model,
+ ) {
+ return Material(
+ color: Colors.grey[900],
+ child: Stack(
+ fit: StackFit.passthrough,
+ children: <Widget>[
+ /// Add user picker for selecting users and adding new users
+ Align(
+ alignment: FractionalOffset.bottomRight,
+ child: RepaintBoundary(
+ child: UserList(
+ loginDisabled: false,
+ ),
+ ),
+ ),
+
+ // Add user removal target
+ Align(
+ alignment: FractionalOffset.center,
+ child: RepaintBoundary(
+ child: Container(
+ child: DragTarget<Account>(
+ onWillAccept: (Account data) => true,
+ onAccept: model.removeUser,
+ builder: (
+ _,
+ List<Account> candidateData,
+ __,
+ ) =>
+ _UserRemovalTarget(
+ show: model.showingRemoveUserTarget,
+ grow: candidateData.isNotEmpty,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+/// Displays a removal target for removing users
+class _UserRemovalTarget extends StatefulWidget {
+ /// Grows the target by some percentage.
+ final bool grow;
+
+ /// Shows the target.
+ final bool show;
+
+ /// Constructor.
+ const _UserRemovalTarget({this.show, this.grow});
+
+ @override
+ _UserRemovalTargetState createState() => _UserRemovalTargetState();
+}
+
+class _UserRemovalTargetState extends State<_UserRemovalTarget>
+ with TickerProviderStateMixin {
+ AnimationController _masterAnimationController;
+ AnimationController _initialScaleController;
+ CurvedAnimation _initialScaleCurvedAnimation;
+ AnimationController _scaleController;
+ CurvedAnimation _scaleCurvedAnimation;
+
+ @override
+ void initState() {
+ super.initState();
+ _masterAnimationController = AnimationController(
+ vsync: this,
+ duration: Duration(milliseconds: 500),
+ );
+ _initialScaleController = AnimationController(
+ vsync: this,
+ duration: Duration(milliseconds: 250),
+ );
+ _initialScaleCurvedAnimation = CurvedAnimation(
+ parent: _initialScaleController,
+ curve: Curves.fastOutSlowIn,
+ reverseCurve: Curves.fastOutSlowIn,
+ );
+ _scaleController = AnimationController(
+ vsync: this,
+ duration: Duration(milliseconds: 250),
+ );
+ _scaleCurvedAnimation = CurvedAnimation(
+ parent: _scaleController,
+ curve: Curves.fastOutSlowIn,
+ reverseCurve: Curves.fastOutSlowIn,
+ );
+ _initialScaleController.addStatusListener((AnimationStatus status) {
+ if (!widget.show && _initialScaleController.isDismissed) {
+ _masterAnimationController.stop();
+ }
+ });
+
+ if (widget.show) {
+ _masterAnimationController.repeat();
+ _initialScaleController.forward();
+ if (widget.grow) {
+ _scaleController.forward();
+ }
+ }
+ }
+
+ @override
+ void didUpdateWidget(_) {
+ super.didUpdateWidget(_);
+ if (widget.grow) {
+ _scaleController.forward();
+ } else {
+ _scaleController.reverse();
+ }
+ if (widget.show) {
+ _masterAnimationController.repeat();
+ _initialScaleController.forward();
+ } else {
+ _initialScaleController.value = 0.0;
+ }
+ }
+
+ @override
+ void dispose() {
+ _scaleController.dispose();
+ _initialScaleController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => Container(
+ child: AnimatedBuilder(
+ animation: _masterAnimationController,
+ builder: (BuildContext context, Widget child) => Transform(
+ alignment: FractionalOffset.center,
+ transform: Matrix4.identity().scaled(
+ lerpDouble(1.0, 0.7, _scaleCurvedAnimation.value) *
+ _initialScaleCurvedAnimation.value,
+ lerpDouble(1.0, 0.7, _scaleCurvedAnimation.value) *
+ _initialScaleCurvedAnimation.value,
+ ),
+ child: Container(
+ width: _kRemovalTargetSize,
+ height: _kRemovalTargetSize,
+ decoration: BoxDecoration(
+ borderRadius:
+ BorderRadius.circular(_kRemovalTargetSize / 2.0),
+ border: Border.all(color: Colors.white.withAlpha(200)),
+ color: Colors.white.withAlpha(
+ lerpDouble(0, 100.0, _scaleCurvedAnimation.value)
+ .toInt()),
+ ),
+ child: Center(
+ child: Text(
+ 'REMOVE',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 16.0,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/session_shells/ermine/login_shell/meta/userpicker_base_shell.cmx b/session_shells/ermine/login_shell/meta/userpicker_base_shell.cmx
new file mode 100644
index 0000000..344a16c
--- /dev/null
+++ b/session_shells/ermine/login_shell/meta/userpicker_base_shell.cmx
@@ -0,0 +1,24 @@
+{
+ "program": {
+ "data": "data/userpicker_base_shell"
+ },
+ "sandbox": {
+ "dev": [
+ "misc"
+ ],
+ "services": [
+ "fuchsia.cobalt.LoggerFactory",
+ "fuchsia.device.manager.Administrator",
+ "fuchsia.fonts.Provider",
+ "fuchsia.logger.LogSink",
+ "fuchsia.modular.Clipboard",
+ "fuchsia.net.Connectivity",
+ "fuchsia.netstack.Netstack",
+ "fuchsia.sys.Environment",
+ "fuchsia.sys.Launcher",
+ "fuchsia.ui.input.ImeService",
+ "fuchsia.ui.policy.Presenter",
+ "fuchsia.ui.scenic.Scenic"
+ ]
+ }
+}
diff --git a/session_shells/ermine/login_shell/pubspec.yaml b/session_shells/ermine/login_shell/pubspec.yaml
new file mode 100644
index 0000000..76bca92
--- /dev/null
+++ b/session_shells/ermine/login_shell/pubspec.yaml
@@ -0,0 +1,5 @@
+# Copyright 2017 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.
+
+name: userpicker_base_shell