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:
@@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
33
experimental/web_dashboard/lib/src/api/api.g.dart
Normal file
33
experimental/web_dashboard/lib/src/api/api.g.dart
Normal 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),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
12
experimental/web_dashboard/lib/src/auth/auth.dart
Normal file
12
experimental/web_dashboard/lib/src/auth/auth.dart
Normal 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;
|
||||
}
|
||||
41
experimental/web_dashboard/lib/src/auth/firebase.dart
Normal file
41
experimental/web_dashboard/lib/src/auth/firebase.dart
Normal 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);
|
||||
}
|
||||
21
experimental/web_dashboard/lib/src/auth/mock.dart
Normal file
21
experimental/web_dashboard/lib/src/auth/mock.dart
Normal 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";
|
||||
}
|
||||
67
experimental/web_dashboard/lib/src/pages/dashboard.dart
Normal file
67
experimental/web_dashboard/lib/src/pages/dashboard.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
161
experimental/web_dashboard/lib/src/pages/entries.dart
Normal file
161
experimental/web_dashboard/lib/src/pages/entries.dart
Normal 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
experimental/web_dashboard/lib/src/pages/sign_in.dart
Normal file
46
experimental/web_dashboard/lib/src/pages/sign_in.dart
Normal 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');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
experimental/web_dashboard/lib/src/utils/chart_utils.dart
Normal file
65
experimental/web_dashboard/lib/src/utils/chart_utils.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
15
experimental/web_dashboard/lib/src/utils/day_helpers.dart
Normal file
15
experimental/web_dashboard/lib/src/utils/day_helpers.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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