Compare commits

...

25 Commits

Author SHA1 Message Date
4492fb9f0c Build number 2018-11-24 17:30:02 +02:00
36410752e4 Fix app version 2018-11-24 17:29:33 +02:00
0219f7bfbb Version 0.3.10 2018-11-24 17:04:41 +02:00
5f3c77f4b9 Resolves #145 Fan support 2018-11-24 17:00:45 +02:00
a36c7a9ca3 Resolves #186 Switch for group with same domain antities 2018-11-24 11:33:59 +02:00
56ce6dfeeb Improved tap animation for glance card 2018-11-24 11:02:28 +02:00
67c214454f build number 2018-11-24 01:20:18 +02:00
73398378c4 Resolves #227 2018-11-24 00:37:55 +02:00
215871ce9e Resolves #226 2018-11-23 21:59:33 +02:00
fd8ea6befd Show auto groups that is not hidden 2018-11-23 19:30:16 +02:00
809a1a1c8c Resolves #146 Lock support 2018-11-23 19:18:17 +02:00
fc8f2f200f Small screens support 2018-11-23 18:16:38 +02:00
f41c9f9197 Resolves #202 Service call info 2018-11-23 16:38:26 +02:00
cdf55ce68b Resolves #201 New progress indicator in the bottom of the app 2018-11-23 16:30:42 +02:00
12088d9516 Resolves #223, Resolves #197 2018-11-23 16:03:38 +02:00
a0235ee385 Handle entity taps and holds in one place 2018-11-23 15:06:42 +02:00
67fbdb13c6 Proper autogenerateg groups detection 2018-11-23 14:33:03 +02:00
c5960de0be Resolves #193 Source selection support for media_player 2018-11-23 14:18:25 +02:00
da15e880ec Sound mode support for media player 2018-11-23 14:11:34 +02:00
efbe33f4e3 Fix font sizes, long entity states 2018-11-18 16:40:12 +02:00
af84c99a2d build 69 2018-11-18 13:25:00 +02:00
438449cad8 Handling taps on entity name and state for glance card 2018-11-18 13:24:05 +02:00
d9ca55c3b7 Resolves #131 2018-11-18 13:19:00 +02:00
f248268984 New bottom info bar 2018-11-18 12:46:54 +02:00
8ee096595c Resolves #192 Don't fetch data on every app resume 2018-11-18 09:47:22 +02:00
33 changed files with 744 additions and 296 deletions

View File

