diff --git a/lib/entity.class.dart b/lib/entity.class.dart index eea1b20..01a3212 100644 --- a/lib/entity.class.dart +++ b/lib/entity.class.dart @@ -8,34 +8,33 @@ class Entity { "unknown": Colors.black12, "playing": Colors.amber }; - static const RIGTH_WIDGET_PADDING = 14.0; + static const RIGHT_WIDGET_PADDING = 14.0; static const LEFT_WIDGET_PADDING = 8.0; static const EXTENDED_WIDGET_HEIGHT = 50.0; static const WIDGET_HEIGHT = 34.0; + static const ICON_SIZE = 28.0; + static const STATE_FONT_SIZE = 16.0; + static const NAME_FONT_SIZE = 16.0; + static const SMALL_FONT_SIZE = 14.0; + static const INPUT_WIDTH = 160.0; Map _attributes; String _domain; String _entityId; String _state; - String _entityPicture; DateTime _lastUpdated; - String get displayName => _attributes["friendly_name"] ?? (_attributes["name"] ?? "_"); + String get displayName => + _attributes["friendly_name"] ?? (_attributes["name"] ?? "_"); String get domain => _domain; String get entityId => _entityId; String get state => _state; set state(value) => _state = value; - double get minValue => _attributes["min"] ?? 0.0; - double get maxValue => _attributes["max"] ?? 100.0; - double get valueStep => _attributes["step"] ?? 1.0; - double get doubleState => double.tryParse(_state) ?? 0.0; - bool get isSliderField => _attributes["mode"] == "slider"; - bool get isTextField => _attributes["mode"] == "text"; - bool get isPasswordField => _attributes["mode"] == "password"; - String get deviceClass => _attributes["device_class"] ?? null; - bool get isView => (_domain == "group") && (_attributes != null ? _attributes["view"] ?? false : false); + bool get isView => + (_domain == "group") && + (_attributes != null ? _attributes["view"] ?? false : false); bool get isGroup => _domain == "group"; String get icon => _attributes["icon"] ?? ""; bool get isOn => state == "on"; @@ -48,10 +47,6 @@ class Entity { update(rawData); } - int getValueDivisions() { - return ((maxValue - minValue)/valueStep).round().round(); - } - void update(Map rawData) { _attributes = rawData["attributes"] ?? {}; _domain = rawData["entity_id"].split(".")[0]; @@ -64,7 +59,28 @@ class Entity { if (_lastUpdated == null) { return "-"; } else { - return formatDate(_lastUpdated, [yy, '-', M, '-', d, ' ', HH, ':', nn, ':', ss]); + DateTime now = DateTime.now(); + Duration d = now.difference(_lastUpdated); + String text; + int v; + if (d.inDays == 0) { + if (d.inHours == 0) { + if (d.inMinutes == 0) { + text = "seconds ago"; + v = d.inSeconds; + } else { + text = "minutes ago"; + v = d.inMinutes; + } + } else { + text = "hours ago"; + v = d.inHours; + } + } else { + text = "days ago"; + v = d.inDays; + } + return "$v $text"; } } @@ -72,65 +88,54 @@ class Entity { eventBus.fire(new ShowEntityPageEvent(this)); } - Widget buildWidget(BuildContext context) { + void sendNewState(newState) { + return; + } + + Widget buildWidget(bool inCard, BuildContext context) { return SizedBox( height: Entity.WIDGET_HEIGHT, child: Row( children: [ GestureDetector( child: _buildIconWidget(), - onTap: openEntityPage, + onTap: inCard ? openEntityPage : null, ), Expanded( child: GestureDetector( child: _buildNameWidget(), - onTap: openEntityPage, + onTap: inCard ? openEntityPage : null, ), ), - _buildActionWidget(context) + _buildActionWidget(inCard, context) ], ), ); } - Widget buildExtendedWidget(BuildContext context, String staticState) { - return Row( - children: [ - _buildIconWidget(), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: _buildNameWidget(), - ), - _buildExtendedActionWidget(context, staticState) - ], - ), - _buildLastUpdatedWidget() - ], - ), - ) - ], - ); + Widget buildAdditionalWidget() { + return _buildLastUpdatedWidget(); } Widget _buildIconWidget() { return Padding( padding: EdgeInsets.fromLTRB(Entity.LEFT_WIDGET_PADDING, 0.0, 12.0, 0.0), - child: MaterialDesignIcons.createIconWidgetFromEntityData(this, 28.0, Entity.STATE_ICONS_COLORS[_state] ?? Colors.blueGrey), + child: MaterialDesignIcons.createIconWidgetFromEntityData( + this, + Entity.ICON_SIZE, + Entity.STATE_ICONS_COLORS[_state] ?? Colors.blueGrey), ); } Widget _buildLastUpdatedWidget() { - return Text( - '${this.lastUpdated}', - textAlign: TextAlign.left, - style: TextStyle( - fontSize: 12.0, - color: Colors.black26 + return Padding( + padding: EdgeInsets.fromLTRB( + Entity.LEFT_WIDGET_PADDING, Entity.SMALL_FONT_SIZE, 0.0, 0.0), + child: Text( + '${this.lastUpdated}', + textAlign: TextAlign.left, + style: + TextStyle(fontSize: Entity.SMALL_FONT_SIZE, color: Colors.black26), ), ); } @@ -142,173 +147,340 @@ class Entity { "${this.displayName}", overflow: TextOverflow.ellipsis, softWrap: false, - style: TextStyle( - fontSize: 16.0 - ), + style: TextStyle(fontSize: Entity.NAME_FONT_SIZE), ), ); } - Widget _buildActionWidget(BuildContext context) { + Widget _buildActionWidget(bool inCard, BuildContext context) { return Padding( - padding: EdgeInsets.fromLTRB(0.0, 0.0, Entity.RIGTH_WIDGET_PADDING, 0.0), + padding: + EdgeInsets.fromLTRB(0.0, 0.0, Entity.RIGHT_WIDGET_PADDING, 0.0), child: GestureDetector( child: Text( - this.isPasswordField ? "******" : "$_state${this.unitOfMeasurement}", textAlign: TextAlign.right, style: new TextStyle( - fontSize: 16.0, - ) - ), + fontSize: Entity.STATE_FONT_SIZE, + )), onTap: openEntityPage, ) ); } - - Widget _buildExtendedActionWidget(BuildContext context, String staticState) { - return _buildActionWidget(context); - } } class SwitchEntity extends Entity { - SwitchEntity(Map rawData) : super(rawData); @override - Widget _buildActionWidget(BuildContext context) { + void sendNewState(newValue) { + eventBus.fire(new ServiceCallEvent( + _domain, (newValue as bool) ? "turn_on" : "turn_off", entityId, null)); + } + + @override + Widget _buildActionWidget(bool inCard, BuildContext context) { return Switch( value: this.isOn, onChanged: ((switchState) { - eventBus.fire(new ServiceCallEvent(_domain, switchState ? "turn_on" : "turn_off", entityId, null)); + sendNewState(switchState); }), ); } - } class ButtonEntity extends Entity { - ButtonEntity(Map rawData) : super(rawData); @override - Widget _buildActionWidget(BuildContext context) { + void sendNewState(newValue) { + eventBus.fire(new ServiceCallEvent(_domain, "turn_on", _entityId, null)); + } + + @override + Widget _buildActionWidget(bool inCard, BuildContext context) { return FlatButton( onPressed: (() { - eventBus.fire(new ServiceCallEvent(_domain, "turn_on", _entityId, null)); + sendNewState(null); }), child: Text( "EXECUTE", textAlign: TextAlign.right, - style: new TextStyle(fontSize: 16.0, color: Colors.blue), + style: + new TextStyle(fontSize: Entity.STATE_FONT_SIZE, color: Colors.blue), ), ); } - } -class InputEntity extends Entity { +// +// SLIDER +// +class SliderEntity extends Entity { + int _multiplier = 1; - InputEntity(Map rawData) : super(rawData); + double get minValue => _attributes["min"] ?? 0.0; + double get maxValue => _attributes["max"] ?? 100.0; + double get valueStep => _attributes["step"] ?? 1.0; + double get doubleState => double.tryParse(_state) ?? 0.0; - @override - Widget buildExtendedWidget(BuildContext context, String staticState) { - return Column( - children: [ - SizedBox( - height: Entity.EXTENDED_WIDGET_HEIGHT, - child: Row( - children: [ - _buildIconWidget(), - Expanded( - child: _buildNameWidget(), - ), - _buildLastUpdatedWidget() - ], - ), - ), - SizedBox( - height: Entity.EXTENDED_WIDGET_HEIGHT, - child: _buildExtendedActionWidget(context, staticState), - ) - ], - ); - } - - @override - Widget _buildActionWidget(BuildContext context) { - if (this.isSliderField) { - return Container( - width: 200.0, - child: Row( - children: [ - Expanded( - child: Slider( - min: this.minValue*10, - max: this.maxValue*10, - value: (this.doubleState <= this.maxValue) && (this.doubleState >= this.minValue) ? this.doubleState*10 : this.minValue*10, - divisions: this.getValueDivisions(), - onChanged: (value) { - eventBus.fire(new StateChangedEvent(_entityId, (value.roundToDouble() / 10).toString(), true)); - }, - onChangeEnd: (value) { - eventBus.fire(new ServiceCallEvent(_domain, "set_value", _entityId,{"value": "$_state"})); - }, - ), - ), - Padding( - padding: EdgeInsets.only(right: 16.0), - child: Text( - "$_state${this.unitOfMeasurement}", - textAlign: TextAlign.right, - style: new TextStyle( - fontSize: 16.0, - ) - ), - ) - ], - ), - ); - } else { - return super._buildActionWidget(context); + SliderEntity(Map rawData) : super(rawData) { + if (valueStep < 1) { + _multiplier = 10; + } else if (valueStep < 0.1) { + _multiplier = 100; } } @override - Widget _buildExtendedActionWidget(BuildContext context, String staticState) { - return Padding( - padding: EdgeInsets.fromLTRB(Entity.LEFT_WIDGET_PADDING, 0.0, Entity.RIGTH_WIDGET_PADDING, 0.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: TextField( - obscureText: this.isPasswordField, - controller: TextEditingController( - text: staticState, - ), - onChanged: (value) { - staticState = value; + void sendNewState(newValue) { + eventBus.fire(new ServiceCallEvent(_domain, "set_value", _entityId, + {"value": "${newValue.toString()}"})); + } + + @override + Widget _buildActionWidget(bool inCard, BuildContext context) { + return Container( + width: 200.0, + child: Row( + children: [ + Expanded( + child: Slider( + min: this.minValue * _multiplier, + max: this.maxValue * _multiplier, + value: (this.doubleState <= this.maxValue) && + (this.doubleState >= this.minValue) + ? this.doubleState * _multiplier + : this.minValue * _multiplier, + onChanged: (value) { + eventBus.fire(new StateChangedEvent(_entityId, + (value.roundToDouble() / _multiplier).toString(), true)); }, - ), + onChangeEnd: (value) { + sendNewState(value.roundToDouble() / _multiplier); + }, ), - SizedBox( - width: 63.0, - child: FlatButton( - onPressed: () { - eventBus.fire(new ServiceCallEvent(_domain, "set_value", _entityId,{"value": "$staticState"})); - Navigator.pop(context); - }, - child: Text( - "SET", - textAlign: TextAlign.right, - style: new TextStyle(fontSize: 16.0, color: Colors.blue), - ), - ), - ) - ], + ), + Padding( + padding: EdgeInsets.only(right: Entity.RIGHT_WIDGET_PADDING), + child: Text("$_state${this.unitOfMeasurement}", + textAlign: TextAlign.right, + style: new TextStyle( + fontSize: Entity.STATE_FONT_SIZE, + )), + ) + ], + ), + ); + } +} + +// +// DATETIME +// + +class DateTimeEntity extends Entity { + bool get hasDate => _attributes["has_date"] ?? false; + bool get hasTime => _attributes["has_time"] ?? false; + int get year => _attributes["year"] ?? 1970; + int get month => _attributes["month"] ?? 1; + int get day => _attributes["day"] ?? 1; + int get hour => _attributes["hour"] ?? 0; + int get minute => _attributes["minute"] ?? 0; + int get second => _attributes["second"] ?? 0; + String get formattedState => _getFormattedState(); + DateTime get dateTimeState => _getDateTimeState(); + + DateTimeEntity(Map rawData) : super(rawData); + + DateTime _getDateTimeState() { + return DateTime(this.year, this.month, this.day, this.hour, this.minute, this.second); + } + + String _getFormattedState() { + String formattedState = ""; + if (this.hasDate) { + formattedState += formatDate(dateTimeState, [M, ' ', d, ', ', yyyy]); + } + if (this.hasTime) { + formattedState += " "+formatDate(dateTimeState, [HH, ':', nn]); + } + return formattedState; + } + + @override + void sendNewState(newValue) { + eventBus.fire(new ServiceCallEvent(_domain, "set_datetime", _entityId, + newValue)); + } + + @override + Widget _buildActionWidget(bool inCard, BuildContext context) { + return Padding( + padding: + EdgeInsets.fromLTRB(0.0, 0.0, Entity.RIGHT_WIDGET_PADDING, 0.0), + child: GestureDetector( + child: Text( + "$formattedState", + textAlign: TextAlign.right, + style: new TextStyle( + fontSize: Entity.STATE_FONT_SIZE, + )), + onTap: () => _handleStateTap(context), ) ); } -} \ No newline at end of file + void _handleStateTap(BuildContext context) { + if (hasDate) { + _showDatePicker(context).then((date) { + if (date != null) { + if (hasTime) { + _showTimePicker(context).then((time){ + sendNewState({"date": "${formatDate(date, [yyyy, '-', mm, '-', dd])}", "time": "${formatDate(DateTime(1970, 1, 1, time.hour, time.minute), [HH, ':', nn])}"}); + }); + } else { + sendNewState({"date": "${formatDate(date, [yyyy, '-', mm, '-', dd])}"}); + } + } + }); + } else if (hasTime) { + _showTimePicker(context).then((time){ + if (time != null) { + sendNewState({"time": "${formatDate(DateTime(1970, 1, 1, time.hour, time.minute), [HH, ':', nn])}"}); + } + }); + } else { + TheLogger.log("Warning", "$entityId has no date and no time"); + } + } + + Future _showDatePicker(BuildContext context) { + return showDatePicker( + context: context, + initialDate: dateTimeState, + firstDate: DateTime(1970), + lastDate: DateTime(2037) //Unix timestamp will finish at Jan 19, 2038 + ); + } + + Future _showTimePicker(BuildContext context) { + return showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(dateTimeState) + ); + } +} + +class SelectEntity extends Entity { + List _listOptions = []; + String get initialValue => _attributes["initial"] ?? null; + + SelectEntity(Map rawData) : super(rawData) { + if (_attributes["options"] != null) { + _attributes["options"].forEach((value){ + _listOptions.add(value.toString()); + }); + } + } + + @override + void sendNewState(newValue) { + eventBus.fire(new ServiceCallEvent(_domain, "select_option", _entityId, + {"option": "$newValue"})); + } + + @override + Widget _buildActionWidget(bool inCard, BuildContext context) { + return Container( + width: Entity.INPUT_WIDTH, + child: DropdownButton( + value: _state, + items: this._listOptions.map((String value) { + return new DropdownMenuItem( + value: value, + child: new Text(value), + ); + }).toList(), + onChanged: (_) { + sendNewState(_); + }, + ), + ); + } +} + +class TextEntity extends Entity { + String tmpState; + FocusNode _focusNode; + bool validValue = false; + + int get valueMinLength => _attributes["min"] ?? -1; + int get valueMaxLength => _attributes["max"] ?? -1; + String get valuePattern => _attributes["pattern"] ?? null; + bool get isTextField => _attributes["mode"] == "text"; + bool get isPasswordField => _attributes["mode"] == "password"; + + TextEntity(Map rawData) : super(rawData) { + _focusNode = FocusNode(); + //TODO possible memory leak generator + _focusNode.addListener(_focusListener); + //tmpState = state; + } + + @override + void sendNewState(newValue) { + if (validate(newValue)) { + eventBus.fire(new ServiceCallEvent(_domain, "set_value", _entityId, + {"value": "{newValue"})); + } + } + + @override + void update(Map rawData) { + super.update(rawData); + tmpState = _state; + } + + bool validate(newValue) { + if (newValue is String) { + //TODO add pattern support + validValue = (newValue.length >= this.valueMinLength) && + (this.valueMaxLength == -1 || + (newValue.length <= this.valueMaxLength)); + } else { + validValue = true; + } + return validValue; + } + + void _focusListener() { + if (!_focusNode.hasFocus && (tmpState != state)) { + sendNewState(tmpState); + tmpState = state; + } + } + + @override + Widget _buildActionWidget(bool inCard, BuildContext context) { + if (this.isTextField || this.isPasswordField) { + return Container( + width: Entity.INPUT_WIDTH, + child: TextField( + focusNode: inCard ? _focusNode : null, + obscureText: this.isPasswordField, + controller: new TextEditingController.fromValue( + new TextEditingValue( + text: tmpState, + selection: + new TextSelection.collapsed(offset: tmpState.length))), + onChanged: (value) { + tmpState = value; + }), + ); + } else { + TheLogger.log("Warning", "Unsupported input mode for $entityId"); + return super._buildActionWidget(inCard, context); + } + } +} diff --git a/lib/entity.page.dart b/lib/entity.page.dart index 1314440..2bc2372 100644 --- a/lib/entity.page.dart +++ b/lib/entity.page.dart @@ -12,21 +12,17 @@ class EntityViewPage extends StatefulWidget { class _EntityViewPageState extends State { String _title; Entity _entity; - String _lastState; StreamSubscription _stateSubscription; @override void initState() { super.initState(); _entity = widget.entity; - _lastState = _entity.state; if (_stateSubscription != null) _stateSubscription.cancel(); _stateSubscription = eventBus.on().listen((event) { - setState(() { - if (event.entityId == _entity.entityId) { - _lastState = event.newState ?? _entity.state; - } - }); + if (event.entityId == _entity.entityId) { + setState(() {}); + } }); _prepareData(); } @@ -50,7 +46,8 @@ class _EntityViewPageState extends State { padding: EdgeInsets.all(10.0), child: ListView( children: [ - _entity.buildExtendedWidget(context, _lastState) + _entity.buildWidget(false, context), + _entity.buildAdditionalWidget() ], ), ), @@ -59,6 +56,10 @@ class _EntityViewPageState extends State { @override void dispose(){ + if (_entity is TextEntity && (_entity as TextEntity).tmpState != _entity.state) { + eventBus.fire(new ServiceCallEvent(_entity.domain, "set_value", _entity.entityId, {"value": "${(_entity as TextEntity).tmpState}"})); + TheLogger.log("Debug", "Saving changed input value for ${_entity.entityId}"); + } if (_stateSubscription != null) _stateSubscription.cancel(); super.dispose(); } diff --git a/lib/entity_collection.class.dart b/lib/entity_collection.class.dart index 1aae3b6..4e06795 100644 --- a/lib/entity_collection.class.dart +++ b/lib/entity_collection.class.dart @@ -40,9 +40,20 @@ class EntityCollection { return ButtonEntity(rawEntityData); } - case "input_text": + case "input_datetime": { + return DateTimeEntity(rawEntityData); + } + + case "input_select": { + return SelectEntity(rawEntityData); + } + case "input_number": { - return InputEntity(rawEntityData); + return SliderEntity(rawEntityData); + } + + case "input_text": { + return TextEntity(rawEntityData); } default: { diff --git a/lib/home_assistant.class.dart b/lib/home_assistant.class.dart index 03790f3..4653f72 100644 --- a/lib/home_assistant.class.dart +++ b/lib/home_assistant.class.dart @@ -183,7 +183,7 @@ class HomeAssistant { } void _handleEntityStateChange(Map eventData) { - TheLogger.log("Debug", "Parsing new state for ${eventData['entity_id']}"); + TheLogger.log("Debug", "New state for ${eventData['entity_id']}"); _entities.updateState(eventData); eventBus.fire(new StateChangedEvent(eventData["entity_id"], null, false)); } diff --git a/lib/main.dart b/lib/main.dart index 36ff6ad..3f690c9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,7 @@ part 'badge_class.dart'; EventBus eventBus = new EventBus(); const String appName = "HA Client"; -const appVersion = "0.2.0"; +const appVersion = "0.2.1"; String homeAssistantWebHost; @@ -385,7 +385,7 @@ class _MainPageState extends State with WidgetsBindingObserver { Widget _buildCardHeader(String name) { var result; - if (name.length > 0) { + if (name.trim().length > 0) { result = new ListTile( //leading: const Icon(Icons.device_hub), //subtitle: Text(".."), @@ -409,7 +409,7 @@ class _MainPageState extends State with WidgetsBindingObserver { entities.add( Padding( padding: EdgeInsets.fromLTRB(0.0, 10.0, 0.0, 10.0), - child: entity.buildWidget(context), + child: entity.buildWidget(true, context), )); } }); diff --git a/lib/mdi.class.dart b/lib/mdi.class.dart index 3bd9c1c..bcc0cd6 100644 --- a/lib/mdi.class.dart +++ b/lib/mdi.class.dart @@ -2875,6 +2875,7 @@ class MaterialDesignIcons { if (data.entityPicture != null) { if (homeAssistantWebHost != null) { return CircleAvatar( + radius: size/2, backgroundColor: Colors.white, backgroundImage: CachedNetworkImageProvider( "$homeAssistantWebHost${data.entityPicture}", diff --git a/pubspec.lock b/pubspec.lock index 54e3160..4673b8b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -285,7 +285,7 @@ packages: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" plugin: dependency: transitive description: @@ -486,7 +486,7 @@ packages: name: xml url: "https://pub.dartlang.org" source: hosted - version: "3.2.1" + version: "3.2.3" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6476290..e5a0c9b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: hass_client description: Home Assistant Android Client -version: 0.2.0+22 +version: 0.2.1+23 environment: sdk: ">=2.0.0-dev.68.0 <3.0.0"