mirror of
https://github.com/flutter/samples.git
synced 2025-11-10 23:08:59 +00:00
Add firebase support to web_dashboard (#421)
* add mock data, app state, model classes * Set up app without ChangeNotifier * refactor * add experiments to experimental/ * Add project-agnostic Firebase authentication code * add sign in button * add stub firebase API * add firestore * refactor code for google_sign_in * update pubspec.lock * switch to mocks for non-firebase version * Add firebase instructions to the README * fix README * sign in silently if the user is already signed in * add json_serializable * update README * ignore 'id' field on types * Implement FirebaseItemApi * Add build_runner instructions to README * remove experiments directory * add EditItemForm * move types.dart into api.dart * move mock and firebase configuration into the constructor * add main_mock entrypoint * add copyright checks to grinder script * fix fix-copyright task * run grind fix-copyright * add run and generate tasks * add run tasks to grind script * add fillWithMockData() fix delete() in mock API * add edit / new form dialogs * Add charts that display entries from Firebase * Add Entries list without editing * refactor home page * format * Add entries page functionality * Show current day in charts * cleanup: pubspec.lock, remove type annotation * Remove _selectedItem from Home page Add ItemsDropdown Use ItemsDropdown in NewEntryDialog / NewEntryForm * rename item-category * don't wait to show snackbar on delete * fix circular progress indicator * Move dialogs into dialogs.dart * run grind fix-copyright * remove unused import * Refactor entry total calculation, add chart_utils library * fix bug in chart_utils.dart * convert CategoryChart to a stateless widget * use a const for number of days in chart * code review updates - rename stream -> subscribe - timeStamp -> timestamp - remove latest() from API - use FutureBuilder and StreamBuilder instead of stateful widget - rename variables in mock_service_test.dart * use a single collection reference in firebase API * remove reference to stream in mock API * Use a new type, _EntriesEvent to improve filtering in mock API * add analysis_options.yaml and fix (most) issues * fix avoid_types_on_closure_parameters lint warnings * use spread operator in dashboard.dart * handle case where selected item in the category dropdown goes away * use StreamBuilder + FutureBuilder on Entries page * rename method * use fake firebase configuration * update pubspec.lock * update README * Change categories_dropdown to FutureBuilder + StreamBuilder * Update minSdkVersion in build.gradle SDK version 16 was failing: "The number of method references in a .dex file cannot exceed 64K." * update README * Use a collection reference in FirebaseEntryApi Already added to FirebaseCategoryApi * Invoke onSelected in CategoriesDropdown when necessary Also, avoid calling onSelected during a build. * fix misnamed var * remove unused import * Use relative imports * Use extension methods for DateTime utilities * remove forms.dart * Make Firebase instructions specific for this sample * add copyright headers * fix grammar * dartfmt * avoid setState() during build phase in CategoryDropdown * add empty test to material_theme_builder
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// 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:flutter/material.dart';
|
||||
import '../api/api.dart';
|
||||
|
||||
/// Subscribes to the latest list of categories and allows the user to select
|
||||
/// one.
|
||||
class CategoryDropdown extends StatefulWidget {
|
||||
final CategoryApi api;
|
||||
final ValueChanged<Category> onSelected;
|
||||
|
||||
CategoryDropdown({
|
||||
@required this.api,
|
||||
@required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
_CategoryDropdownState createState() => _CategoryDropdownState();
|
||||
}
|
||||
|
||||
class _CategoryDropdownState extends State<CategoryDropdown> {
|
||||
Category _selected;
|
||||
Future<List<Category>> _future;
|
||||
Stream<List<Category>> _stream;
|
||||
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// This widget needs to wait for the list of Categories, select the first
|
||||
// Category, and emit an `onSelected` event.
|
||||
//
|
||||
// This could be done inside the FutureBuilder's `builder` callback,
|
||||
// but calling setState() during the build is an error. (Calling the
|
||||
// onSelected callback will also cause the parent widget to call
|
||||
// setState()).
|
||||
//
|
||||
// Instead, we'll create a new Future that sets the selected Category and
|
||||
// calls `onSelected` if necessary. Then, we'll pass *that* future to
|
||||
// FutureBuilder. Now the selected category is set and events are emitted
|
||||
// *before* the build is triggered by the FutureBuilder.
|
||||
_future = widget.api.list().then((categories) {
|
||||
if (categories.isEmpty) {
|
||||
return categories;
|
||||
}
|
||||
|
||||
_setSelected(categories.first);
|
||||
return categories;
|
||||
});
|
||||
|
||||
// Same here, we'll create a new stream that handles any potential
|
||||
// setState() operations before we trigger our StreamBuilder.
|
||||
_stream = widget.api.subscribe().map((categories) {
|
||||
if (!categories.contains(_selected) && categories.isNotEmpty) {
|
||||
_setSelected(categories.first);
|
||||
}
|
||||
|
||||
return categories;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Category>>(
|
||||
future: _future,
|
||||
builder: (context, futureSnapshot) {
|
||||
// Show an empty dropdown while the data is loading.
|
||||
if (!futureSnapshot.hasData) {
|
||||
return DropdownButton<Category>(items: [], onChanged: null);
|
||||
}
|
||||
|
||||
return StreamBuilder<List<Category>>(
|
||||
initialData: futureSnapshot.hasData ? futureSnapshot.data : [],
|
||||
stream: _stream,
|
||||
builder: (context, snapshot) {
|
||||
var data = snapshot.hasData ? snapshot.data : <Category>[];
|
||||
return DropdownButton<Category>(
|
||||
value: _selected,
|
||||
items: data.map(_buildDropdownItem).toList(),
|
||||
onChanged: (category) {
|
||||
_setSelected(category);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _setSelected(Category category) {
|
||||
if (_selected == category) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = category;
|
||||
});
|
||||
|
||||
widget.onSelected(_selected);
|
||||
}
|
||||
|
||||
DropdownMenuItem<Category> _buildDropdownItem(Category category) {
|
||||
return DropdownMenuItem<Category>(
|
||||
child: Text(category.name), value: category);
|
||||
}
|
||||
}
|
||||
111
experimental/web_dashboard/lib/src/widgets/category_chart.dart
Normal file
111
experimental/web_dashboard/lib/src/widgets/category_chart.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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:charts_flutter/flutter.dart' as charts;
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import '../api/api.dart';
|
||||
import '../utils/chart_utils.dart' as utils;
|
||||
import 'dialogs.dart';
|
||||
|
||||
// The number of days to show in the chart
|
||||
const _daysBefore = 10;
|
||||
|
||||
class CategoryChart extends StatelessWidget {
|
||||
final Category category;
|
||||
final DashboardApi api;
|
||||
|
||||
CategoryChart({
|
||||
@required this.category,
|
||||
@required this.api,
|
||||
});
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(category.name),
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
showDialog<EditCategoryDialog>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return EditCategoryDialog(category: category);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
// Load the initial snapshot using a FutureBuilder, and subscribe to
|
||||
// additional updates with a StreamBuilder.
|
||||
child: FutureBuilder<List<Entry>>(
|
||||
future: api.entries.list(category.id),
|
||||
builder: (context, futureSnapshot) {
|
||||
if (!futureSnapshot.hasData) {
|
||||
return _buildLoadingIndicator();
|
||||
}
|
||||
return StreamBuilder<List<Entry>>(
|
||||
initialData: futureSnapshot.data,
|
||||
stream: api.entries.subscribe(category.id),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return _buildLoadingIndicator();
|
||||
}
|
||||
return _BarChart(entries: snapshot.data);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingIndicator() {
|
||||
return Center(child: CircularProgressIndicator());
|
||||
}
|
||||
}
|
||||
|
||||
class _BarChart extends StatelessWidget {
|
||||
final List<Entry> entries;
|
||||
|
||||
_BarChart({this.entries});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return charts.BarChart(
|
||||
[_seriesData()],
|
||||
animate: false,
|
||||
);
|
||||
}
|
||||
|
||||
charts.Series<utils.EntryTotal, String> _seriesData() {
|
||||
return charts.Series<utils.EntryTotal, String>(
|
||||
id: 'Entries',
|
||||
colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault,
|
||||
domainFn: (entryTotal, _) {
|
||||
if (entryTotal == null) return null;
|
||||
|
||||
var format = intl.DateFormat.Md();
|
||||
return format.format(entryTotal.day);
|
||||
},
|
||||
measureFn: (total, _) {
|
||||
if (total == null) return null;
|
||||
|
||||
return total.value;
|
||||
},
|
||||
data: utils.entryTotalsByDay(entries, _daysBefore),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
experimental/web_dashboard/lib/src/widgets/category_forms.dart
Normal file
103
experimental/web_dashboard/lib/src/widgets/category_forms.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
// 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 'package:web_dashboard/src/api/api.dart';
|
||||
import 'package:web_dashboard/src/app.dart';
|
||||
|
||||
class NewCategoryForm extends StatefulWidget {
|
||||
@override
|
||||
_NewCategoryFormState createState() => _NewCategoryFormState();
|
||||
}
|
||||
|
||||
class _NewCategoryFormState extends State<NewCategoryForm> {
|
||||
Category _category = Category('');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var api = Provider.of<AppState>(context).api;
|
||||
return EditCategoryForm(
|
||||
category: _category,
|
||||
onDone: (shouldInsert) {
|
||||
if (shouldInsert) {
|
||||
api.categories.insert(_category);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EditCategoryForm extends StatefulWidget {
|
||||
final Category category;
|
||||
final ValueChanged<bool> onDone;
|
||||
|
||||
EditCategoryForm({
|
||||
@required this.category,
|
||||
@required this.onDone,
|
||||
});
|
||||
|
||||
@override
|
||||
_EditCategoryFormState createState() => _EditCategoryFormState();
|
||||
}
|
||||
|
||||
class _EditCategoryFormState extends State<EditCategoryForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextFormField(
|
||||
initialValue: widget.category.name,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
),
|
||||
onChanged: (newValue) {
|
||||
widget.category.name = newValue;
|
||||
},
|
||||
validator: (value) {
|
||||
if (value.isEmpty) {
|
||||
return 'Please enter a name';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
|
||||
child: RaisedButton(
|
||||
child: Text('Cancel'),
|
||||
onPressed: () {
|
||||
widget.onDone(false);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
|
||||
child: RaisedButton(
|
||||
child: Text('OK'),
|
||||
onPressed: () {
|
||||
if (_formKey.currentState.validate()) {
|
||||
widget.onDone(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
98
experimental/web_dashboard/lib/src/widgets/dialogs.dart
Normal file
98
experimental/web_dashboard/lib/src/widgets/dialogs.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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 'package:web_dashboard/src/api/api.dart';
|
||||
import 'package:web_dashboard/src/widgets/category_forms.dart';
|
||||
|
||||
import '../app.dart';
|
||||
import 'edit_entry.dart';
|
||||
|
||||
class NewCategoryDialog extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SimpleDialog(
|
||||
title: Text('New Category'),
|
||||
children: <Widget>[
|
||||
NewCategoryForm(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EditCategoryDialog extends StatelessWidget {
|
||||
final Category category;
|
||||
|
||||
EditCategoryDialog({
|
||||
@required this.category,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var api = Provider.of<AppState>(context).api;
|
||||
|
||||
return SimpleDialog(
|
||||
title: Text('Edit Category'),
|
||||
children: [
|
||||
EditCategoryForm(
|
||||
category: category,
|
||||
onDone: (shouldUpdate) {
|
||||
if (shouldUpdate) {
|
||||
api.categories.update(category, category.id);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NewEntryDialog extends StatefulWidget {
|
||||
@override
|
||||
_NewEntryDialogState createState() => _NewEntryDialogState();
|
||||
}
|
||||
|
||||
class _NewEntryDialogState extends State<NewEntryDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SimpleDialog(
|
||||
title: Text('New Entry'),
|
||||
children: [
|
||||
NewEntryForm(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EditEntryDialog extends StatelessWidget {
|
||||
final Category category;
|
||||
final Entry entry;
|
||||
|
||||
EditEntryDialog({
|
||||
this.category,
|
||||
this.entry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var api = Provider.of<AppState>(context).api;
|
||||
|
||||
return SimpleDialog(
|
||||
title: Text('Edit Entry'),
|
||||
children: [
|
||||
EditEntryForm(
|
||||
entry: entry,
|
||||
onDone: (shouldUpdate) {
|
||||
if (shouldUpdate) {
|
||||
api.entries.update(category.id, entry.id, entry);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
154
experimental/web_dashboard/lib/src/widgets/edit_entry.dart
Normal file
154
experimental/web_dashboard/lib/src/widgets/edit_entry.dart
Normal 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 'package:provider/provider.dart';
|
||||
import 'package:web_dashboard/src/api/api.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import '../app.dart';
|
||||
import 'categories_dropdown.dart';
|
||||
|
||||
class NewEntryForm extends StatefulWidget {
|
||||
@override
|
||||
_NewEntryFormState createState() => _NewEntryFormState();
|
||||
}
|
||||
|
||||
class _NewEntryFormState extends State<NewEntryForm> {
|
||||
Category _selected;
|
||||
Entry _entry = Entry(0, DateTime.now());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var api = Provider.of<AppState>(context).api;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CategoryDropdown(
|
||||
api: api.categories,
|
||||
onSelected: (category) {
|
||||
setState(() {
|
||||
_selected = category;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
EditEntryForm(
|
||||
entry: _entry,
|
||||
onDone: (shouldInsert) {
|
||||
if (shouldInsert) {
|
||||
api.entries.insert(_selected.id, _entry);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EditEntryForm extends StatefulWidget {
|
||||
final Entry entry;
|
||||
final ValueChanged<bool> onDone;
|
||||
|
||||
EditEntryForm({
|
||||
@required this.entry,
|
||||
@required this.onDone,
|
||||
});
|
||||
|
||||
@override
|
||||
_EditEntryFormState createState() => _EditEntryFormState();
|
||||
}
|
||||
|
||||
class _EditEntryFormState extends State<EditEntryForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: TextFormField(
|
||||
initialValue: widget.entry.value.toString(),
|
||||
decoration: InputDecoration(labelText: 'Value'),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
try {
|
||||
int.parse(value);
|
||||
} catch (e) {
|
||||
return "Please enter a whole number";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (newValue) {
|
||||
try {
|
||||
widget.entry.value = int.parse(newValue);
|
||||
} on FormatException {
|
||||
print('Entry cannot contain "$newValue". Expected a number');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(intl.DateFormat('MM/dd/yyyy').format(widget.entry.time)),
|
||||
RaisedButton(
|
||||
child: Text('Edit'),
|
||||
onPressed: () async {
|
||||
var result = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: widget.entry.time,
|
||||
firstDate: DateTime.now().subtract(Duration(days: 365)),
|
||||
lastDate: DateTime.now());
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
widget.entry.time = result;
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
|
||||
child: RaisedButton(
|
||||
child: Text('Cancel'),
|
||||
onPressed: () {
|
||||
widget.onDone(false);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
|
||||
child: RaisedButton(
|
||||
child: Text('OK'),
|
||||
onPressed: () {
|
||||
if (_formKey.currentState.validate()) {
|
||||
widget.onDone(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user