1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 14:58:34 +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:
John Ryan
2020-05-26 13:14:21 -07:00
committed by GitHub
parent b518c322cc
commit 395ae8c0bb
39 changed files with 2730 additions and 220 deletions

View File

@@ -6,4 +6,6 @@ import 'package:flutter/material.dart';
import 'src/app.dart';
void main() => runApp(DashboardApp());
void main() {
runApp(DashboardApp());
}

View File

@@ -0,0 +1,11 @@
// 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.mock());
}

View File

@@ -2,44 +2,106 @@
// 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:cloud_firestore/cloud_firestore.dart';
import 'package:json_annotation/json_annotation.dart';
part 'api.g.dart';
/// Manipulates app data,
abstract class DashboardApi {
ItemApi get items;
CategoryApi get categories;
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();
}
/// Manipulates [Category] data.
abstract class CategoryApi {
Future<Category> delete(String id);
/// Something being tracked.
class Item {
final String name;
String id;
Future<Category> get(String id);
Item(this.name);
Future<Category> insert(Category category);
Future<List<Category>> list();
Future<Category> update(Category category, String id);
Stream<List<Category>> subscribe();
}
/// 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);
Future<Entry> delete(String categoryId, String id);
Future<Entry> get(String categoryId, String id);
Future<Entry> insert(String categoryId, Entry entry);
Future<List<Entry>> list(String categoryId);
Future<Entry> update(String categoryId, String id, Entry entry);
Stream<List<Entry>> subscribe(String categoryId);
}
/// Something that's being tracked, e.g. Hours Slept, Cups of water, etc.
@JsonSerializable()
class Category {
String name;
@JsonKey(ignore: true)
String id;
Category(this.name);
factory Category.fromJson(Map<String, dynamic> json) =>
_$CategoryFromJson(json);
Map<String, dynamic> toJson() => _$CategoryToJson(this);
@override
operator ==(Object other) => other is Category && other.id == id;
@override
int get hashCode => id.hashCode;
@override
String toString() {
return '<Category id=$id>';
}
}
/// A number tracked at a point in time.
@JsonSerializable()
class Entry {
final int value;
final DateTime time;
int value;
@JsonKey(fromJson: _timestampToDateTime, toJson: _dateTimeToTimestamp)
DateTime time;
@JsonKey(ignore: true)
String id;
Entry(this.value, this.time);
factory Entry.fromJson(Map<String, dynamic> json) => _$EntryFromJson(json);
Map<String, dynamic> toJson() => _$EntryToJson(this);
static DateTime _timestampToDateTime(Timestamp timestamp) {
return DateTime.fromMillisecondsSinceEpoch(
timestamp.millisecondsSinceEpoch);
}
static Timestamp _dateTimeToTimestamp(DateTime dateTime) {
return Timestamp.fromMillisecondsSinceEpoch(
dateTime.millisecondsSinceEpoch);
}
@override
operator ==(Object other) => other is Entry && other.id == id;
@override
int get hashCode => id.hashCode;
@override
String toString() {
return '<Entry id=$id>';
}
}

View File

@@ -0,0 +1,33 @@
// 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.
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Category _$CategoryFromJson(Map<String, dynamic> json) {
return Category(
json['name'] as String,
);
}
Map<String, dynamic> _$CategoryToJson(Category instance) => <String, dynamic>{
'name': instance.name,
};
Entry _$EntryFromJson(Map<String, dynamic> json) {
return Entry(
json['value'] as int,
Entry._timestampToDateTime(json['time'] as Timestamp),
);
}
Map<String, dynamic> _$EntryToJson(Entry instance) => <String, dynamic>{
'value': instance.value,
'time': Entry._dateTimeToTimestamp(instance.time),
};

View File

