From d8690b8cae398e8b49ccfcea0482e463ccb0d80f Mon Sep 17 00:00:00 2001 From: Collin Jackson Date: Tue, 25 Aug 2015 16:11:51 -0700 Subject: [PATCH] Add date picker to widgets library and teach fitness app to use it Also, add an example for the date picker --- examples/fitness/lib/date_utils.dart | 22 +- examples/fitness/lib/fitness_item.dart | 2 +- examples/fitness/lib/measurement.dart | 102 ++++- examples/widgets/date_picker.dart | 62 +++ sky/packages/sky/lib/widgets.dart | 1 + sky/packages/sky/lib/widgets/date_picker.dart | 393 ++++++++++++++++++ sky/packages/sky/lib/widgets/dialog.dart | 19 +- sky/packages/sky/pubspec.yaml | 1 + 8 files changed, 577 insertions(+), 25 deletions(-) create mode 100644 examples/widgets/date_picker.dart create mode 100644 sky/packages/sky/lib/widgets/date_picker.dart diff --git a/examples/fitness/lib/date_utils.dart b/examples/fitness/lib/date_utils.dart index 570203e64..c0f42d54f 100644 --- a/examples/fitness/lib/date_utils.dart +++ b/examples/fitness/lib/date_utils.dart @@ -13,6 +13,12 @@ class DateUtils { static const MS_IN_WEEK = DateTime.DAYS_PER_WEEK * Duration.MILLISECONDS_PER_DAY; + // TODO(jmesserly): locale specific date format + static String _twoDigits(int n) { + if (n >= 10) return "${n}"; + return "0${n}"; + } + /** Formats a time in H:MM A format */ static String toHourMinutesString(Duration duration) { assert(duration.inDays == 0); @@ -68,14 +74,16 @@ class DateUtils { } else if (delta.inMilliseconds < MS_IN_WEEK) { return WEEKDAYS[then.weekday]; } else { - // TODO(jmesserly): locale specific date format - String twoDigits(int n) { - if (n >= 10) return "${n}"; - return "0${n}"; - } - String twoDigitMonth = twoDigits(then.month); - String twoDigitDay = twoDigits(then.day); + String twoDigitMonth = _twoDigits(then.month); + String twoDigitDay = _twoDigits(then.day); return "${then.year}-${twoDigitMonth}-${twoDigitDay}"; } } + + static String toDateString(DateTime then) { + // TODO(jmesserly): locale specific date format + String twoDigitMonth = _twoDigits(then.month); + String twoDigitDay = _twoDigits(then.day); + return "${then.year}-${twoDigitMonth}-${twoDigitDay}"; + } } diff --git a/examples/fitness/lib/fitness_item.dart b/examples/fitness/lib/fitness_item.dart index b4c952c4b..4e7d90605 100644 --- a/examples/fitness/lib/fitness_item.dart +++ b/examples/fitness/lib/fitness_item.dart @@ -21,7 +21,7 @@ abstract class FitnessItem { Map toJson() => { 'when' : when.toIso8601String() }; // TODO(jackson): Internationalize - String get displayDate => DateUtils.toRecentTimeString(when); + String get displayDate => DateUtils.toDateString(when); FitnessItemRow toRow({ FitnessItemHandler onDismissed }); } diff --git a/examples/fitness/lib/measurement.dart b/examples/fitness/lib/measurement.dart index 4e00f557b..bcbe84783 100644 --- a/examples/fitness/lib/measurement.dart +++ b/examples/fitness/lib/measurement.dart @@ -17,6 +17,7 @@ class Measurement extends FitnessItem { Map toJson() { Map json = super.toJson(); json['weight'] = weight; + json['type'] = runtimeType.toString(); return json; } @@ -53,6 +54,55 @@ class MeasurementRow extends FitnessItemRow { } } +class MeasurementDateDialog extends StatefulComponent { + MeasurementDateDialog({ this.navigator, this.previousDate }); + + Navigator navigator; + DateTime previousDate; + + @override + void initState() { + _selectedDate = previousDate; + } + + void syncConstructorArguments(MeasurementDateDialog source) { + navigator = source.navigator; + previousDate = source.previousDate; + } + + DateTime _selectedDate; + + void _handleDateChanged(DateTime value) { + setState(() { + _selectedDate = value; + }); + } + + Widget build() { + return new Dialog( + content: new DatePicker( + selectedDate: _selectedDate, + firstDate: new DateTime(2015, 8), + lastDate: new DateTime(2101), + onChanged: _handleDateChanged + ), + contentPadding: EdgeDims.zero, + actions: [ + new FlatButton( + child: new Text('CANCEL'), + onPressed: navigator.pop + ), + new FlatButton( + child: new Text('OK'), + onPressed: () { + navigator.pop(_selectedDate); + } + ), + ] + ); + } +} + class MeasurementFragment extends StatefulComponent { MeasurementFragment({ this.navigator, this.onCreated }); @@ -66,6 +116,7 @@ class MeasurementFragment extends StatefulComponent { } String _weight = ""; + DateTime _when = new DateTime.now(); String _errorMessage = null; EventDisposition _handleSave() { @@ -79,7 +130,7 @@ class MeasurementFragment extends StatefulComponent { }); return EventDisposition.processed; } - onCreated(new Measurement(when: new DateTime.now(), weight: parsedWeight)); + onCreated(new Measurement(when: _when, weight: parsedWeight)); navigator.pop(); return EventDisposition.processed; } @@ -107,23 +158,44 @@ class MeasurementFragment extends StatefulComponent { static final GlobalKey weightKey = new GlobalKey(); + EventDisposition _handleDatePressed(_) { + showDialog(navigator, (navigator) { + return new MeasurementDateDialog(navigator: navigator, previousDate: _when); + }).then((DateTime value) { + if (value == null) + return; + setState(() { + _when = value; + }); + }); + return EventDisposition.processed; + } + Widget buildBody() { - Measurement measurement = new Measurement(when: new DateTime.now()); + Measurement measurement = new Measurement(when: _when); + // TODO(jackson): Revisit the layout of this pane to be more maintainable return new Material( type: MaterialType.canvas, - child: new ScrollableViewport( - child: new Container( - padding: const EdgeDims.all(20.0), - child: new BlockBody([ - new Text(measurement.displayDate), - new Input( - key: weightKey, - placeholder: 'Enter weight', - keyboardType: KeyboardType_NUMBER, - onChanged: _handleWeightChanged - ), - ]) - ) + child: new Container( + padding: const EdgeDims.all(20.0), + child: new Column([ + new Listener( + onGestureTap: _handleDatePressed, + child: new Container( + height: 50.0, + child: new Column([ + new Text('Measurement Date'), + new Text(measurement.displayDate, style: Theme.of(this).text.caption), + ], alignItems: FlexAlignItems.start) + ) + ), + new Input( + key: weightKey, + placeholder: 'Enter weight', + keyboardType: KeyboardType_NUMBER, + onChanged: _handleWeightChanged + ), + ], alignItems: FlexAlignItems.stretch) ) ); } diff --git a/examples/widgets/date_picker.dart b/examples/widgets/date_picker.dart new file mode 100644 index 000000000..c79c5cba5 --- /dev/null +++ b/examples/widgets/date_picker.dart @@ -0,0 +1,62 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:sky/widgets.dart'; +import 'package:sky/theme/colors.dart' as colors; + +void main() => runApp(new DatePickerDemo()); + +class DatePickerDemo extends App { + + DateTime _dateTime; + + void initState() { + DateTime now = new DateTime.now(); + _dateTime = new DateTime(now.year, now.month, now.day); + } + + void _handleDateChanged(DateTime dateTime) { + setState(() { + _dateTime = dateTime; + }); + } + + Widget build() { + return new Theme( + data: new ThemeData( + brightness: ThemeBrightness.light, + primarySwatch: colors.Teal + ), + child: new Stack([ + new Scaffold( + toolbar: new ToolBar(center: new Text("Date Picker")), + body: new Material( + child: new Row( + [new Text(_dateTime.toString())], + alignItems: FlexAlignItems.end, + justifyContent: FlexJustifyContent.center + ) + ) + ), + new Dialog( + content: new DatePicker( + selectedDate: _dateTime, + firstDate: new DateTime(2015, 8), + lastDate: new DateTime(2101), + onChanged: _handleDateChanged + ), + contentPadding: EdgeDims.zero, + actions: [ + new FlatButton( + child: new Text('CANCEL') + ), + new FlatButton( + child: new Text('OK') + ), + ] + ) + ]) + ); + } +} diff --git a/sky/packages/sky/lib/widgets.dart b/sky/packages/sky/lib/widgets.dart index 9e95d5da9..e5d584189 100644 --- a/sky/packages/sky/lib/widgets.dart +++ b/sky/packages/sky/lib/widgets.dart @@ -10,6 +10,7 @@ export 'widgets/basic.dart'; export 'widgets/button_base.dart'; export 'widgets/card.dart'; export 'widgets/checkbox.dart'; +export 'widgets/date_picker.dart'; export 'widgets/default_text_style.dart'; export 'widgets/dialog.dart'; export 'widgets/dismissable.dart'; diff --git a/sky/packages/sky/lib/widgets/date_picker.dart b/sky/packages/sky/lib/widgets/date_picker.dart new file mode 100644 index 000000000..a24243e75 --- /dev/null +++ b/sky/packages/sky/lib/widgets/date_picker.dart @@ -0,0 +1,393 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:sky/theme/colors.dart' as colors; +import 'package:sky/theme/typography.dart' as typography; +import 'package:sky/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/date_symbols.dart'; + +typedef void DatePickerValueChanged(DateTime dateTime); + +enum DatePickerMode { day, year } + +typedef void DatePickerModeChanged(DatePickerMode value); + +class DatePicker extends StatefulComponent { + DatePicker({ + this.selectedDate, + this.onChanged, + this.firstDate, + this.lastDate + }) { + assert(selectedDate != null); + assert(firstDate != null); + assert(lastDate != null); + } + + DateTime selectedDate; + DatePickerValueChanged onChanged; + DateTime firstDate; + DateTime lastDate; + + void syncConstructorArguments(DatePicker source) { + selectedDate = source.selectedDate; + onChanged = source.onChanged; + firstDate = source.firstDate; + lastDate = source.lastDate; + } + + DatePickerMode _mode = DatePickerMode.day; + + void _handleModeChanged(DatePickerMode mode) { + setState(() { + _mode = mode; + }); + } + + void _handleYearChanged(DateTime dateTime) { + setState(() { + _mode = DatePickerMode.day; + }); + if (onChanged != null) + onChanged(dateTime); + } + + static const double _calendarHeight = 210.0; + + Widget build() { + Widget header = new DatePickerHeader( + selectedDate: selectedDate, + mode: _mode, + onModeChanged: _handleModeChanged + ); + Widget picker; + switch (_mode) { + case DatePickerMode.day: + picker = new MonthPicker( + selectedDate: selectedDate, + onChanged: onChanged, + firstDate: firstDate, + lastDate: lastDate, + itemExtent: _calendarHeight + ); + break; + case DatePickerMode.year: + picker = new YearPicker( + selectedDate: selectedDate, + onChanged: _handleYearChanged, + firstDate: firstDate, + lastDate: lastDate + ); + break; + } + return new BlockBody([header, new Container(height: _calendarHeight, child: picker)]); + } + +} + +// Shows the selected date in large font and toggles between year and day mode +class DatePickerHeader extends Component { + DatePickerHeader({ this.selectedDate, this.mode, this.onModeChanged }) { + assert(selectedDate != null); + assert(mode != null); + } + + DateTime selectedDate; + DatePickerMode mode; + DatePickerModeChanged onModeChanged; + + EventDisposition _handleChangeMode(DatePickerMode value) { + if (value == mode) + return EventDisposition.ignored; + onModeChanged(value); + return EventDisposition.processed; + } + + Widget build() { + ThemeData theme = Theme.of(this); + typography.TextTheme headerTheme; + Color dayColor; + Color yearColor; + switch(theme.primaryColorBrightness) { + case ThemeBrightness.light: + headerTheme = typography.black; + dayColor = mode == DatePickerMode.day ? colors.black87 : colors.black54; + yearColor = mode == DatePickerMode.year ? colors.black87 : colors.black54; + break; + case ThemeBrightness.dark: + headerTheme = typography.white; + dayColor = mode == DatePickerMode.day ? colors.white87 : colors.white54; + yearColor = mode == DatePickerMode.year ? colors.white87 : colors.white54; + break; + } + TextStyle dayStyle = headerTheme.display3.copyWith(color: dayColor, height: 1.0, fontSize: 100.0); + TextStyle monthStyle = headerTheme.headline.copyWith(color: dayColor, height: 1.0); + TextStyle yearStyle = headerTheme.headline.copyWith(color: yearColor, height: 1.0); + DateTime firstDate = new DateTime(1900); + DateTime lastDate = new DateTime(2101); + + return new Container( + child: new BlockBody([ + new Center( + child: new Listener( + child: new Text(new DateFormat("MMM").format(selectedDate).toUpperCase(), style: monthStyle), + onGestureTap: (_) => _handleChangeMode(DatePickerMode.day) + ) + ), + new Center( + child: new Listener( + child: new Text(new DateFormat("d").format(selectedDate), style: dayStyle), + onGestureTap: (_) => _handleChangeMode(DatePickerMode.day) + ) + ), + new Center( + child: new Listener( + child: new Text(new DateFormat("yyyy").format(selectedDate), style: yearStyle), + onGestureTap: (_) => _handleChangeMode(DatePickerMode.year) + ) + ) + ]), + padding: new EdgeDims.all(10.0), + decoration: new BoxDecoration(backgroundColor: theme.primaryColor) + ); + } +} + +// Fixed height component shows a single month and allows choosing a day +class DayPicker extends Component { + DayPicker({ + this.selectedDate, + this.currentDate, + this.onChanged, + this.displayedMonth + }) { + assert(selectedDate != null); + assert(currentDate != null); + assert(displayedMonth != null); + } + + final DateTime selectedDate; + final DateTime currentDate; + final DatePickerValueChanged onChanged; + final DateTime displayedMonth; + + Widget build() { + ThemeData theme = Theme.of(this); + TextStyle headerStyle = theme.text.caption.copyWith(fontWeight: FontWeight.w700); + TextStyle monthStyle = headerStyle.copyWith(fontSize: 14.0, height: 24.0 / 14.0); + TextStyle dayStyle = headerStyle.copyWith(fontWeight: FontWeight.w500); + DateFormat dateFormat = new DateFormat(); + DateSymbols symbols = dateFormat.dateSymbols; + + List headers = []; + for (String weekDay in symbols.NARROWWEEKDAYS) { + headers.add(new Text(weekDay, style: headerStyle)); + } + List rows = [ + new Text(new DateFormat("MMMM y").format(displayedMonth), style: monthStyle), + new Flex( + headers, + justifyContent: FlexJustifyContent.spaceAround + ) + ]; + int year = displayedMonth.year; + int month = displayedMonth.month; + // Dart's Date time constructor is very forgiving and will understand + // month 13 as January of the next year. :) + int daysInMonth = new DateTime(year, month + 1).difference(new DateTime(year, month)).inDays; + int firstDay = new DateTime(year, month).day; + int weeksShown = 6; + List days = [ + DateTime.SUNDAY, + DateTime.MONDAY, + DateTime.TUESDAY, + DateTime.WEDNESDAY, + DateTime.THURSDAY, + DateTime.FRIDAY, + DateTime.SATURDAY + ]; + int daySlots = weeksShown * days.length; + List labels = []; + for (int i = 0; i < daySlots; i++) { + // This assumes a start day of SUNDAY, but could be changed. + int day = i - firstDay + 1; + Widget item; + if (day < 1 || day > daysInMonth) { + item = new Text(""); + } else { + // Put a light circle around the selected day + BoxDecoration decoration = null; + if (selectedDate.year == year && + selectedDate.month == month && + selectedDate.day == day) + decoration = new BoxDecoration( + backgroundColor: theme.primarySwatch[100], + shape: Shape.circle + ); + + // Use a different font color for the current day + TextStyle itemStyle = dayStyle; + if (currentDate.year == year && + currentDate.month == month && + currentDate.day == day) + itemStyle = itemStyle.copyWith(color: theme.primaryColor); + + item = new Listener( + onGestureTap: (_) { + DateTime result = new DateTime(year, month, day); + if (onChanged != null) + onChanged(result); + }, + child: new Container( + height: 30.0, + decoration: decoration, + child: new Center( + child: new Text(day.toString(), style: itemStyle) + ) + ) + ); + } + labels.add(new Flexible(child: item)); + } + for (int w = 0; w < weeksShown; w++) { + int startIndex = w * days.length; + rows.add(new Container( + child: new Flex( + labels.sublist(startIndex, startIndex + days.length), + justifyContent: FlexJustifyContent.spaceAround + ) + )); + } + + return new Column(rows); + } +} + +// Scrollable list of DayPickers to allow choosing a month +class MonthPicker extends ScrollableWidgetList { + MonthPicker({ + this.selectedDate, + this.onChanged, + this.firstDate, + this.lastDate, + double itemExtent + }) : super(itemExtent: itemExtent) { + assert(selectedDate != null); + assert(lastDate.isAfter(firstDate)); + } + + DateTime selectedDate; + DatePickerValueChanged onChanged; + DateTime firstDate; + DateTime lastDate; + + void syncConstructorArguments(MonthPicker source) { + selectedDate = source.selectedDate; + onChanged = source.onChanged; + firstDate = source.firstDate; + lastDate = source.lastDate; + super.syncConstructorArguments(source); + } + + void initState() { + _updateCurrentDate(); + super.initState(); + } + + DateTime _currentDate; + void _updateCurrentDate() { + _currentDate = new DateTime.now(); + DateTime tomorrow = new DateTime(_currentDate.year, _currentDate.month, _currentDate.day + 1); + Duration timeUntilTomorrow = tomorrow.difference(_currentDate); + timeUntilTomorrow += const Duration(seconds: 1); // so we don't miss it by rounding + new Timer(timeUntilTomorrow, () { + setState(() { + _updateCurrentDate(); + }); + }); + } + + int get itemCount => (lastDate.year - firstDate.year) * 12 + lastDate.month - firstDate.month + 1; + + List buildItems(int start, int count) { + List result = new List(); + DateTime startDate = new DateTime(firstDate.year + start ~/ 12, firstDate.month + start % 12); + for (int i = 0; i < count; ++i) { + DateTime displayedMonth = new DateTime(startDate.year + i ~/ 12, startDate.month + i % 12); + Widget item = new Container( + height: itemExtent, + key: new ObjectKey(displayedMonth), + child: new DayPicker( + selectedDate: selectedDate, + currentDate: _currentDate, + onChanged: onChanged, + displayedMonth: displayedMonth + ) + ); + result.add(item); + } + return result; + } +} + +// Scrollable list of years to allow picking a year +class YearPicker extends ScrollableWidgetList { + YearPicker({ + this.selectedDate, + this.onChanged, + this.firstDate, + this.lastDate + }) : super(itemExtent: 50.0) { + assert(selectedDate != null); + assert(lastDate.isAfter(firstDate)); + } + DateTime selectedDate; + DatePickerValueChanged onChanged; + DateTime firstDate; + DateTime lastDate; + + void syncConstructorArguments(YearPicker source) { + selectedDate = source.selectedDate; + onChanged = source.onChanged; + firstDate = source.firstDate; + lastDate = source.lastDate; + super.syncConstructorArguments(source); + } + + int get itemCount => lastDate.year - firstDate.year + 1; + + List buildItems(int start, int count) { + TextStyle style = Theme.of(this).text.body1.copyWith(color: colors.black54); + List items = new List(); + for(int i = start; i < start + count; i++) { + int year = firstDate.year + i; + String label = year.toString(); + Widget item = new Listener( + key: new Key(label), + onGestureTap: (_) { + DateTime result = new DateTime(year, selectedDate.month, selectedDate.day); + if (onChanged != null) + onChanged(result); + }, + child: new InkWell( + child: new Container( + height: itemExtent, + decoration: year == selectedDate.year ? new BoxDecoration( + backgroundColor: Theme.of(this).primarySwatch[100], + shape: Shape.circle + ) : null, + child: new Center( + child: new Text(label, style: style) + ) + ) + ) + ); + items.add(item); + } + return items; + } +} diff --git a/sky/packages/sky/lib/widgets/dialog.dart b/sky/packages/sky/lib/widgets/dialog.dart index 2b9c8e9b2..6add84afb 100644 --- a/sky/packages/sky/lib/widgets/dialog.dart +++ b/sky/packages/sky/lib/widgets/dialog.dart @@ -25,7 +25,9 @@ class Dialog extends Component { Dialog({ Key key, this.title, + this.titlePadding, this.content, + this.contentPadding, this.actions, this.onDismiss }): super(key: key); @@ -34,10 +36,17 @@ class Dialog extends Component { /// of the dialog. final Widget title; + // Padding around the title; uses material design default if none is supplied + // If there is no title, no padding will be provided + final EdgeDims titlePadding; + /// The (optional) content of the dialog is displayed in the center of the /// dialog in a lighter font. final Widget content; + // Padding around the content; uses material design default if none is supplied + final EdgeDims contentPadding; + /// The (optional) set of actions that are displayed at the bottom of the /// dialog. final List actions; @@ -59,8 +68,11 @@ class Dialog extends Component { List dialogBody = new List(); if (title != null) { + EdgeDims padding = titlePadding; + if (padding == null) + padding = new EdgeDims(24.0, 24.0, content == null ? 20.0 : 0.0, 24.0); dialogBody.add(new Padding( - padding: new EdgeDims(24.0, 24.0, content == null ? 20.0 : 0.0, 24.0), + padding: padding, child: new DefaultTextStyle( style: Theme.of(this).text.title, child: title @@ -69,8 +81,11 @@ class Dialog extends Component { } if (content != null) { + EdgeDims padding = contentPadding; + if (padding == null) + padding = const EdgeDims(20.0, 24.0, 24.0, 24.0); dialogBody.add(new Padding( - padding: const EdgeDims(20.0, 24.0, 24.0, 24.0), + padding: padding, child: new DefaultTextStyle( style: Theme.of(this).text.subhead, child: content diff --git a/sky/packages/sky/pubspec.yaml b/sky/packages/sky/pubspec.yaml index 65f1d6514..762cef05b 100644 --- a/sky/packages/sky/pubspec.yaml +++ b/sky/packages/sky/pubspec.yaml @@ -13,5 +13,6 @@ dependencies: sky_services: ^0.0.14 sky_tools: ^0.0.10 vector_math: ^1.4.3 + intl: ^0.12.4+2 environment: sdk: '>=1.8.0 <2.0.0'