1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 14:58:34 +00:00

add web_dashboard API (#333)

* add web_dashboard sample

* add docs

* address code review comments

* restructure web_dashboard

* set up provider and mock service

* add copyright headers, use relative imports

* add API class, add tests

* fmt

* rename services -> API, remove data library

* use new API in app

* add stream to items api

* convert from StreamBuilder to StreamProvider

* add subscription to Entry API

* rename API classes

* address comments

* Update README.md

* update README

* remove routing_demo
This commit is contained in:
John Ryan
2020-03-02 16:04:10 -08:00
committed by GitHub
parent 738b0d9958
commit edf219354e
73 changed files with 2681 additions and 13 deletions

View File

@@ -0,0 +1,9 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. 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 'src/app.dart';
void main() => runApp(DashboardApp());

View File

@@ -0,0 +1,45 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Manipulates app data,
abstract class DashboardApi {
ItemApi get items;
EntryApi get entries;
}
/// Manipulates [Item] data.
abstract class ItemApi {
Future<Item> delete(String id);
Future<Item> get(String id);
Future<Item> insert(Item item);
Future<List<Item>> list();
Future<Item> update(Item item, String id);
Stream<List<Item>> allItemsStream();
}
/// Something being tracked.
class Item {
final String name;
String id;
Item(this.name);
}
/// Manipulates [Entry] data.
abstract class EntryApi {
Future<Entry> delete(String itemId, String id);
Future<Entry> insert(String itemId, Entry entry);
Future<List<Entry>> list(String itemId);
Future<Entry> update(String itemId, String id, Entry entry);
Stream<List<Entry>> allEntriesStream(String itemId);
}
/// A number tracked at a point in time.
class Entry {
final int value;
final DateTime time;
String id;
Entry(this.value, this.time);
}

View File

@@ -0,0 +1,3 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

View File

@@ -0,0 +1,106 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. 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:uuid/uuid.dart' as uuid;
import 'api.dart';
class MockDashboardApi implements DashboardApi {
@override
final EntryApi entries = MockEntryApi();
@override
final ItemApi items = MockItemApi();
}
class MockItemApi implements ItemApi {
Map<String, Item> _storage = {};
StreamController<List<Item>> _streamController =
StreamController<List<Item>>.broadcast();
@override
Future<Item> delete(String id) async {
_emit();
return _storage.remove(id);
}
@override
Future<Item> get(String id) async {
return _storage[id];
}
@override
Future<Item> insert(Item item) async {
var id = uuid.Uuid().v4();
var newItem = Item(item.name)..id = id;
_storage[id] = newItem;
_emit();
return newItem;
}
@override
Future<List<Item>> list() async {
return _storage.values.toList();
}
@override
Future<Item> update(Item item, String id) async {
_storage[id] = item;
return item..id = id;
}
Stream<List<Item>> allItemsStream() {
return _streamController.stream;
}
void _emit() {
_streamController.add(_storage.values.toList());
}
}
class MockEntryApi implements EntryApi {
Map<String, Entry> _storage = {};
StreamController<List<Entry>> _streamController =
StreamController<List<Entry>>.broadcast();
@override
Future<Entry> delete(String itemId, String id) async {
_emit();
return _storage.remove('$itemId-$id');
}
@override
Future<Entry> insert(String itemId, Entry entry) async {
var id = uuid.Uuid().v4();
var newEntry = Entry(entry.value, entry.time)..id = id;
_storage['$itemId-$id'] = newEntry;
_emit();
return newEntry;
}
@override
Future<List<Entry>> list(String itemId) async {
return _storage.keys
.where((k) => k.startsWith(itemId))
.map((k) => _storage[k])
.toList();
}
@override
Future<Entry> update(String itemId, String id, Entry entry) async {
_storage['$itemId-$id'] = entry;
return entry..id = id;
}
@override
Stream<List<Entry>> allEntriesStream(String itemId) {
return _streamController.stream;
}
void _emit() {
_streamController.add(_storage.values.toList());
}
}

View File

@@ -0,0 +1,25 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. 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:provider/provider.dart';
import 'pages/home.dart';
import 'api/api.dart';
import 'api/mock.dart';
/// An app that shows a responsive dashboard.
class DashboardApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<DashboardApi>(create: (_) => MockDashboardApi()),
],
child: MaterialApp(
home: HomePage(),
),
);
}
}

View File