@ -1,9 +1,9 @@
part of 'main.dart';
class EntityViewPage extends StatefulWidget {
EntityViewPage({Key key, @required this.entity, @required this.homeAssistant }) : super(key: key);
EntityViewPage({Key key, @required this.entityId, @required this.homeAssistant }) : super(key: key);
final Entity entity;
final String entityId;
final HomeAssistant homeAssistant;
@override
@ -12,30 +12,26 @@ class EntityViewPage extends StatefulWidget {
class _EntityViewPageState extends State<EntityViewPage> {
String _title;
StreamSubscription _refreshDataSubscription;
StreamSubscription _stateSubscription;
@override
void initState() {
super.initState();
_stateSubscription = eventBus.on<StateChangedEvent>().listen((event) {
if (event.entityId == widget.entity.entityId) {
TheLogger.debug("State change event handled by entity page: ${event.entityId}");
if (event.entityId == widget.entityId) {
setState(() {});
}
});
_refreshDataSubscription = eventBus.on<RefreshDataFinishedEvent>().listen((event) {
setState(() {});
});
_prepareData();
_getHistory();
}
void _prepareData() async {
_title = widget.entity.displayName;
}
void _getHistory() {
/* widget.homeAssistant.getHistory(widget.entity.entityId).then((List history) {
if (history != null) {
}
});*/
_title = widget.homeAssistant.entities.get(widget.entityId).displayName;
}
@ -54,7 +50,7 @@ class _EntityViewPageState extends State<EntityViewPage> {
padding: EdgeInsets.all(10.0),
child: HomeAssistantModel(
homeAssistant: widget.homeAssistant,
child: widget.entity.buildEntityPageWidget(context)
child: widget.homeAssistant.entities.get(widget.entityId).buildEntityPageWidget(context)
)
),
);
@ -63,6 +59,7 @@ class _EntityViewPageState extends State<EntityViewPage> {
@override
void dispose(){
if (_stateSubscription != null) _stateSubscription.cancel();
if (_refreshDataSubscription != null) _refreshDataSubscription.cancel();
super.dispose();
}
}

View File

@ -34,4 +34,24 @@ class EntityTapAction {
static const moreInfo = 'more-info';
static const toggle = 'toggle';
static const callService = 'call-service';
static const none = 'none';
}
class CardType {
static const entities = "entities";
static const glance = "glance";
static const mediaControl = "media-control";
static const weatherForecast = "weather-forecast";
static const thermostat = "thermostat";
static const sensor = "sensor";
static const plantStatus = "plant-status";
static const pictureEntity = "picture-entity";
static const pictureElements = "picture-elements";
static const picture = "picture";
static const map = "map";
static const iframe = "iframe";
static const gauge = "gauge";
static const entityButton = "entity-button";
static const conditional = "conditional";
static const alarmPanel = "alarm-panel";
}

View File

@ -76,6 +76,15 @@ class Entity {
}
}
List<String> getStringListAttributeValue(String attribute) {
if (attributes["$attribute"] != null) {
List<String> result = (attributes["$attribute"] as List).cast<String>();
return result;
} else {
return null;
}
}
Widget buildDefaultWidget(BuildContext context) {
return DefaultEntityContainer(
state: _buildStatePart(context)

View File

@ -6,8 +6,10 @@ class EntityWrapper {
String icon;
String tapAction;
String holdAction;
String actionService;
Map<String, dynamic> actionServiceData;
String tapActionService;
Map<String, dynamic> tapActionServiceData;
String holdActionService;
Map<String, dynamic> holdActionServiceData;
Entity entity;
@ -16,12 +18,66 @@ class EntityWrapper {
String icon,
String displayName,
this.tapAction: EntityTapAction.moreInfo,
this.holdAction,
this.actionService,
this.actionServiceData
this.holdAction: EntityTapAction.none,
this.tapActionService,
this.tapActionServiceData,
this.holdActionService,
this.holdActionServiceData
}) {
this.icon = icon ?? entity.icon;
this.displayName = displayName ?? entity.displayName;
}
void handleTap() {
switch (tapAction) {
case EntityTapAction.toggle: {
eventBus.fire(
ServiceCallEvent("homeassistant", "toggle", entity.entityId, null));
break;
}
case EntityTapAction.callService: {
eventBus.fire(
ServiceCallEvent(tapActionService.split(".")[0], tapActionService.split(".")[1], null, tapActionServiceData));
break;
}
case EntityTapAction.none: {
break;
}
default: {
eventBus.fire(
new ShowEntityPageEvent(entity));
break;
}
}
}
void handleHold() {
switch (holdAction) {
case EntityTapAction.toggle: {
eventBus.fire(
ServiceCallEvent("homeassistant", "toggle", entity.entityId, null));
break;
}
case EntityTapAction.callService: {
eventBus.fire(
ServiceCallEvent(tapActionService.split(".")[0], tapActionService.split(".")[1], null, tapActionServiceData));
break;
}
case EntityTapAction.moreInfo: {
eventBus.fire(
new ShowEntityPageEvent(entity));
break;
}
default: {
break;
}
}
}
}

View File

@ -0,0 +1,32 @@
part of '../main.dart';
class FanEntity extends Entity {
static const SUPPORT_SET_SPEED = 1;
static const SUPPORT_OSCILLATE = 2;
static const SUPPORT_DIRECTION = 4;
FanEntity(Map rawData) : super(rawData);
bool get supportSetSpeed => ((attributes["supported_features"] &
FanEntity.SUPPORT_SET_SPEED) ==
FanEntity.SUPPORT_SET_SPEED);
bool get supportOscillate => ((attributes["supported_features"] &
FanEntity.SUPPORT_OSCILLATE) ==
FanEntity.SUPPORT_OSCILLATE);
bool get supportDirection => ((attributes["supported_features"] &
FanEntity.SUPPORT_DIRECTION) ==
FanEntity.SUPPORT_DIRECTION);
List<String> get speedList => getStringListAttributeValue("speed_list");
@override
Widget _buildStatePart(BuildContext context) {
return SwitchStateWidget();
}
@override
Widget _buildAdditionalControlsForPage(BuildContext context) {
return FanControlsWidget();
}
}

View File

@ -0,0 +1,43 @@
part of '../main.dart';
class GroupEntity extends Entity {
GroupEntity(Map rawData) : super(rawData);
final List<String> _domainsForSwitchableGroup = ["switch", "light", "automation", "input_boolean"];
String mutualDomain;
bool switchable = false;
@override
Widget _buildStatePart(BuildContext context) {
if (switchable) {
return SwitchStateWidget(
domainForService: "homeassistant",
);
} else {
return super._buildStatePart(context);
}
}
@override
void update(Map rawData) {
super.update(rawData);
if (_isOneDomain()) {
mutualDomain = attributes['entity_id'][0].split(".")[0];
switchable = _domainsForSwitchableGroup.contains(mutualDomain);
}
}
bool _isOneDomain() {
bool result = false;
if (attributes['entity_id'] != null && attributes['entity_id'] is List && attributes['entity_id'].isNotEmpty) {
String firstChildDomain = attributes['entity_id'][0].split(".")[0];
result = true;
attributes['entity_id'].forEach((childEntityId){
if (childEntityId.split(".")[0] != firstChildDomain) {
result = false;
}
});
}
return result;
}
}

View File

@ -38,7 +38,7 @@ class LightEntity extends Entity {
double get minMireds => _getDoubleAttributeValue("min_mireds");
Color get color => _getColor();
bool get isAdditionalControls => ((attributes["supported_features"] != null) && (attributes["supported_features"] != 0));
List<String> get effectList => _getEffectList();
List<String> get effectList => getStringListAttributeValue("effect_list");
LightEntity(Map rawData) : super(rawData);
@ -55,15 +55,6 @@ class LightEntity extends Entity {
}
}
List<String> _getEffectList() {
if (attributes["effect_list"] != null) {
List<String> result = (attributes["effect_list"] as List).cast<String>();
return result;
} else {
return null;
}
}
@override
Widget _buildStatePart(BuildContext context) {
return SwitchStateWidget();

View File

@ -0,0 +1,12 @@
part of '../main.dart';
class LockEntity extends Entity {
LockEntity(Map rawData) : super(rawData);
bool get isLocked => state == "locked";
@override
Widget _buildStatePart(BuildContext context) {
return LockStateWidget();
}
}

View File

@ -72,6 +72,9 @@ class MediaPlayerEntity extends Entity {
MediaPlayerEntity.SUPPORT_SELECT_SOUND_MODE) ==
MediaPlayerEntity.SUPPORT_SELECT_SOUND_MODE);
List<String> get soundModeList => getStringListAttributeValue("sound_mode_list");
List<String> get sourceList => getStringListAttributeValue("source_list");
@override
Widget _buildAdditionalControlsForPage(BuildContext context) {
return MediaPlayerControls();

View File

@ -44,6 +44,9 @@ class EntityCollection {
case 'sensor': {
return SensorEntity(rawEntityData);
}
case 'lock': {
return LockEntity(rawEntityData);
}
case "automation":
case "input_boolean":
case "switch": {
@ -52,6 +55,9 @@ class EntityCollection {
case "light": {
return LightEntity(rawEntityData);
}
case "group": {
return GroupEntity(rawEntityData);
}
case "script":
case "scene": {
return ButtonEntity(rawEntityData);
@ -74,6 +80,9 @@ class EntityCollection {
case "cover": {
return CoverEntity(rawEntityData);
}
case "fan": {
return FanEntity(rawEntityData);
}
default: {
return Entity(rawEntityData);
}
@ -126,7 +135,7 @@ class EntityCollection {
List<Entity> groups = [];
List<Entity> nonGroupEntities = [];
_allEntities.forEach((id, entity){
if ((id.indexOf("group.") == 0) && (id.indexOf(".all_") == -1) && (!entity.isView)) {
if (entity.isGroup && (entity.attributes['auto'] == null || (entity.attributes['auto'] && !entity.isHidden)) && (!entity.isView)) {
groups.add(entity);
}
if (!entity.isGroup) {

View File

@ -12,7 +12,7 @@ class ModeSelectorWidget extends StatelessWidget {
ModeSelectorWidget({
Key key,
this.caption,
@required this.caption,
@required this.options,
this.value,
@required this.onChange,

View File

@ -6,27 +6,22 @@ class ModeSwitchWidget extends StatelessWidget {
final onChange;
final double captionFontSize;
final bool value;
final bool expanded;
ModeSwitchWidget({
Key key,
@required this.caption,
@required this.onChange,
this.captionFontSize,
this.value
this.value,
this.expanded: true
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(
child: Text(
"$caption",
style: TextStyle(
fontSize: captionFontSize ?? Sizes.stateFontSize
),
),
),
_buildCaption(),
Switch(
onChanged: (value) => onChange(value),
value: value ?? false,
@ -35,4 +30,19 @@ class ModeSwitchWidget extends StatelessWidget {
);
}
Widget _buildCaption() {
Widget captionWidget = Text(
"$caption",
style: TextStyle(
fontSize: captionFontSize ?? Sizes.stateFontSize
),
);
if (expanded) {
return Expanded(
child: captionWidget,
);
}
return captionWidget;
}
}

View File

@ -0,0 +1,123 @@
part of '../../main.dart';
class FanControlsWidget extends StatefulWidget {
@override
_FanControlsWidgetState createState() => _FanControlsWidgetState();
}
class _FanControlsWidgetState extends State<FanControlsWidget> {
bool _tmpOscillate;
bool _tmpDirectionForward;
bool _changedHere = false;
String _tmpSpeed;
void _resetState(FanEntity entity) {
_tmpOscillate = entity.attributes["oscillating"] ?? false;
_tmpDirectionForward = entity.attributes["direction"] == "forward";
_tmpSpeed = entity.attributes["speed"];
}
void _setOscillate(FanEntity entity, bool oscillate) {
setState(() {
_tmpOscillate = oscillate;
_changedHere = true;
eventBus.fire(new ServiceCallEvent(
"fan", "oscillate", entity.entityId,
{"oscillating": oscillate}));
});
}
void _setDirection(FanEntity entity, bool forward) {
setState(() {
_tmpDirectionForward = forward;
_changedHere = true;
eventBus.fire(new ServiceCallEvent(
"fan", "set_direction", entity.entityId,
{"direction": forward ? "forward" : "reverse"}));
});
}
void _setSpeed(FanEntity entity, String value) {
setState(() {
_tmpSpeed = value;
_changedHere = true;
eventBus.fire(new ServiceCallEvent(
"fan", "set_speed", entity.entityId,
{"speed": value}));
});
}
@override
Widget build(BuildContext context) {
final entityModel = EntityModel.of(context);
final FanEntity entity = entityModel.entityWrapper.entity;
if (!_changedHere) {
_resetState(entity);
} else {
_changedHere = false;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
_buildSpeedControl(entity),
_buildOscillateControl(entity),
_buildDirectionControl(entity)
],
);
}
Widget _buildSpeedControl(FanEntity entity) {
if (entity.supportSetSpeed && entity.speedList != null && entity.speedList.isNotEmpty) {
return ModeSelectorWidget(
onChange: (effect) => _setSpeed(entity, effect),
caption: "Speed",
options: entity.speedList,
value: _tmpSpeed
);
} else {
return Container(width: 0.0, height: 0.0);
}
}
Widget _buildOscillateControl(FanEntity entity) {
if (entity.supportOscillate) {
return ModeSwitchWidget(
onChange: (value) => _setOscillate(entity, value),
caption: "Oscillate",
value: _tmpOscillate,
expanded: false,
);
} else {
return Container(width: 0.0, height: 0.0);
}
}
Widget _buildDirectionControl(FanEntity entity) {
if (entity.supportDirection) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
IconButton(
onPressed: _tmpDirectionForward ?
() => _setDirection(entity, false) :
null,
icon: Icon(Icons.rotate_left),
),
IconButton(
onPressed: !_tmpDirectionForward ?
() => _setDirection(entity, true) :
null,
icon: Icon(Icons.rotate_right),
),
],
);
} else {
return Container(width: 0.0, height: 0.0);
}
}
}

View File

@ -79,11 +79,13 @@ class MediaPlayerWidget extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image(
image: CachedNetworkImageProvider("$homeAssistantWebHost${entity.entityPicture}"),
height: 240.0,
width: 320.0,
fit: BoxFit.contain,
Flexible(
child: Image(
image: CachedNetworkImageProvider("$homeAssistantWebHost${entity.entityPicture}"),
height: 240.0,
//width: 320.0,
fit: BoxFit.contain,
),
)
],
),
@ -255,6 +257,8 @@ class _MediaPlayerControlsState extends State<MediaPlayerControls> {
double _newVolumeLevel;
bool _changedHere = false;
String _newSoundMode;
String _newSource;
void _setVolume(double value, String entityId) {
setState(() {
@ -276,6 +280,22 @@ class _MediaPlayerControlsState extends State<MediaPlayerControls> {
eventBus.fire(ServiceCallEvent("media_player", "volume_down", entityId, null));
}
void _setSoundMode(String value, String entityId) {
setState(() {
_newSoundMode = value;
_changedHere = true;
eventBus.fire(ServiceCallEvent("media_player", "select_sound_mode", entityId, {"sound_mode": "$value"}));
});
}
void _setSource(String source, String entityId) {
setState(() {
_newSource = source;
_changedHere = true;
eventBus.fire(ServiceCallEvent("media_player", "select_source", entityId, {"source": "$source"}));
});
}
@override
Widget build(BuildContext context) {
final MediaPlayerEntity entity = EntityModel.of(context).entityWrapper.entity;
@ -347,6 +367,38 @@ class _MediaPlayerControlsState extends State<MediaPlayerControls> {
));
}
if (entity.supportSelectSoundMode && entity.soundModeList != null) {
if (!_changedHere) {
_newSoundMode = entity.attributes["sound_mode"];
} else {
_changedHere = false;
}
children.add(
ModeSelectorWidget(
options: entity.soundModeList,
caption: "Sound mode",
value: _newSoundMode,
onChange: (value) => _setSoundMode(value, entity.entityId)
)
);
}
if (entity.supportSelectSource && entity.sourceList != null) {
if (!_changedHere) {
_newSource = entity.attributes["source"];
} else {
_changedHere = false;
}
children.add(
ModeSelectorWidget(
options: entity.sourceList,
caption: "Source",
value: _newSource,
onChange: (value) => _setSource(value, entity.entityId)
)
);
}
}
return Column(
children: children,

View File

@ -10,15 +10,31 @@ class DefaultEntityContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
EntityIcon(),
Expanded(
child: EntityName(),
),
state
],
final EntityModel entityModel = EntityModel.of(context);
return InkWell(
onLongPress: () {
if (entityModel.handleTap) {
entityModel.entityWrapper.handleHold();
}
},
onTap: () {
if (entityModel.handleTap) {
entityModel.entityWrapper.handleTap();
}
},
child: Row(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
EntityIcon(),
Flexible(
fit: FlexFit.tight,
flex: 3,
child: EntityName(),
),
state
],
),
);
}
}

View File

@ -10,59 +10,14 @@ class EntityIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
final entityModel = EntityModel.of(context);
return GestureDetector(
child: Padding(
padding: padding,
child: MaterialDesignIcons.createIconWidgetFromEntityData(
entityModel.entityWrapper,
iconSize,
EntityColor.stateColor(entityModel.entityWrapper.entity.state)
),
final EntityWrapper entityWrapper = EntityModel.of(context).entityWrapper;
return Padding(
padding: padding,
child: MaterialDesignIcons.createIconWidgetFromEntityData(
entityWrapper,
iconSize,
EntityColor.stateColor(entityWrapper.entity.state)
),
onLongPress: () {
if (entityModel.handleTap) {
switch (entityModel.entityWrapper.holdAction) {
case EntityTapAction.toggle: {
eventBus.fire(
ServiceCallEvent("homeassistant", "toggle", entityModel.entityWrapper.entity.entityId, null));
break;
}
default: {
eventBus.fire(
new ShowEntityPageEvent(entityModel.entityWrapper.entity));
break;
}
}
}
},
onTap: () {
if (entityModel.handleTap) {
switch (entityModel.entityWrapper.tapAction) {
case EntityTapAction.toggle: {
eventBus.fire(
ServiceCallEvent("homeassistant", "toggle", entityModel.entityWrapper.entity.entityId, null));
break;
}
case EntityTapAction.callService: {
eventBus.fire(
ServiceCallEvent(entityModel.entityWrapper.actionService.split(".")[0], entityModel.entityWrapper.actionService.split(".")[1], null, entityModel.entityWrapper.actionServiceData));
break;
}
default: {
eventBus.fire(
new ShowEntityPageEvent(entityModel.entityWrapper.entity));
break;
}
}
}
}
);
}
}

View File

@ -12,22 +12,16 @@ class EntityName extends StatelessWidget {
@override
Widget build(BuildContext context) {
final entityModel = EntityModel.of(context);
return GestureDetector(
child: Padding(
padding: padding,
child: Text(
"${entityModel.entityWrapper.displayName}",
overflow: textOverflow,
softWrap: wordsWrap,
style: TextStyle(fontSize: fontSize),
textAlign: textAlign,
),
final EntityWrapper entityWrapper = EntityModel.of(context).entityWrapper;
return Padding(
padding: padding,
child: Text(
"${entityWrapper.displayName}",
overflow: textOverflow,
softWrap: wordsWrap,
style: TextStyle(fontSize: fontSize),
textAlign: textAlign,
),
onTap: () =>
entityModel.handleTap
? eventBus.fire(new ShowEntityPageEvent(entityModel.entityWrapper.entity))
: null,
);
}
}

View File

@ -11,6 +11,7 @@ class GlanceEntityContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
final EntityWrapper entityWrapper = EntityModel.of(context).entityWrapper;
List<Widget> result = [];
if (showName) {
result.add(EntityName(
@ -21,10 +22,12 @@ class GlanceEntityContainer extends StatelessWidget {
fontSize: Sizes.smallFontSize,
));
}
result.add(EntityIcon(
padding: EdgeInsets.all(0.0),
iconSize: Sizes.largeIconSize,
));
result.add(
EntityIcon(
padding: EdgeInsets.all(0.0),
iconSize: Sizes.iconSize,
)
);
if (showState) {
result.add(SimpleEntityState(
textAlign: TextAlign.center,
@ -32,11 +35,20 @@ class GlanceEntityContainer extends StatelessWidget {
padding: EdgeInsets.only(top: Sizes.rowPadding),
));
}
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: result,
return Center(
child: InkResponse(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: Sizes.iconSize*2),
child: Column(
mainAxisSize: MainAxisSize.min,
//mainAxisAlignment: MainAxisAlignment.start,
//crossAxisAlignment: CrossAxisAlignment.center,
children: result,
),
),
onTap: () => entityWrapper.handleTap(),
onLongPress: () => entityWrapper.handleHold(),
),
);
}
}

View File

@ -19,39 +19,34 @@ class ClimateStateWidget extends StatelessWidget {
return Padding(
padding: EdgeInsets.fromLTRB(
0.0, 0.0, Sizes.rightWidgetPadding, 0.0),
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
children: <Widget>[
Text("${entity.state}",
textAlign: TextAlign.right,
style: new TextStyle(
fontWeight: FontWeight.bold,
fontSize: Sizes.stateFontSize,
)),
Text(" $targetTemp",
textAlign: TextAlign.right,
style: new TextStyle(
fontSize: Sizes.stateFontSize,
))
],
),
entity.attributes["current_temperature"] != null ?
Text("Currently: ${entity.attributes["current_temperature"]}",
textAlign: TextAlign.right,
style: new TextStyle(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
children: <Widget>[
Text("${entity.state}",
textAlign: TextAlign.right,
style: new TextStyle(
fontWeight: FontWeight.bold,
fontSize: Sizes.stateFontSize,
color: Colors.black45)
) :
Container(height: 0.0,)
],
),
onTap: () => entityModel.handleTap
? eventBus.fire(new ShowEntityPageEvent(entity))
: null,
)),
Text(" $targetTemp",
textAlign: TextAlign.right,
style: new TextStyle(
fontSize: Sizes.stateFontSize,
))
],
),
entity.attributes["current_temperature"] != null ?
Text("Currently: ${entity.attributes["current_temperature"]}",
textAlign: TextAlign.right,
style: new TextStyle(
fontSize: Sizes.stateFontSize,
color: Colors.black45)
) :
Container(height: 0.0,)
],
));
}
}

View File

@ -0,0 +1,32 @@
part of '../../main.dart';
class LockStateWidget extends StatelessWidget {
void _lock(Entity entity) {
eventBus.fire(new ServiceCallEvent("lock", "lock", entity.entityId, null));
}
void _unlock(Entity entity) {
eventBus.fire(new ServiceCallEvent("lock", "unlock", entity.entityId, null));
}
@override
Widget build(BuildContext context) {
final entityModel = EntityModel.of(context);
final LockEntity entity = entityModel.entityWrapper.entity;
return SizedBox(
height: 34.0,
child: FlatButton(
onPressed: (() {
entity.isLocked ? _unlock(entity) : _lock(entity);
}),
child: Text(
entity.isLocked ? "UNLOCK" : "LOCK",
textAlign: TextAlign.right,
style:
new TextStyle(fontSize: Sizes.stateFontSize, color: Colors.blue),
),
)
);
}
}

View File

@ -23,6 +23,7 @@ class _SelectStateWidgetState extends State<SelectStateWidget> {
if (entity.listOptions.isNotEmpty) {
ctrl = DropdownButton<String>(
value: entity.state,
isExpanded: true,
items: entity.listOptions.map((String value) {
return new DropdownMenuItem<String>(
value: value,
@ -36,7 +37,9 @@ class _SelectStateWidgetState extends State<SelectStateWidget> {
} else {
ctrl = Text('---');
}
return Expanded(
return Flexible(
flex: 2,
fit: FlexFit.tight,
//width: Entity.INPUT_WIDTH,
child: ctrl,
);

View File

@ -12,25 +12,22 @@ class SimpleEntityState extends StatelessWidget {
Widget build(BuildContext context) {
final entityModel = EntityModel.of(context);
Widget result = Padding(
padding: padding,
child: GestureDetector(
child: Text(
"${entityModel.entityWrapper.entity.state}${entityModel.entityWrapper.entity.unitOfMeasurement}",
textAlign: textAlign,
maxLines: 4,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: new TextStyle(
fontSize: Sizes.stateFontSize,
)),
onTap: () => entityModel.handleTap
? eventBus.fire(new ShowEntityPageEvent(entityModel.entityWrapper.entity))
: null,
padding: padding,
child: Text(
"${entityModel.entityWrapper.entity.state} ${entityModel.entityWrapper.entity.unitOfMeasurement}",
textAlign: textAlign,
maxLines: 10,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: new TextStyle(
fontSize: Sizes.stateFontSize,
)
)
);
if (expanded) {
return SizedBox(
width: MediaQuery.of(context).size.width * 0.3,
return Flexible(
fit: FlexFit.tight,
flex: 2,
child: result,
);
} else {

View File

@ -1,6 +1,11 @@
part of '../../main.dart';
class SwitchStateWidget extends StatefulWidget {
final String domainForService;
const SwitchStateWidget({Key key, this.domainForService}) : super(key: key);
@override
_SwitchStateWidgetState createState() => _SwitchStateWidgetState();
}
@ -24,11 +29,17 @@ class _SwitchStateWidgetState extends State<SwitchStateWidget> {
setState(() {
newState = entity.state;
updatedHere = true;
TheLogger.debug("Timer@!!");
//TheLogger.debug("Timer@!!");
});
});
String domain;
if (widget.domainForService != null) {
domain = widget.domainForService;
} else {
domain = entity.domain;
}
eventBus.fire(new ServiceCallEvent(
entity.domain, (newValue as bool) ? "turn_on" : "turn_off", entity.entityId, null));
domain, (newValue as bool) ? "turn_on" : "turn_off", entity.entityId, null));
}
@override

View File

@ -66,7 +66,9 @@ class _TextInputStateWidgetState extends State<TextInputStateWidget> {
_tmpValue = entity.state;
}
if (entity.isTextField || entity.isPasswordField) {
return Expanded(
return Flexible(
fit: FlexFit.tight,
flex: 2,
//width: Entity.INPUT_WIDTH,
child: TextField(
focusNode: _focusNode,

View File

@ -447,15 +447,21 @@ class HomeAssistant {
} else {
if (entities.isExist(rawEntity["entity"])) {
Entity e = entities.get(rawEntity["entity"]);
String tapAction = EntityTapAction.moreInfo;
String holdAction = EntityTapAction.none;
if (card.type == CardType.glance) {
tapAction = rawEntity["tap_action"] ?? EntityTapAction.moreInfo;
holdAction = rawEntity["hold_action"] ?? EntityTapAction.none;
}
card.entities.add(
EntityWrapper(
entity: e,
displayName: rawEntity["name"],
icon: rawEntity["icon"],
tapAction: rawEntity["tap_action"] ?? EntityTapAction.moreInfo,
holdAction: rawEntity["hold_action"] ?? EntityTapAction.moreInfo,
actionService: rawEntity["service"],
actionServiceData: rawEntity["service_data"] ?? {"entity_id": e.entityId}
tapAction: tapAction,
holdAction: holdAction,
tapActionService: rawEntity["service"],
tapActionServiceData: rawEntity["service_data"] ?? {"entity_id": e.entityId}
)
);
}

View File

@ -4,7 +4,6 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:web_socket_channel/io.dart';
import 'package:progress_indicators/progress_indicators.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/widgets.dart';
import 'package:cached_network_image/cached_network_image.dart';
@ -14,6 +13,7 @@ import 'package:date_format/date_format.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_colorpicker/material_picker.dart';
import 'package:charts_flutter/flutter.dart' as charts;
import 'package:progress_indicators/progress_indicators.dart';
part 'entity_class/const.dart';
part 'entity_class/entity.class.dart';
@ -29,6 +29,9 @@ part 'entity_class/select_entity.class.dart';
part 'entity_class/other_entity.class.dart';
part 'entity_class/slider_entity.dart';
part 'entity_class/media_player_entity.class.dart';
part 'entity_class/lock_entity.class.dart';
part 'entity_class/group_entity.class.dart';
part 'entity_class/fan_entity.class.dart';
part 'entity_widgets/common/badge.dart';
part 'entity_widgets/model_widgets.dart';
part 'entity_widgets/default_entity_container.dart';
@ -57,10 +60,12 @@ part 'entity_widgets/state/climate_state.dart';
part 'entity_widgets/state/cover_state.dart';
part 'entity_widgets/state/date_time_state.dart';
part 'entity_widgets/state/button_state.dart';
part 'entity_widgets/state/lock_state.dart';
part 'entity_widgets/controls/climate_controls.dart';
part 'entity_widgets/controls/cover_controls.dart';
part 'entity_widgets/controls/light_controls.dart';
part 'entity_widgets/controls/media_player_widgets.dart';
part 'entity_widgets/controls/fan_controls.dart';
part 'settings.page.dart';
part 'home_assistant.class.dart';
part 'log.page.dart';
@ -82,7 +87,7 @@ part 'ui_widgets/card_header_widget.dart';
EventBus eventBus = new EventBus();
const String appName = "HA Client";
const appVersion = "0.3.9";
const appVersion = "0.3.10";
String homeAssistantWebHost;
@ -147,7 +152,6 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
StreamSubscription _showEntityPageSubscription;
StreamSubscription _refreshDataSubscription;
StreamSubscription _showErrorSubscription;
int _isLoading = 1;
bool _settingsLoaded = false;
bool _accountMenuExpanded = false;
bool _useLovelaceUI;
@ -158,6 +162,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
_settingsLoaded = false;
WidgetsBinding.instance.addObserver(this);
TheLogger.debug("<!!!> Creating new HomeAssistant instance");
_homeAssistant = HomeAssistant();
_settingsSubscription = eventBus.on<SettingsChangedEvent>().listen((event) {
@ -176,10 +181,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
_subscribe();
_refreshData();
}, onError: (_) {
setState(() {
_isLoading = 2;
});
_showErrorSnackBar(message: _, errorCode: 5);
_showErrorBottomBar(message: _, errorCode: 5);
});
}
@ -226,7 +228,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
if (_showEntityPageSubscription == null) {
_showEntityPageSubscription =
eventBus.on<ShowEntityPageEvent>().listen((event) {
_showEntityPage(event.entity);
_showEntityPage(event.entity.entityId);
});
}
@ -238,21 +240,17 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
if (_showErrorSubscription == null) {
_showErrorSubscription = eventBus.on<ShowErrorEvent>().listen((event){
_showErrorSnackBar(message: event.text, errorCode: event.errorCode);
_showErrorBottomBar(message: event.text, errorCode: event.errorCode);
});
}
}
_refreshData() async {
_homeAssistant.updateSettings(_webSocketApiEndpoint, _password, _authType, _useLovelaceUI);
setState(() {
_hideErrorSnackBar();
_isLoading = 1;
});
_hideBottomBar();
_showInfoBottomBar(progress: true,);
await _homeAssistant.fetch().then((result) {
setState(() {
_isLoading = 0;
});
_hideBottomBar();
}).catchError((e) {
_setErrorState(e);
});
@ -260,18 +258,15 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
}
_setErrorState(e) {
setState(() {
_isLoading = 2;
});
if (e is Error) {
TheLogger.error(e.toString());
TheLogger.error("${e.stackTrace}");
_showErrorSnackBar(
_showErrorBottomBar(
message: "There was some error",
errorCode: 13
);
} else {
_showErrorSnackBar(
_showErrorBottomBar(
message: e != null ? e["errorMessage"] ?? "$e" : "Unknown error",
errorCode: e["errorCode"] != null ? e["errorCode"] : 99
);
@ -279,14 +274,18 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
}
void _callService(String domain, String service, String entityId, Map<String, dynamic> additionalParams) {
_showInfoBottomBar(
message: "Calling $domain.$service",
duration: Duration(seconds: 3)
);
_homeAssistant.callService(domain, service, entityId, additionalParams).catchError((e) => _setErrorState(e));
}
void _showEntityPage(Entity entity) {
void _showEntityPage(String entityId) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EntityViewPage(entity: entity, homeAssistant: _homeAssistant),
builder: (context) => EntityViewPage(entityId: entityId, homeAssistant: _homeAssistant),
)
);
}
@ -303,31 +302,6 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
return result;
}
Widget _buildAppTitle() {
Row titleRow = Row(
children: [Text(_homeAssistant != null ? _homeAssistant.locationName : "")],
);
if (_isLoading == 1) {
titleRow.children.add(Padding(
child: JumpingDotsProgressIndicator(
fontSize: 26.0,
color: Colors.white,
),
padding: const EdgeInsets.fromLTRB(5.0, 0.0, 0.0, 30.0),
));
} else if (_isLoading == 2) {
titleRow.children.add(Padding(
child: Icon(
Icons.error_outline,
size: 20.0,
color: Colors.red,
),
padding: const EdgeInsets.fromLTRB(5.0, 0.0, 0.0, 0.0),
));
}
return titleRow;
}
Drawer _buildAppDrawer() {
List<Widget> menuItems = [];
menuItems.add(
@ -409,21 +383,51 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
);
}
void _hideErrorSnackBar() {
_scaffoldKey?.currentState?.hideCurrentSnackBar();
void _hideBottomBar() {
//_scaffoldKey?.currentState?.hideCurrentSnackBar();
setState(() {
_showBottomBar = false;
});
}
void _showErrorSnackBar({Key key, @required String message, @required int errorCode}) {
SnackBarAction action;
Widget _bottomBarAction;
bool _showBottomBar = false;
String _bottomBarText;
bool _bottomBarProgress;
Color _bottomBarColor;
Timer _bottomBarTimer;
void _showInfoBottomBar({String message, bool progress: false, Duration duration}) {
_bottomBarTimer?.cancel();
_bottomBarAction = Container(height: 0.0, width: 0.0,);
_bottomBarColor = Colors.grey.shade50;
setState(() {
_bottomBarText = message;
_bottomBarProgress = progress;
_showBottomBar = true;
});
if (duration != null) {
_bottomBarTimer = Timer(duration, () {
_hideBottomBar();
});
}
}
void _showErrorBottomBar({Key key, @required String message, @required int errorCode}) {
TextStyle textStyle = TextStyle(
color: Colors.blue,
fontSize: Sizes.nameFontSize
);
_bottomBarColor = Colors.red.shade100;
switch (errorCode) {
case 9:
case 11:
case 7:
case 1: {
action = SnackBarAction(
label: "Retry",
_bottomBarAction = FlatButton(
child: Text("Retry", style: textStyle),
onPressed: () {
_scaffoldKey?.currentState?.hideCurrentSnackBar();
//_scaffoldKey?.currentState?.hideCurrentSnackBar();
_refreshData();
},
);
@ -432,10 +436,10 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
case 5: {
message = "Check connection settings";
action = SnackBarAction(
label: "Open",
_bottomBarAction = FlatButton(
child: Text("Open", style: textStyle),
onPressed: () {
_scaffoldKey?.currentState?.hideCurrentSnackBar();
//_scaffoldKey?.currentState?.hideCurrentSnackBar();
Navigator.pushNamed(context, '/connection-settings');
},
);
@ -443,10 +447,10 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
}
case 6: {
action = SnackBarAction(
label: "Settings",
_bottomBarAction = FlatButton(
child: Text("Settings", style: textStyle),
onPressed: () {
_scaffoldKey?.currentState?.hideCurrentSnackBar();
//_scaffoldKey?.currentState?.hideCurrentSnackBar();
Navigator.pushNamed(context, '/connection-settings');
},
);
@ -454,10 +458,10 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
}
case 10: {
action = SnackBarAction(
label: "Refresh",
_bottomBarAction = FlatButton(
child: Text("Refresh", style: textStyle),
onPressed: () {
_scaffoldKey?.currentState?.hideCurrentSnackBar();
//_scaffoldKey?.currentState?.hideCurrentSnackBar();
_refreshData();
},
);
@ -465,10 +469,10 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
}
case 8: {
action = SnackBarAction(
label: "Reconnect",
_bottomBarAction = FlatButton(
child: Text("Reconnect", style: textStyle),
onPressed: () {
_scaffoldKey?.currentState?.hideCurrentSnackBar();
//_scaffoldKey?.currentState?.hideCurrentSnackBar();
_refreshData();
},
);
@ -476,24 +480,29 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
}
default: {
action = SnackBarAction(
label: "Reload",
_bottomBarAction = FlatButton(
child: Text("Reload", style: textStyle),
onPressed: () {
_scaffoldKey?.currentState?.hideCurrentSnackBar();
//_scaffoldKey?.currentState?.hideCurrentSnackBar();
_refreshData();
},
);
break;
}
}
_scaffoldKey.currentState.hideCurrentSnackBar();
setState(() {
_bottomBarProgress = false;
_bottomBarText = "$message (code: $errorCode)";
_showBottomBar = true;
});
/*_scaffoldKey.currentState.hideCurrentSnackBar();
_scaffoldKey.currentState.showSnackBar(
SnackBar(
content: Text("$message (code: $errorCode)"),
action: action,
duration: Duration(hours: 1),
)
);
);*/
}
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
@ -506,7 +515,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
floating: true,
pinned: true,
primary: true,
title: _buildAppTitle(),
title: Text(_homeAssistant != null ? _homeAssistant.locationName : ""),
leading: IconButton(
icon: Icon(Icons.menu),
onPressed: () {
@ -532,7 +541,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
Icon(
MaterialDesignIcons.createIconDataFromIconName("mdi:home-assistant"),
size: 100.0,
color: _isLoading == 2 ? Colors.redAccent : Colors.blue,
color: Colors.blue,
),
]
),
@ -544,12 +553,61 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
Widget bottomBar;
if (_showBottomBar) {
List<Widget> bottomBarChildren = [];
if (_bottomBarText != null) {
bottomBarChildren.add(
Padding(
padding: EdgeInsets.fromLTRB(
Sizes.leftWidgetPadding, Sizes.rowPadding, 0.0,
Sizes.rowPadding),
child: Text(
"$_bottomBarText",
textAlign: TextAlign.left,
softWrap: true,
),
)
);
}
if (_bottomBarProgress) {
bottomBarChildren.add(
CollectionScaleTransition(
children: <Widget>[
Icon(Icons.stop, size: 10.0, color: EntityColor.stateColor(EntityState.on),),
Icon(Icons.stop, size: 10.0, color: EntityColor.stateColor(EntityState.unavailable),),
Icon(Icons.stop, size: 10.0, color: EntityColor.stateColor(EntityState.off),),
],
),
);
}
if (bottomBarChildren.isNotEmpty) {
bottomBar = Container(
color: _bottomBarColor,
child: Row(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: _bottomBarProgress ? CrossAxisAlignment.center : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: bottomBarChildren,
),
),
_bottomBarAction
],
),
);
}
}
// This method is rerun every time setState is called.
if (_homeAssistant.ui == null || _homeAssistant.ui.views == null) {
return Scaffold(
key: _scaffoldKey,
primary: false,
drawer: _buildAppDrawer(),
bottomNavigationBar: bottomBar,
body: _buildScaffoldBody(true)
);
} else {
@ -557,6 +615,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
key: _scaffoldKey,
drawer: _buildAppDrawer(),
primary: false,
bottomNavigationBar: bottomBar,
body: DefaultTabController(
length: _homeAssistant.ui?.views?.length ?? 0,
child: _buildScaffoldBody(false),

View File

@ -22,6 +22,9 @@ class MaterialDesignIcons {
"cover.closed": "mdi:window-closed",
"cover.closing": "mdi:window-open",
"cover.opening": "mdi:window-open",
"lock.locked": "mdi:lock",
"lock.unlocked": "mdi:lock-open",
"fan": "mdi:fan"
};
static Map _defaultIconsByDeviceClass = {

View File

@ -97,6 +97,7 @@ class _ConnectionSettingsPageState extends State<ConnectionSettingsPage> {
],
),
body: ListView(
scrollDirection: Axis.vertical,
padding: const EdgeInsets.all(20.0),
children: <Widget>[
Text(
@ -150,8 +151,15 @@ class _ConnectionSettingsPageState extends State<ConnectionSettingsPage> {
}
),
new Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("Login with access token (HA >= 0.78.0)"),
Flexible(
child: Text(
"Login with access token (HA >= 0.78.0)",
softWrap: true,
maxLines: 2,
),
),
Switch(
value: (_newAuthType == "access_token"),
onChanged: (value) {

View File

@ -23,37 +23,37 @@ class HACard {
Widget build(BuildContext context) {
switch (type) {
case "entities": {
case CardType.entities: {
return EntitiesCardWidget(
card: this,
);
}
case "glance": {
case CardType.glance: {
return GlanceCardWidget(
card: this,
);
}
case "media-control": {
case CardType.mediaControl: {
return MediaControlCardWidget(
card: this,
);
}
case "weather-forecast":
case "thermostat":
case "sensor":
case "plant-status":
case "picture-entity":
case "picture-elements":
case "picture":
case "map":
case "iframe":
case "gauge":
case "entity-button":
case "conditional":
case "alarm-panel": {
case CardType.weatherForecast:
case CardType.thermostat:
case CardType.sensor:
case CardType.plantStatus:
case CardType.pictureEntity:
case CardType.pictureElements:
case CardType.picture:
case CardType.map:
case CardType.iframe:
case CardType.gauge:
case CardType.entityButton:
case CardType.conditional:
case CardType.alarmPanel: {
return UnsupportedCardWidget(
card: this,
);

View File

@ -6,8 +6,8 @@ class Sizes {
static const extendedWidgetHeight = 50.0;
static const iconSize = 28.0;
static const largeIconSize = 34.0;
static const stateFontSize = 16.0;
static const nameFontSize = 16.0;
static const stateFontSize = 15.0;
static const nameFontSize = 15.0;
static const smallFontSize = 14.0;
static const largeFontSize = 24.0;
static const inputWidth = 160.0;

View File

@ -14,7 +14,7 @@ class CardHeaderWidget extends StatelessWidget {
title: Text("$name",
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis,
style: new TextStyle(fontWeight: FontWeight.bold, fontSize: 25.0)),
style: new TextStyle(fontWeight: FontWeight.bold, fontSize: Sizes.largeFontSize)),
);
} else {
result = new Container(width: 0.0, height: 0.0);

View File

@ -24,27 +24,25 @@ class GlanceCardWidget extends StatelessWidget {
Widget _buildRows(BuildContext context) {
List<Widget> result = [];
double width = MediaQuery.of(context).size.width - Sizes.leftWidgetPadding - (2*Sizes.rightWidgetPadding);
List<EntityWrapper> toShow = card.entities.where((entity) {return !entity.entity.isHidden;}).toList();
int columnsCount = toShow.length >= card.columnsCount ? card.columnsCount : toShow.length;
card.entities.forEach((EntityWrapper entity) {
if (!entity.entity.isHidden) {
result.add(
SizedBox(
width: width / columnsCount,
child: EntityModel(
entityWrapper: entity,
child: entity.entity.buildGlanceWidget(context, card.showName, card.showState),
handleTap: true
),
)
);
}
toShow.forEach((EntityWrapper entity) {
result.add(
FractionallySizedBox(
widthFactor: 1/columnsCount,
child: EntityModel(
entityWrapper: entity,
child: entity.entity.buildGlanceWidget(context, card.showName, card.showState),
handleTap: true
),
)
);
});
return Padding(
padding: EdgeInsets.fromLTRB(0.0, Sizes.rowPadding, 0.0, 2*Sizes.rowPadding),
child: Wrap(
alignment: WrapAlignment.spaceAround,
//alignment: WrapAlignment.spaceAround,
runSpacing: Sizes.rowPadding*2,
children: result,
),

View File

@ -1,7 +1,7 @@
name: hass_client
description: Home Assistant Android Client
version: 0.3.9+67
version: 0.3.10+74
environment:
sdk: ">=2.0.0-dev.68.0 <3.0.0"