
The main purpose of this PR is to make it so that when you set the initial route and it's a hierarchical route (e.g. `/a/b/c`), it implies multiple pushes, one for each step of the route (so in that case, `/`, `/a`, `/a/b`, and `/a/b/c`, in that order). If any of those routes don't exist, it falls back to '/'. As part of doing that, I: * Changed the default for MaterialApp.initialRoute to honor the actual initial route. * Added a MaterialApp.onUnknownRoute for handling bad routes. * Added a feature to flutter_driver that allows the host test script and the device test app to communicate. * Added a test to make sure `flutter drive --route` works. (Hopefully that will also prove `flutter run --route` works, though this isn't testing the `flutter` tool's side of that. My main concern is over whether the engine side works.) * Fixed `flutter drive` to output the right target file name. * Changed how the stocks app represents its data, so that we can show a page for a stock before we know if it exists. * Made it possible to show a stock page that doesn't exist. It shows a progress indicator if we're loading the data, or else shows a message saying it doesn't exist. * Changed the pathing structure of routes in stocks to work more sanely. * Made search in the stocks app actually work (before it only worked if we happened to accidentally trigger a rebuild). Added a test. * Replaced some custom code in the stocks app with a BackButton. * Added a "color" feature to BackButton to support the stocks use case. * Spaced out the ErrorWidget text a bit more. * Added `RouteSettings.copyWith`, which I ended up not using. * Improved the error messages around routing. While I was in some files I made a few formatting fixes, fixed some code health issues, and also removed `flaky: true` from some devicelab tests that have been stable for a while. Also added some documentation here and there.
353 lines
10 KiB
Dart
353 lines
10 KiB
Dart
// Copyright 2015 The Chromium 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:flutter/rendering.dart' show debugDumpRenderTree, debugDumpLayerTree, debugDumpSemanticsTree;
|
|
import 'package:flutter/scheduler.dart' show timeDilation;
|
|
import 'stock_data.dart';
|
|
import 'stock_list.dart';
|
|
import 'stock_strings.dart';
|
|
import 'stock_symbol_viewer.dart';
|
|
import 'stock_types.dart';
|
|
|
|
typedef void ModeUpdater(StockMode mode);
|
|
|
|
enum _StockMenuItem { autorefresh, refresh, speedUp, speedDown }
|
|
enum StockHomeTab { market, portfolio }
|
|
|
|
class _NotImplementedDialog extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return new AlertDialog(
|
|
title: const Text('Not Implemented'),
|
|
content: const Text('This feature has not yet been implemented.'),
|
|
actions: <Widget>[
|
|
new FlatButton(
|
|
onPressed: debugDumpApp,
|
|
child: new Row(
|
|
children: <Widget>[
|
|
const Icon(
|
|
Icons.dvr,
|
|
size: 18.0,
|
|
),
|
|
new Container(
|
|
width: 8.0,
|
|
),
|
|
const Text('DUMP APP TO CONSOLE'),
|
|
],
|
|
),
|
|
),
|
|
new FlatButton(
|
|
onPressed: () {
|
|
Navigator.pop(context, false);
|
|
},
|
|
child: const Text('OH WELL'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class StockHome extends StatefulWidget {
|
|
const StockHome(this.stocks, this.configuration, this.updater);
|
|
|
|
final StockData stocks;
|
|
final StockConfiguration configuration;
|
|
final ValueChanged<StockConfiguration> updater;
|
|
|
|
@override
|
|
StockHomeState createState() => new StockHomeState();
|
|
}
|
|
|
|
class StockHomeState extends State<StockHome> {
|
|
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
|
|
final TextEditingController _searchQuery = new TextEditingController();
|
|
bool _isSearching = false;
|
|
bool _autorefresh = false;
|
|
|
|
void _handleSearchBegin() {
|
|
ModalRoute.of(context).addLocalHistoryEntry(new LocalHistoryEntry(
|
|
onRemove: () {
|
|
setState(() {
|
|
_isSearching = false;
|
|
_searchQuery.clear();
|
|
});
|
|
},
|
|
));
|
|
setState(() {
|
|
_isSearching = true;
|
|
});
|
|
}
|
|
|
|
void _handleStockModeChange(StockMode value) {
|
|
if (widget.updater != null)
|
|
widget.updater(widget.configuration.copyWith(stockMode: value));
|
|
}
|
|
|
|
void _handleStockMenu(BuildContext context, _StockMenuItem value) {
|
|
switch(value) {
|
|
case _StockMenuItem.autorefresh:
|
|
setState(() {
|
|
_autorefresh = !_autorefresh;
|
|
});
|
|
break;
|
|
case _StockMenuItem.refresh:
|
|
showDialog<Null>(
|
|
context: context,
|
|
child: new _NotImplementedDialog()
|
|
);
|
|
break;
|
|
case _StockMenuItem.speedUp:
|
|
timeDilation /= 5.0;
|
|
break;
|
|
case _StockMenuItem.speedDown:
|
|
timeDilation *= 5.0;
|
|
break;
|
|
}
|
|
}
|
|
|
|
Widget _buildDrawer(BuildContext context) {
|
|
return new Drawer(
|
|
child: new ListView(
|
|
children: <Widget>[
|
|
const DrawerHeader(child: const Center(child: const Text('Stocks'))),
|
|
const ListTile(
|
|
leading: const Icon(Icons.assessment),
|
|
title: const Text('Stock List'),
|
|
selected: true,
|
|
),
|
|
const ListTile(
|
|
leading: const Icon(Icons.account_balance),
|
|
title: const Text('Account Balance'),
|
|
enabled: false,
|
|
),
|
|
new ListTile(
|
|
leading: const Icon(Icons.dvr),
|
|
title: const Text('Dump App to Console'),
|
|
onTap: () {
|
|
try {
|
|
debugDumpApp();
|
|
debugDumpRenderTree();
|
|
debugDumpLayerTree();
|
|
debugDumpSemanticsTree();
|
|
} catch (e, stack) {
|
|
debugPrint('Exception while dumping app:\n$e\n$stack');
|
|
}
|
|
},
|
|
),
|
|
const Divider(),
|
|
new ListTile(
|
|
leading: const Icon(Icons.thumb_up),
|
|
title: const Text('Optimistic'),
|
|
trailing: new Radio<StockMode>(
|
|
value: StockMode.optimistic,
|
|
groupValue: widget.configuration.stockMode,
|
|
onChanged: _handleStockModeChange,
|
|
),
|
|
onTap: () {
|
|
_handleStockModeChange(StockMode.optimistic);
|
|
},
|
|
),
|
|
new ListTile(
|
|
leading: const Icon(Icons.thumb_down),
|
|
title: const Text('Pessimistic'),
|
|
trailing: new Radio<StockMode>(
|
|
value: StockMode.pessimistic,
|
|
groupValue: widget.configuration.stockMode,
|
|
onChanged: _handleStockModeChange,
|
|
),
|
|
onTap: () {
|
|
_handleStockModeChange(StockMode.pessimistic);
|
|
},
|
|
),
|
|
const Divider(),
|
|
new ListTile(
|
|
leading: const Icon(Icons.settings),
|
|
title: const Text('Settings'),
|
|
onTap: _handleShowSettings,
|
|
),
|
|
new ListTile(
|
|
leading: const Icon(Icons.help),
|
|
title: const Text('About'),
|
|
onTap: _handleShowAbout,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _handleShowSettings() {
|
|
Navigator.popAndPushNamed(context, '/settings');
|
|
}
|
|
|
|
void _handleShowAbout() {
|
|
showAboutDialog(context: context);
|
|
}
|
|
|
|
Widget buildAppBar() {
|
|
return new AppBar(
|
|
elevation: 0.0,
|
|
title: new Text(StockStrings.of(context).title()),
|
|
actions: <Widget>[
|
|
new IconButton(
|
|
icon: const Icon(Icons.search),
|
|
onPressed: _handleSearchBegin,
|
|
tooltip: 'Search',
|
|
),
|
|
new PopupMenuButton<_StockMenuItem>(
|
|
onSelected: (_StockMenuItem value) { _handleStockMenu(context, value); },
|
|
itemBuilder: (BuildContext context) => <PopupMenuItem<_StockMenuItem>>[
|
|
new CheckedPopupMenuItem<_StockMenuItem>(
|
|
value: _StockMenuItem.autorefresh,
|
|
checked: _autorefresh,
|
|
child: const Text('Autorefresh'),
|
|
),
|
|
const PopupMenuItem<_StockMenuItem>(
|
|
value: _StockMenuItem.refresh,
|
|
child: const Text('Refresh'),
|
|
),
|
|
const PopupMenuItem<_StockMenuItem>(
|
|
value: _StockMenuItem.speedUp,
|
|
child: const Text('Increase animation speed'),
|
|
),
|
|
const PopupMenuItem<_StockMenuItem>(
|
|
value: _StockMenuItem.speedDown,
|
|
child: const Text('Decrease animation speed'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
bottom: new TabBar(
|
|
tabs: <Widget>[
|
|
new Tab(text: StockStrings.of(context).market()),
|
|
new Tab(text: StockStrings.of(context).portfolio()),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
static Iterable<Stock> _getStockList(StockData stocks, Iterable<String> symbols) {
|
|
return symbols.map<Stock>((String symbol) => stocks[symbol])
|
|
.where((Stock stock) => stock != null);
|
|
}
|
|
|
|
Iterable<Stock> _filterBySearchQuery(Iterable<Stock> stocks) {
|
|
if (_searchQuery.text.isEmpty)
|
|
return stocks;
|
|
final RegExp regexp = new RegExp(_searchQuery.text, caseSensitive: false);
|
|
return stocks.where((Stock stock) => stock.symbol.contains(regexp));
|
|
}
|
|
|
|
void _buyStock(Stock stock) {
|
|
setState(() {
|
|
stock.percentChange = 100.0 * (1.0 / stock.lastSale);
|
|
stock.lastSale += 1.0;
|
|
});
|
|
_scaffoldKey.currentState.showSnackBar(new SnackBar(
|
|
content: new Text("Purchased ${stock.symbol} for ${stock.lastSale}"),
|
|
action: new SnackBarAction(
|
|
label: "BUY MORE",
|
|
onPressed: () {
|
|
_buyStock(stock);
|
|
},
|
|
),
|
|
));
|
|
}
|
|
|
|
Widget _buildStockList(BuildContext context, Iterable<Stock> stocks, StockHomeTab tab) {
|
|
return new StockList(
|
|
stocks: stocks.toList(),
|
|
onAction: _buyStock,
|
|
onOpen: (Stock stock) {
|
|
Navigator.pushNamed(context, '/stock:${stock.symbol}');
|
|
},
|
|
onShow: (Stock stock) {
|
|
_scaffoldKey.currentState.showBottomSheet<Null>((BuildContext context) => new StockSymbolBottomSheet(stock: stock));
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildStockTab(BuildContext context, StockHomeTab tab, List<String> stockSymbols) {
|
|
return new AnimatedBuilder(
|
|
key: new ValueKey<StockHomeTab>(tab),
|
|
animation: new Listenable.merge(<Listenable>[_searchQuery, widget.stocks]),
|
|
builder: (BuildContext context, Widget child) {
|
|
return _buildStockList(context, _filterBySearchQuery(_getStockList(widget.stocks, stockSymbols)).toList(), tab);
|
|
},
|
|
);
|
|
}
|
|
|
|
static const List<String> portfolioSymbols = const <String>["AAPL","FIZZ", "FIVE", "FLAT", "ZINC", "ZNGA"];
|
|
|
|
Widget buildSearchBar() {
|
|
return new AppBar(
|
|
leading: new BackButton(
|
|
color: Theme.of(context).accentColor,
|
|
),
|
|
title: new TextField(
|
|
controller: _searchQuery,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Search stocks',
|
|
),
|
|
),
|
|
backgroundColor: Theme.of(context).canvasColor,
|
|
);
|
|
}
|
|
|
|
void _handleCreateCompany() {
|
|
showModalBottomSheet<Null>(
|
|
context: context,
|
|
builder: (BuildContext context) => new _CreateCompanySheet(),
|
|
);
|
|
}
|
|
|
|
Widget buildFloatingActionButton() {
|
|
return new FloatingActionButton(
|
|
tooltip: 'Create company',
|
|
child: const Icon(Icons.add),
|
|
backgroundColor: Colors.redAccent,
|
|
onPressed: _handleCreateCompany,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return new DefaultTabController(
|
|
length: 2,
|
|
child: new Scaffold(
|
|
key: _scaffoldKey,
|
|
appBar: _isSearching ? buildSearchBar() : buildAppBar(),
|
|
floatingActionButton: buildFloatingActionButton(),
|
|
drawer: _buildDrawer(context),
|
|
body: new TabBarView(
|
|
children: <Widget>[
|
|
_buildStockTab(context, StockHomeTab.market, widget.stocks.allSymbols),
|
|
_buildStockTab(context, StockHomeTab.portfolio, portfolioSymbols),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CreateCompanySheet extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return new Column(
|
|
children: <Widget>[
|
|
const TextField(
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Company Name',
|
|
),
|
|
),
|
|
const Text('(This demo is not yet complete.)'),
|
|
// For example, we could add a button that actually updates the list
|
|
// and then contacts the server, etc.
|
|
],
|
|
);
|
|
}
|
|
}
|