@@ -0,0 +1,49 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. 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:provider/provider.dart';
import '../api/api.dart';
import 'item_details.dart';
class HomePage extends StatelessWidget {
Widget build(BuildContext context) {
var api = Provider.of<DashboardApi>(context);
return Scaffold(
body: StreamProvider<List<Item>>(
initialData: [],
create: (context) => api.items.allItemsStream(),
child: Consumer<List<Item>>(
builder: (context, items, child) {
return ListView.builder(
itemBuilder: (context, idx) {
return ListTile(
title: Text(items[idx].name),
onTap: () {
_showDetails(items[idx], context);
},
);
},
itemCount: items.length,
);
},
),
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
api.items.insert(Item('Coffees Drank'));
},
),
);
}
void _showDetails(Item item, BuildContext context) {
Navigator.of(context).push(MaterialPageRoute(builder: (context) {
return ItemDetailsPage(item);
}));
}
}

View File

@@ -0,0 +1,22 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. 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:web_dashboard/src/api/api.dart';
class ItemDetailsPage extends StatelessWidget {
final Item item;
ItemDetailsPage(this.item);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text('${item.name}'),
),
);
}
}

View File

@@ -0,0 +1,154 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. 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 'navigation_rail.dart';
bool _isLargeScreen(BuildContext context) {
return MediaQuery.of(context).size.width > 960.0;
}
bool _isMediumScreen(BuildContext context) {
return MediaQuery.of(context).size.width > 640.0;
}
/// See bottomNavigationBarItem or NavigationRailDestination
class AdaptiveScaffoldDestination {
final String title;
final IconData icon;
const AdaptiveScaffoldDestination({
@required this.title,
@required this.icon,
});
}
/// A widget that adapts to the current display size, displaying a [Drawer],
/// [NavigationRail], or [BottomNavigationBar]. Navigation destinations are
/// defined in the [destinations] parameter.
class AdaptiveScaffold extends StatefulWidget {
final Widget title;
final Widget body;
final int currentIndex;
final List<AdaptiveScaffoldDestination> destinations;
final ValueChanged<int> onNavigationIndexChange;
final FloatingActionButton floatingActionButton;
AdaptiveScaffold({
this.title,
this.body,
@required this.currentIndex,
@required this.destinations,
this.onNavigationIndexChange,
this.floatingActionButton,
});
@override
_AdaptiveScaffoldState createState() => _AdaptiveScaffoldState();
}
class _AdaptiveScaffoldState extends State<AdaptiveScaffold> {
@override
Widget build(BuildContext context) {
// Show a Drawer
if (_isLargeScreen(context)) {
return Row(
children: [
Drawer(
child: Column(
children: [
DrawerHeader(
child: Center(
child: widget.title,
),
),
for (var d in widget.destinations)
ListTile(
leading: Icon(d.icon),
title: Text(d.title),
selected:
widget.destinations.indexOf(d) == widget.currentIndex,
onTap: () => _destinationTapped(d),
),
],
),
),
VerticalDivider(
width: 1,
thickness: 1,
color: Colors.grey[300],
),
Expanded(
child: Scaffold(
appBar: AppBar(),
body: widget.body,
floatingActionButton: widget.floatingActionButton,
),
),
],
);
}
// Show a navigation rail
if (_isMediumScreen(context)) {
return Scaffold(
appBar: AppBar(
title: widget.title,
),
body: Row(
children: [
NavigationRail(
leading: widget.floatingActionButton,
destinations: [
...widget.destinations.map(
(d) => NavigationRailDestination(
icon: Icon(d.icon),
title: Text(d.title),
),
),
],
currentIndex: widget.currentIndex,
onDestinationSelected: widget.onNavigationIndexChange,
),
VerticalDivider(
width: 1,
thickness: 1,
color: Colors.grey[300],
),
Expanded(
child: widget.body,
),
],
),
);
}
// Show a bottom app bar
return Scaffold(
body: widget.body,
appBar: AppBar(title: widget.title),
bottomNavigationBar: BottomNavigationBar(
items: [
...widget.destinations.map(
(d) => BottomNavigationBarItem(
icon: Icon(d.icon),
title: Text(d.title),
),
),
],
currentIndex: widget.currentIndex,
onTap: widget.onNavigationIndexChange,
),
floatingActionButton: widget.floatingActionButton,
);
}
void _destinationTapped(AdaptiveScaffoldDestination destination) {
var idx = widget.destinations.indexOf(destination);
if (idx != widget.currentIndex) {
widget.onNavigationIndexChange(idx);
}
}
}

View File