@@ -1,3 +1,151 @@
// 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:cloud_firestore/cloud_firestore.dart';
import 'api.dart';
class FirebaseDashboardApi implements DashboardApi {
@override
final EntryApi entries;
@override
final CategoryApi categories;
FirebaseDashboardApi(Firestore firestore, String userId)
: entries = FirebaseEntryApi(firestore, userId),
categories = FirebaseCategoryApi(firestore, userId);
}
class FirebaseEntryApi implements EntryApi {
final Firestore firestore;
final String userId;
final CollectionReference _categoriesRef;
FirebaseEntryApi(this.firestore, this.userId)
: _categoriesRef = firestore.collection('users/$userId/categories');
@override
Stream<List<Entry>> subscribe(String categoryId) {
var snapshots = _categoriesRef
.document('$categoryId')
.collection('entries')
.snapshots();
var result = snapshots.map((querySnapshot) {
return querySnapshot.documents.map((snapshot) {
return Entry.fromJson(snapshot.data)..id = snapshot.documentID;
}).toList();
});
return result;
}
@override
Future<Entry> delete(String categoryId, String id) async {
var document = _categoriesRef.document('$categoryId/entries/$id');
var entry = await get(categoryId, document.documentID);
await document.delete();
return entry;
}
@override
Future<Entry> insert(String categoryId, Entry entry) async {
var document = await _categoriesRef
.document('$categoryId')
.collection('entries')
.add(entry.toJson());
return await get(categoryId, document.documentID);
}
@override
Future<List<Entry>> list(String categoryId) async {
var entriesRef =
_categoriesRef.document('$categoryId').collection('entries');
var querySnapshot = await entriesRef.getDocuments();
var entries = querySnapshot.documents
.map((doc) => Entry.fromJson(doc.data)..id = doc.documentID)
.toList();
return entries;
}
@override
Future<Entry> update(String categoryId, String id, Entry entry) async {
var document = _categoriesRef.document('$categoryId/entries/$id');
await document.setData(entry.toJson());
var snapshot = await document.get();
return Entry.fromJson(snapshot.data)..id = snapshot.documentID;
}
@override
Future<Entry> get(String categoryId, String id) async {
var document = _categoriesRef.document('$categoryId/entries/$id');
var snapshot = await document.get();
return Entry.fromJson(snapshot.data)..id = snapshot.documentID;
}
}
class FirebaseCategoryApi implements CategoryApi {
final Firestore firestore;
final String userId;
final CollectionReference _categoriesRef;
FirebaseCategoryApi(this.firestore, this.userId)
: _categoriesRef = firestore.collection('users/$userId/categories');
@override
Stream<List<Category>> subscribe() {
var snapshots = _categoriesRef.snapshots();
var result = snapshots.map((querySnapshot) {
return querySnapshot.documents.map((snapshot) {
return Category.fromJson(snapshot.data)..id = snapshot.documentID;
}).toList();
});
return result;
}
@override
Future<Category> delete(String id) async {
var document = _categoriesRef.document('$id');
var categories = await get(document.documentID);
await document.delete();
return categories;
}
@override
Future<Category> get(String id) async {
var document = _categoriesRef.document('$id');
var snapshot = await document.get();
return Category.fromJson(snapshot.data)..id = snapshot.documentID;
}
@override
Future<Category> insert(Category category) async {
var document = await _categoriesRef.add(category.toJson());
return await get(document.documentID);
}
@override
Future<List<Category>> list() async {
var querySnapshot = await _categoriesRef.getDocuments();
var categories = querySnapshot.documents
.map((doc) => Category.fromJson(doc.data)..id = doc.documentID)
.toList();
return categories;
}
@override
Future<Category> update(Category category, String id) async {
var document = _categoriesRef.document('$id');
await document.setData(category.toJson());
var snapshot = await document.get();
return Category.fromJson(snapshot.data)..id = snapshot.documentID;
}
}

View File

@@ -3,6 +3,7 @@
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:math';
import 'package:uuid/uuid.dart' as uuid;
@@ -13,48 +14,67 @@ class MockDashboardApi implements DashboardApi {
final EntryApi entries = MockEntryApi();
@override
final ItemApi items = MockItemApi();
final CategoryApi categories = MockCategoryApi();
MockDashboardApi();
/// Creates a [MockDashboardApi] filled with mock data for the last 30 days.
Future<void> fillWithMockData() async {
await Future<void>.delayed(Duration(seconds: 1));
var category1 = await categories.insert(Category('Coffee (oz)'));
var category2 = await categories.insert(Category('Running (miles)'));
var category3 = await categories.insert(Category('Git Commits'));
var monthAgo = DateTime.now().subtract(Duration(days: 30));
for (var category in [category1, category2, category3]) {
for (var i = 0; i < 30; i++) {
var date = monthAgo.add(Duration(days: i));
var value = Random().nextInt(6) + 1;
await entries.insert(category.id, Entry(value, date));
}
}
}
}
class MockItemApi implements ItemApi {
Map<String, Item> _storage = {};
StreamController<List<Item>> _streamController =
StreamController<List<Item>>.broadcast();
class MockCategoryApi implements CategoryApi {
Map<String, Category> _storage = {};
StreamController<List<Category>> _streamController =
StreamController<List<Category>>.broadcast();
@override
Future<Item> delete(String id) async {
Future<Category> delete(String id) async {
var removed = _storage.remove(id);
_emit();
return _storage.remove(id);
return removed;
}
@override
Future<Item> get(String id) async {
Future<Category> get(String id) async {
return _storage[id];
}
@override
Future<Item> insert(Item item) async {
Future<Category> insert(Category category) async {
var id = uuid.Uuid().v4();
var newItem = Item(item.name)..id = id;
_storage[id] = newItem;
var newCategory = Category(category.name)..id = id;
_storage[id] = newCategory;
_emit();
return newItem;
return newCategory;
}
@override
Future<List<Item>> list() async {
Future<List<Category>> list() async {
return _storage.values.toList();
}
@override
Future<Item> update(Item item, String id) async {
_storage[id] = item;
return item..id = id;
Future<Category> update(Category category, String id) async {
_storage[id] = category;
_emit();
return category..id = id;
}
Stream<List<Item>> allItemsStream() {
return _streamController.stream;
}
Stream<List<Category>> subscribe() => _streamController.stream;
void _emit() {
_streamController.add(_storage.values.toList());
@@ -63,44 +83,64 @@ class MockItemApi implements ItemApi {
class MockEntryApi implements EntryApi {
Map<String, Entry> _storage = {};
StreamController<List<Entry>> _streamController =
StreamController<List<Entry>>.broadcast();
StreamController<_EntriesEvent> _streamController =
StreamController.broadcast();
@override
Future<Entry> delete(String itemId, String id) async {
_emit();
return _storage.remove('$itemId-$id');
Future<Entry> delete(String categoryId, String id) async {
_emit(categoryId);
return _storage.remove('$categoryId-$id');
}
@override
Future<Entry> insert(String itemId, Entry entry) async {
Future<Entry> insert(String categoryId, Entry entry) async {
var id = uuid.Uuid().v4();
var newEntry = Entry(entry.value, entry.time)..id = id;
_storage['$itemId-$id'] = newEntry;
_emit();
_storage['$categoryId-$id'] = newEntry;
_emit(categoryId);
return newEntry;
}
@override
Future<List<Entry>> list(String itemId) async {
Future<List<Entry>> list(String categoryId) async {
return _storage.keys
.where((k) => k.startsWith(itemId))
.where((k) => k.startsWith(categoryId))
.map((k) => _storage[k])
.toList();
}
@override
Future<Entry> update(String itemId, String id, Entry entry) async {
_storage['$itemId-$id'] = entry;
Future<Entry> update(String categoryId, String id, Entry entry) async {
_storage['$categoryId-$id'] = entry;
_emit(categoryId);
return entry..id = id;
}
@override
Stream<List<Entry>> allEntriesStream(String itemId) {
return _streamController.stream;
Stream<List<Entry>> subscribe(String categoryId) {
return _streamController.stream
.where((event) => event.categoryId == categoryId)
.map((event) => event.entries);
}
void _emit() {
_streamController.add(_storage.values.toList());
void _emit(String categoryId) {
var entries = _storage.keys
.where((k) => k.startsWith(categoryId))
.map((k) => _storage[k])
.toList();
_streamController.add(_EntriesEvent(categoryId, entries));
}
@override
Future<Entry> get(String categoryId, String id) async {
return _storage['$categoryId-$id'];
}
}
class _EntriesEvent {
final String categoryId;
final List<Entry> entries;
_EntriesEvent(this.categoryId, this.entries);
}

View File

@@ -2,58 +2,102 @@
// 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:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'api/api.dart';
import 'api/firebase.dart';
import 'api/mock.dart';
import 'auth/auth.dart';
import 'auth/firebase.dart';
import 'auth/mock.dart';
import 'pages/home.dart';
import 'widgets/third_party/adaptive_scaffold.dart';
import 'pages/sign_in.dart';
/// An app that shows a responsive dashboard.
/// The global state the app.
class AppState {
final Auth auth;
DashboardApi api;
AppState(this.auth);
}
/// Creates a [DashboardApi] when the user is logged in.
typedef DashboardApi ApiBuilder(User user);
/// An app that displays a personalized dashboard.
class DashboardApp extends StatefulWidget {
static ApiBuilder _mockApiBuilder =
(user) => MockDashboardApi()..fillWithMockData();
static ApiBuilder _apiBuilder =
(user) => FirebaseDashboardApi(Firestore.instance, user.uid);
final Auth auth;
final ApiBuilder apiBuilder;
/// Runs the app using Firebase
DashboardApp()
: auth = FirebaseAuthService(),
apiBuilder = _apiBuilder;
/// Runs the app using mock data
DashboardApp.mock()
: auth = MockAuthService(),
apiBuilder = _mockApiBuilder;
@override
_DashboardAppState createState() => _DashboardAppState();
}
class _DashboardAppState extends State<DashboardApp> {
int _pageIndex = 0;
AppState _appState;
void initState() {
super.initState();
_appState = AppState(widget.auth);
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<DashboardApi>(create: (_) => MockDashboardApi()),
],
return Provider.value(
value: _appState,
child: MaterialApp(
home: AdaptiveScaffold(
currentIndex: _pageIndex,
destinations: [
AdaptiveScaffoldDestination(title: 'Home', icon: Icons.home),
AdaptiveScaffoldDestination(title: 'Entries', icon: Icons.list),
AdaptiveScaffoldDestination(
title: 'Settings', icon: Icons.settings),
],
body: _pageAtIndex(_pageIndex),
onNavigationIndexChange: (newIndex) {
setState(() {
_pageIndex = newIndex;
});
},
home: Builder(
builder: (context) => SignInPage(
auth: _appState.auth,
onSuccess: (user) => _handleSignIn(user, context, _appState),
),
),
),
);
}
static Widget _pageAtIndex(int index) {
switch (index) {
case 1:
return Center(child: Text('page 2'));
case 2:
return Center(child: Text('page 3'));
case 0:
default:
return HomePage();
}
/// Sets the DashboardApi on AppState and navigates to the home page.
void _handleSignIn(User user, BuildContext context, AppState appState) {
appState.api = widget.apiBuilder(user);
_showPage(HomePage(), context);
}
/// Navigates to the home page using a fade transition.
void _showPage(Widget page, BuildContext context) {
var route = _fadeRoute(page);
Navigator.of(context).pushReplacement(route);
}
/// Creates a [Route] that shows [newPage] using a fade transition.
Route<FadeTransition> _fadeRoute(Widget newPage) {
return PageRouteBuilder<FadeTransition>(
pageBuilder: (context, animation, secondaryAnimation) {
return newPage;
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation.drive(CurveTween(curve: Curves.ease)),
child: child,
);
},
);
}
}

View File

@@ -0,0 +1,12 @@
// 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.
abstract class Auth {
Future<User> signIn();
Future signOut();
}
abstract class User {
String get uid;
}

View File

@@ -0,0 +1,41 @@
// 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:google_sign_in/google_sign_in.dart';
import 'package:firebase_auth/firebase_auth.dart' hide FirebaseUser;
import 'auth.dart';
class FirebaseAuthService implements Auth {
final GoogleSignIn _googleSignIn = GoogleSignIn();
final FirebaseAuth _auth = FirebaseAuth.instance;
Future<User> signIn() async {
GoogleSignInAccount googleUser;
if (await _googleSignIn.isSignedIn()) {
googleUser = await _googleSignIn.signInSilently();
} else {
googleUser = await _googleSignIn.signIn();
}
var googleAuth = await googleUser.authentication;
var credential = GoogleAuthProvider.getCredential(
accessToken: googleAuth.accessToken, idToken: googleAuth.idToken);
var authResult = await _auth.signInWithCredential(credential);
return _FirebaseUser(authResult.user.uid);
}
Future<void> signOut() async {
await _auth.signOut();
}
}
class _FirebaseUser implements User {
final String uid;
_FirebaseUser(this.uid);
}

View File

@@ -0,0 +1,21 @@
// 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 'auth.dart';
class MockAuthService implements Auth {
@override
Future<User> signIn() async {
return MockUser();
}
@override
Future signOut() async {
return null;
}
}
class MockUser implements User {
String get uid => "123";
}

View File

@@ -0,0 +1,67 @@
// 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 '../app.dart';
import '../widgets/category_chart.dart';
class DashboardPage extends StatelessWidget {
Widget build(BuildContext context) {
var appState = Provider.of<AppState>(context);
return FutureBuilder<List<Category>>(
future: appState.api.categories.list(),
builder: (context, futureSnapshot) {
if (!futureSnapshot.hasData) {
return Center(
child: CircularProgressIndicator(),
);
}
return StreamBuilder<List<Category>>(
initialData: futureSnapshot.data,
stream: appState.api.categories.subscribe(),
builder: (context, snapshot) {
if (snapshot.data == null) {
return Center(
child: CircularProgressIndicator(),
);
}
return Dashboard(snapshot.data);
},
);
},
);
}
}
class Dashboard extends StatelessWidget {
final List<Category> categories;
Dashboard(this.categories);
@override
Widget build(BuildContext context) {
var api = Provider.of<AppState>(context).api;
return Scrollbar(
child: GridView(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
childAspectRatio: 2,
maxCrossAxisExtent: 500,
),
children: [
...categories.map(
(category) => Card(
child: CategoryChart(
category: category,
api: api,
),
),
)
],
),
);
}
}

View File

@@ -0,0 +1,161 @@
// 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:intl/intl.dart' as intl;
import '../api/api.dart';
import '../app.dart';
import '../widgets/categories_dropdown.dart';
import '../widgets/dialogs.dart';
class EntriesPage extends StatefulWidget {
@override
_EntriesPageState createState() => _EntriesPageState();
}
class _EntriesPageState extends State<EntriesPage> {
Category _selected;
@override
Widget build(BuildContext context) {
var appState = Provider.of<AppState>(context);
return Column(
children: [
CategoryDropdown(
api: appState.api.categories,
onSelected: (category) => setState(() => _selected = category)),
Expanded(
child: _selected == null
? Center(child: CircularProgressIndicator())
: EntriesList(
category: _selected,
api: appState.api.entries,
),
),
],
);
}
}
class EntriesList extends StatefulWidget {
final Category category;
final EntryApi api;
EntriesList({
@required this.category,
@required this.api,
}) : super(key: ValueKey(category.id));
@override
_EntriesListState createState() => _EntriesListState();
}
class _EntriesListState extends State<EntriesList> {
@override
Widget build(BuildContext context) {
if (widget.category == null) {
return _buildLoadingIndicator();
}
return FutureBuilder<List<Entry>>(
future: widget.api.list(widget.category.id),
builder: (context, futureSnapshot) {
if (!futureSnapshot.hasData) {
return _buildLoadingIndicator();
}
return StreamBuilder<List<Entry>>(
initialData: futureSnapshot.data,
stream: widget.api.subscribe(widget.category.id),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return _buildLoadingIndicator();
}
return ListView.builder(
itemBuilder: (context, index) {
return EntryTile(
category: widget.category,
entry: snapshot.data[index],
);
},
itemCount: snapshot.data.length,
);
},
);
},
);
}
Widget _buildLoadingIndicator() {
return Center(child: CircularProgressIndicator());
}
}
class EntryTile extends StatelessWidget {
final Category category;
final Entry entry;
EntryTile({
this.category,
this.entry,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(entry.value.toString()),
subtitle: Text(intl.DateFormat('MM/dd/yy h:mm a').format(entry.time)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlatButton(
child: Text('Edit'),
onPressed: () {
showDialog<void>(
context: context,
builder: (context) {
return EditEntryDialog(category: category, entry: entry);
},
);
},
),
FlatButton(
child: Text('Delete'),
onPressed: () async {
var shouldDelete = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete entry?'),
actions: [
FlatButton(
child: Text('Cancel'),
onPressed: () => Navigator.of(context).pop(false),
),
FlatButton(
child: Text('Delete'),
onPressed: () => Navigator.of(context).pop(true),
),
],
),
);
if (shouldDelete) {
await Provider.of<AppState>(context, listen: false)
.api
.entries
.delete(category.id, entry.id);
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('Entry deleted'),
),
);
}
},
),
],
),
);
}
}

View File

@@ -3,47 +3,79 @@
// 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';
import '../widgets/dialogs.dart';
import '../widgets/third_party/adaptive_scaffold.dart';
import 'dashboard.dart';
import 'entries.dart';
class HomePage extends StatelessWidget {
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _pageIndex = 0;
@override
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'));
},
),
return AdaptiveScaffold(
currentIndex: _pageIndex,
destinations: [
AdaptiveScaffoldDestination(title: 'Home', icon: Icons.home),
AdaptiveScaffoldDestination(title: 'Entries', icon: Icons.list),
AdaptiveScaffoldDestination(title: 'Settings', icon: Icons.settings),
],
body: _pageAtIndex(_pageIndex),
onNavigationIndexChange: (newIndex) {
setState(() {
_pageIndex = newIndex;
});
},
floatingActionButton:
_hasFloatingActionButton ? _buildFab(context) : null,
);
}
void _showDetails(Item item, BuildContext context) {
Navigator.of(context).push(MaterialPageRoute(builder: (context) {
return ItemDetailsPage(item);
}));
bool get _hasFloatingActionButton {
if (_pageIndex == 2) return false;
return true;
}
FloatingActionButton _buildFab(BuildContext context) {
return FloatingActionButton(
child: Icon(Icons.add),
onPressed: () => _handleFabPressed(),
);
}
void _handleFabPressed() {
if (_pageIndex == 0) {
showDialog<NewCategoryDialog>(
context: context,
builder: (context) => NewCategoryDialog(),
);
return;
}
if (_pageIndex == 1) {
showDialog<NewEntryDialog>(
context: context,
builder: (context) => NewEntryDialog(),
);
return;
}
}
Widget _pageAtIndex(int index) {
if (index == 0) {
return DashboardPage();
}
if (index == 1) {
return EntriesPage();
}
return Center(child: Text('Settings page'));
}
}

View File

@@ -1,22 +0,0 @@
// 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,46 @@
// 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 '../auth/auth.dart';
class SignInPage extends StatefulWidget {
final Auth auth;
final ValueChanged<User> onSuccess;
SignInPage({
@required this.auth,
@required this.onSuccess,
});
@override
_SignInPageState createState() => _SignInPageState();
}
class _SignInPageState extends State<SignInPage> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: RaisedButton(
child: Text('Sign In'),
onPressed: () async {
var user = await widget.auth.signIn();
if (user != null) {
widget.onSuccess(user);
} else {
throw ('Unable to sign in');
}
},
),
),
);
}
}

View File

@@ -0,0 +1,65 @@
// 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 '../api/api.dart';
import 'day_helpers.dart';
/// The total value of one or more [Entry]s on a given day.
class EntryTotal {
final DateTime day;
int value;
EntryTotal(this.day, this.value);
}
/// Returns a list of [EntryTotal] objects. Each [EntryTotal] is the sum of
/// the values of all the entries on a given day.
List<EntryTotal> entryTotalsByDay(List<Entry> entries, int daysAgo,
{DateTime today}) {
today ??= DateTime.now();
return _entryTotalsByDay(entries, daysAgo, today).toList();
}
Iterable<EntryTotal> _entryTotalsByDay(
List<Entry> entries, int daysAgo, DateTime today) sync* {
var start = today.subtract(Duration(days: daysAgo));
var entriesByDay = _entriesInRange(start, today, entries);
for (var i = 0; i < entriesByDay.length; i++) {
var list = entriesByDay[i];
var entryTotal = EntryTotal(start.add(Duration(days: i)), 0);
for (var entry in list) {
entryTotal.value += entry.value;
}
yield entryTotal;
}
}
/// Groups entries by day between [start] and [end]. The result is a list of
/// lists. The outer list represents the number of days since [start], and the
/// inner list is the group of entries on that day.
List<List<Entry>> _entriesInRange(
DateTime start, DateTime end, List<Entry> entries) =>
_entriesInRangeImpl(start, end, entries).toList();
Iterable<List<Entry>> _entriesInRangeImpl(
DateTime start, DateTime end, List<Entry> entries) sync* {
start = start.atMidnight;
end = end.atMidnight;
var d = start;
while (d.compareTo(end) <= 0) {
var es = <Entry>[];
for (var entry in entries) {
if (d.isSameDay(entry.time.atMidnight)) {
es.add(entry);
}
}
yield es;
d = d.add(Duration(days: 1));
}
}

View File

@@ -0,0 +1,15 @@
// 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.
extension DayUtils on DateTime {
/// The UTC date portion of a datetime, without the minutes, seconds, etc.
DateTime get atMidnight {
return DateTime.utc(year, month, day);
}
/// Checks that the two [DateTime]s share the same date.
bool isSameDay(DateTime d2) {
return this.year == d2.year && this.month == d2.month && this.day == d2.day;
}
}

View File

@@ -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);
}
}

View 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),
);
}
}

View 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);
}
},
),
),
],
),
],
),
);
}
}

View 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();
},
)
],
);
}
}

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 '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);
}
},
),
),
],
)
],
),
);
}
}