@@ -0,0 +1,402 @@
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Original pull request: https://github.com/flutter/flutter/pull/49574
import 'package:flutter/material.dart';
/// Defines the behavior of the labels of a [NavigationRail].
///
/// See also:
///
/// * [NavigationRail]
enum NavigationRailLabelType {
/// Only the icons of a navigation rail item are shown.
none,
/// Only the selected navigation rail item will show its label.
///
/// The label will animate in and out as new items are selected.
selected,
/// All navigation rail items will show their label.
all,
}
/// Defines the alignment for the group of [NavigationRailDestination]s within
/// a [NavigationRail].
///
/// Navigation rail destinations can be aligned as a group to the [top],
/// [bottom], or [center] of a layout.
enum NavigationRailGroupAlignment {
/// Place the [NavigationRailDestination]s at the top of the rail.
top,
/// Place the [NavigationRailDestination]s in the center of the rail.
center,
/// Place the [NavigationRailDestination]s at the bottom of the rail.
bottom,
}
/// A description for an interactive button within a [NavigationRail].
///
/// See also:
///
/// * [NavigationRail]
class NavigationRailDestination {
/// Creates an destination that is used with [NavigationRail.destinations].
///
/// [icon] should not be null and [title] should not be null when this
/// destination is used in the [NavigationRail].
const NavigationRailDestination({
@required this.icon,
Widget activeIcon,
this.title,
}) : activeIcon = activeIcon ?? icon,
assert(icon != null);
/// The icon of the destination.
///
/// Typically the icon is an [Icon] or an [ImageIcon] widget. If another type
/// of widget is provided then it should configure itself to match the current
/// [IconTheme] size and color.
///
/// If [activeIcon] is provided, this will only be displayed when the
/// destination is not selected.
///
/// To make the [NavigationRail] more accessible, consider choosing an
/// icon with a stroked and filled version, such as [Icons.cloud] and
/// [Icons.cloud_queue]. [icon] should be set to the stroked version and
/// [activeIcon] to the filled version.
final Widget icon;
/// An alternative icon displayed when this destination is selected.
///
/// If this icon is not provided, the [NavigationRail] will display [icon] in
/// either state.
///
/// See also:
///
/// * [NavigationRailDestination.icon], for a description of how to pair
/// icons.
final Widget activeIcon;
/// The title of the item. If the title is not provided only the icon will be
/// shown when not used in a [NavigationRail].
final Widget title;
}
/// TODO
class NavigationRail extends StatefulWidget {
/// TODO
NavigationRail({
this.leading,
this.destinations,
this.currentIndex,
this.onDestinationSelected,
this.groupAlignment = NavigationRailGroupAlignment.top,
this.labelType = NavigationRailLabelType.none,
this.labelTextStyle,
this.selectedLabelTextStyle,
this.iconTheme,
this.selectedIconTheme,
});
/// The leading widget in the rail that is placed above the items.
///
/// This is commonly a [FloatingActionButton], but may also be a non-button,
/// such as a logo.
final Widget leading;
/// Defines the appearance of the button items that are arrayed within the
/// navigation rail.
final List<NavigationRailDestination> destinations;
/// The index into [destinations] for the current active [NavigationRailDestination].
final int currentIndex;
/// Called when one of the [destinations] is selected.
///
/// The stateful widget that creates the navigation rail needs to keep
/// track of the index of the selected [NavigationRailDestination] and call
/// `setState` to rebuild the navigation rail with the new [currentIndex].
final ValueChanged<int> onDestinationSelected;
/// The alignment for the [NavigationRailDestination]s as they are positioned
/// within the [NavigationRail].
///
/// Navigation rail destinations can be aligned as a group to the [top],
/// [bottom], or [center] of a layout.
final NavigationRailGroupAlignment groupAlignment;
/// Defines the layout and behavior of the labels in the [NavigationRail].
///
/// See also:
///
/// * [NavigationRailLabelType] for information on the meaning of different
/// types.
final NavigationRailLabelType labelType;
/// The [TextStyle] of the [NavigationRailDestination] labels.
///
/// This is the default [TextStyle] for all labels. When the
/// [NavigationRailDestination] is selected, the [selectedLabelTextStyle] will be
/// used instead.
final TextStyle labelTextStyle;
/// The [TextStyle] of the [NavigationRailDestination] labels when they are
/// selected.
///
/// This field overrides the [labelTextStyle] for selected items.
///
/// When the [NavigationRailDestination] is not selected, [labelTextStyle] will be
/// used.
final TextStyle selectedLabelTextStyle;
/// The default size, opacity, and color of the icon in the
/// [NavigationRailDestination].
///
/// If this field is not provided, or provided with any null properties, then
///a copy of the [IconThemeData.fallback] with a custom [NavigationRail]
/// specific color will be used.
final IconTheme iconTheme;
/// The size, opacity, and color of the icon in the selected
/// [NavigationRailDestination].
///
/// This field overrides the [iconTheme] for selected items.
///
/// When the [NavigationRailDestination] is not selected, [iconTheme] will be
/// used.
final IconTheme selectedIconTheme;
@override
_NavigationRailState createState() => _NavigationRailState();
}
class _NavigationRailState extends State<NavigationRail>
with TickerProviderStateMixin {
List<AnimationController> _controllers = <AnimationController>[];
List<Animation<double>> _animations;
@override
void initState() {
super.initState();
_initControllers();
}
@override
void dispose() {
_disposeControllers();
super.dispose();
}
@override
void didUpdateWidget(NavigationRail oldWidget) {
super.didUpdateWidget(oldWidget);
// No animated segue if the length of the items list changes.
if (widget.destinations.length != oldWidget.destinations.length) {
_resetState();
return;
}
if (widget.currentIndex != oldWidget.currentIndex) {
_controllers[oldWidget.currentIndex].reverse();
_controllers[widget.currentIndex].forward();
}
}
@override
Widget build(BuildContext context) {
final Widget leading = widget.leading;
return DefaultTextStyle(
style: TextStyle(color: Theme.of(context).colorScheme.primary),
child: Container(
width: _railWidth,
color: Theme.of(context).colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_verticalSpacing,
if (leading != null) ...<Widget>[
SizedBox(
height: _railItemHeight,
width: _railItemWidth,
child: Align(
alignment: Alignment.center,
child: leading,
),
),
_verticalSpacing,
],
for (int i = 0; i < widget.destinations.length; i++)
_RailItem(
animation: _animations[i],
labelKind: widget.labelType,
selected: widget.currentIndex == i,
icon: widget.currentIndex == i
? widget.destinations[i].activeIcon
: widget.destinations[i].icon,
title: DefaultTextStyle(
style: TextStyle(
color: widget.currentIndex == i
? Theme.of(context).colorScheme.primary
: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.64)),
child: widget.destinations[i].title,
),
onTap: () {
widget.onDestinationSelected(i);
},
),
],
),
),
);
}
void _disposeControllers() {
for (final AnimationController controller in _controllers)
controller.dispose();
}
void _initControllers() {
_controllers = List<AnimationController>.generate(
widget.destinations.length, (int index) {
return AnimationController(
duration: kThemeAnimationDuration,
vsync: this,
)..addListener(_rebuild);
});
_animations = _controllers
.map((AnimationController controller) => controller.view)
.toList();
_controllers[widget.currentIndex].value = 1.0;
}
void _resetState() {
_disposeControllers();
_initControllers();
}
void _rebuild() {
setState(() {
// Rebuilding when any of the controllers tick, i.e. when the items are
// animated.
});
}
}
class _RailItem extends StatelessWidget {
_RailItem({
this.animation,
this.labelKind,
this.selected,
this.icon,
this.title,
this.onTap,
}) : assert(labelKind != null),
_positionAnimation = CurvedAnimation(
parent: ReverseAnimation(animation),
curve: Curves.easeInOut,
reverseCurve: Curves.easeInOut.flipped,
);
final Animation<double> _positionAnimation;
final Animation<double> animation;
final NavigationRailLabelType labelKind;
final bool selected;
final Widget icon;
final Widget title;
final VoidCallback onTap;
double _fadeInValue() {
if (animation.value < 0.25) {
return 0;
} else if (animation.value < 0.75) {
return (animation.value - 0.25) * 2;
} else {
return 1;
}
}
double _fadeOutValue() {
if (animation.value > 0.75) {
return (animation.value - 0.75) * 4;
} else {
return 0;
}
}
@override
Widget build(BuildContext context) {
Widget content;
switch (labelKind) {
case NavigationRailLabelType.none:
content = SizedBox(width: _railItemWidth, child: icon);
break;
case NavigationRailLabelType.selected:
content = SizedBox(
width: 72,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(height: _positionAnimation.value * 18),
icon,
Opacity(
alwaysIncludeSemantics: true,
opacity: selected ? _fadeInValue() : _fadeOutValue(),
child: title,
),
],
),
);
break;
case NavigationRailLabelType.all:
content = SizedBox(
width: 72,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
icon,
title,
],
),
);
break;
}
final ColorScheme colors = Theme.of(context).colorScheme;
return IconTheme(
data: IconThemeData(
color: selected ? colors.primary : colors.onSurface.withOpacity(0.64),
),
child: SizedBox(
height: 72,
child: Material(
type: MaterialType.transparency,
clipBehavior: Clip.none,
child: InkResponse(
onTap: onTap,
onHover: (_) {},
splashColor:
Theme.of(context).colorScheme.primary.withOpacity(0.12),
hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.04),
child: content,
),
),
),
);
}
}
const double _railWidth = 72;
const double _railItemWidth = _railWidth;
const double _railItemHeight = _railItemWidth;
const double _spacing = 8;
const Widget _verticalSpacing = SizedBox(height: _spacing);