Compare commits

...

21 Commits

Author SHA1 Message Date
6afbd37d71 892 2020-04-15 15:46:06 +03:00
0e8869878f 0.8.4 2020-04-15 12:06:06 +00:00
7c2cfe3215 MobileApp integration force update 2020-04-15 11:59:40 +00:00
c376c0e952 Minor fix 2020-04-14 21:08:10 +00:00
da5f663396 Gauge card optimizations 2020-04-14 18:39:21 +00:00
0e92418a33 Handle missed entity on Entity page 2020-04-14 18:15:28 +00:00
2eef7cfe5e Improve Horizontal and Vertical stack building 2020-04-13 19:24:37 +00:00
de4e0bfb3a Add new Button card support 2020-04-13 18:17:14 +00:00
8bf2d31e72 Bring back separate entity page 2020-04-13 18:15:14 +00:00
2125c46143 Gauge style improvements 2020-04-13 14:01:17 +00:00
5402eb84df Some default icons update 2020-04-13 13:13:49 +00:00
ad5aa0898f Set header toggle default to false 2020-04-13 13:05:59 +00:00
040d40b614 PayPal donate button 2020-04-11 19:26:25 +00:00
8e58f22c56 Merge pull request #533 from estevez-dev/pre-release/889
890
2020-04-11 21:16:12 +03:00
c91695fbe5 890 2020-04-11 21:15:08 +03:00
c43741da49 889 2020-04-11 20:28:35 +03:00
f2563a0397 Fix startup crash 2020-04-11 20:25:54 +03:00
fba4017819 0.8.3 2020-04-11 16:55:50 +00:00
5f23e108a1 Settings page and theme selection 2020-04-11 16:09:35 +00:00
68d14bd13d Fallback to MJPEG camera stream if streaming component is missing 2020-04-11 13:13:20 +00:00
022622522f Fix section text color for dark theme 2020-04-11 12:48:36 +00:00
25 changed files with 847 additions and 629 deletions

View File

@ -59,6 +59,10 @@ class CardWidget extends StatelessWidget {
return _buildEntityButtonCard(context); return _buildEntityButtonCard(context);
} }
case CardType.BUTTON: {
return _buildEntityButtonCard(context);
}
case CardType.GAUGE: { case CardType.GAUGE: {
return _buildGaugeCard(context); return _buildGaugeCard(context);
} }
@ -78,20 +82,15 @@ class CardWidget extends StatelessWidget {
case CardType.HORIZONTAL_STACK: { case CardType.HORIZONTAL_STACK: {
if (card.childCards.isNotEmpty) { if (card.childCards.isNotEmpty) {
List<Widget> children = []; List<Widget> children = [];
card.childCards.forEach((card) { children = card.childCards.map((childCard) => Flexible(
if (card.getEntitiesToShow().isNotEmpty || card.showEmpty) { fit: FlexFit.tight,
children.add( child: childCard.build(context),
Flexible( )
fit: FlexFit.tight, ).toList();
child: card.build(context),
)
);
}
});
return Row( return Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: children, children: children,
); );
} }
@ -100,12 +99,7 @@ class CardWidget extends StatelessWidget {
case CardType.VERTICAL_STACK: { case CardType.VERTICAL_STACK: {
if (card.childCards.isNotEmpty) { if (card.childCards.isNotEmpty) {
List<Widget> children = []; List<Widget> children = card.childCards.map((childCard) => childCard.build(context)).toList();
card.childCards.forEach((card) {
children.add(
card.build(context)
);
});
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,

View File

@ -25,6 +25,7 @@ class EntityButtonCardBody extends StatelessWidget {
child: FractionallySizedBox( child: FractionallySizedBox(
widthFactor: 1, widthFactor: 1,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
LayoutBuilder( LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {

View File

@ -1,6 +1,6 @@
part of '../../main.dart'; part of '../../main.dart';
class GaugeCardBody extends StatefulWidget { class GaugeCardBody extends StatelessWidget {
final int min; final int min;
final int max; final int max;
@ -8,31 +8,26 @@ class GaugeCardBody extends StatefulWidget {
GaugeCardBody({Key key, this.min, this.max, this.severity}) : super(key: key); GaugeCardBody({Key key, this.min, this.max, this.severity}) : super(key: key);
@override
_GaugeCardBodyState createState() => _GaugeCardBodyState();
}
class _GaugeCardBodyState extends State<GaugeCardBody> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
EntityWrapper entityWrapper = EntityModel.of(context).entityWrapper; EntityWrapper entityWrapper = EntityModel.of(context).entityWrapper;
double fixedValue; double fixedValue;
double value = entityWrapper.entity.doubleState; double value = entityWrapper.entity.doubleState;
if (value > widget.max) { if (value > max) {
fixedValue = widget.max.toDouble(); fixedValue = max.toDouble();
} else if (value < widget.min) { } else if (value < min) {
fixedValue = widget.min.toDouble(); fixedValue = min.toDouble();
} else { } else {
fixedValue = value; fixedValue = value;
} }
List<GaugeRange> ranges; List<GaugeRange> ranges;
if (widget.severity != null && widget.severity["green"] is int && widget.severity["red"] is int && widget.severity["yellow"] is int) { Color currentColor;
if (severity != null && severity["green"] is int && severity["red"] is int && severity["yellow"] is int) {
List<RangeContainer> rangesList = <RangeContainer>[ List<RangeContainer> rangesList = <RangeContainer>[
RangeContainer(widget.severity["green"], HAClientTheme().getGreenGaugeColor()), RangeContainer(severity["green"], HAClientTheme().getGreenGaugeColor()),
RangeContainer(widget.severity["red"], HAClientTheme().getRedGaugeColor()), RangeContainer(severity["red"], HAClientTheme().getRedGaugeColor()),
RangeContainer(widget.severity["yellow"], HAClientTheme().getYellowGaugeColor()) RangeContainer(severity["yellow"], HAClientTheme().getYellowGaugeColor())
]; ];
rangesList.sort((current, next) { rangesList.sort((current, next) {
if (current.startFrom > next.startFrom) { if (current.startFrom > next.startFrom) {
@ -43,11 +38,20 @@ class _GaugeCardBodyState extends State<GaugeCardBody> {
} }
return 0; return 0;
}); });
if (fixedValue < rangesList[1].startFrom) {
currentColor = rangesList[0].color;
} else if (fixedValue < rangesList[2].startFrom && fixedValue >= rangesList[1].startFrom) {
currentColor = rangesList[1].color;
} else {
currentColor = rangesList[2].color;
}
ranges = [ ranges = [
GaugeRange( GaugeRange(
startValue: rangesList[0].startFrom.toDouble(), startValue: rangesList[0].startFrom.toDouble(),
endValue: rangesList[1].startFrom.toDouble(), endValue: rangesList[1].startFrom.toDouble(),
color: fixedValue < rangesList[1].startFrom ? rangesList[0].color : rangesList[0].color.withOpacity(0.1), color: rangesList[0].color.withOpacity(0.1),
sizeUnit: GaugeSizeUnit.factor, sizeUnit: GaugeSizeUnit.factor,
endWidth: 0.3, endWidth: 0.3,
startWidth: 0.3 startWidth: 0.3
@ -55,15 +59,15 @@ class _GaugeCardBodyState extends State<GaugeCardBody> {
GaugeRange( GaugeRange(
startValue: rangesList[1].startFrom.toDouble(), startValue: rangesList[1].startFrom.toDouble(),
endValue: rangesList[2].startFrom.toDouble(), endValue: rangesList[2].startFrom.toDouble(),
color: (fixedValue < rangesList[2].startFrom && fixedValue >= rangesList[1].startFrom) ? rangesList[1].color : rangesList[1].color.withOpacity(0.1), color: rangesList[1].color.withOpacity(0.1),
sizeUnit: GaugeSizeUnit.factor, sizeUnit: GaugeSizeUnit.factor,
endWidth: 0.3, endWidth: 0.3,
startWidth: 0.3 startWidth: 0.3
), ),
GaugeRange( GaugeRange(
startValue: rangesList[2].startFrom.toDouble(), startValue: rangesList[2].startFrom.toDouble(),
endValue: widget.max.toDouble(), endValue: max.toDouble(),
color: fixedValue >= rangesList[2].startFrom ? rangesList[2].color : rangesList[2].color.withOpacity(0.1), color: rangesList[2].color.withOpacity(0.1),
sizeUnit: GaugeSizeUnit.factor, sizeUnit: GaugeSizeUnit.factor,
endWidth: 0.3, endWidth: 0.3,
startWidth: 0.3 startWidth: 0.3
@ -71,14 +75,15 @@ class _GaugeCardBodyState extends State<GaugeCardBody> {
]; ];
} }
if (ranges == null) { if (ranges == null) {
currentColor = Theme.of(context).primaryColorDark;
ranges = <GaugeRange>[ ranges = <GaugeRange>[
GaugeRange( GaugeRange(
startValue: widget.min.toDouble(), startValue: min.toDouble(),
endValue: widget.max.toDouble(), endValue: max.toDouble(),
color: Theme.of(context).primaryColorDark, color: Theme.of(context).primaryColorDark.withOpacity(0.1),
sizeUnit: GaugeSizeUnit.factor, sizeUnit: GaugeSizeUnit.factor,
endWidth: 0.3, endWidth: 0.3,
startWidth: 0.3 startWidth: 0.3,
) )
]; ];
} }
@ -104,12 +109,18 @@ class _GaugeCardBodyState extends State<GaugeCardBody> {
return SfRadialGauge( return SfRadialGauge(
axes: <RadialAxis>[ axes: <RadialAxis>[
RadialAxis( RadialAxis(
maximum: widget.max.toDouble(), maximum: max.toDouble(),
minimum: widget.min.toDouble(), minimum: min.toDouble(),
showLabels: false, showLabels: false,
useRangeColorForAxis: true,
showTicks: false, showTicks: false,
canScaleToFit: true, canScaleToFit: true,
ranges: ranges, ranges: ranges,
axisLineStyle: AxisLineStyle(
thickness: 0.3,
thicknessUnit: GaugeSizeUnit.factor,
color: Colors.transparent
),
annotations: <GaugeAnnotation>[ annotations: <GaugeAnnotation>[
GaugeAnnotation( GaugeAnnotation(
angle: -90, angle: -90,
@ -135,27 +146,16 @@ class _GaugeCardBodyState extends State<GaugeCardBody> {
), ),
) )
], ],
axisLineStyle: AxisLineStyle(
thickness: 0.3,
thicknessUnit: GaugeSizeUnit.factor
),
startAngle: 180, startAngle: 180,
endAngle: 0, endAngle: 0,
pointers: <GaugePointer>[ pointers: <GaugePointer>[
NeedlePointer( RangePointer(
value: fixedValue, value: fixedValue,
lengthUnit: GaugeSizeUnit.factor, sizeUnit: GaugeSizeUnit.factor,
needleLength: 0.9, width: 0.3,
needleColor: Theme.of(context).accentColor, color: currentColor,
enableAnimation: true, enableAnimation: true,
needleStartWidth: 1,
animationType: AnimationType.bounceOut, animationType: AnimationType.bounceOut,
needleEndWidth: 3,
knobStyle: KnobStyle(
sizeUnit: GaugeSizeUnit.factor,
color: Theme.of(context).buttonColor,
knobRadius: 0.1
)
) )
] ]
) )
@ -166,6 +166,7 @@ class _GaugeCardBodyState extends State<GaugeCardBody> {
), ),
); );
} }
} }
class RangeContainer { class RangeContainer {

View File

@ -113,6 +113,7 @@ class CardType {
static const IFRAME = "iframe"; static const IFRAME = "iframe";
static const GAUGE = "gauge"; static const GAUGE = "gauge";
static const ENTITY_BUTTON = "entity-button"; static const ENTITY_BUTTON = "entity-button";
static const BUTTON = "button";
static const CONDITIONAL = "conditional"; static const CONDITIONAL = "conditional";
static const ALARM_PANEL = "alarm-panel"; static const ALARM_PANEL = "alarm-panel";
static const MARKDOWN = "markdown"; static const MARKDOWN = "markdown";

View File

@ -50,25 +50,36 @@ class _CameraStreamViewState extends State<CameraStreamView> {
}); });
}) })
.catchError((e) { .catchError((e) {
_loading.completeError(e); if (e == 'start_stream_failed') {
Logger.e("[Camera Player] $e"); Logger.e("[Camera Player] Home Assistant failed starting stream. Forcing MJPEG: $e");
_loadMJPEG().then((_) {
_loading.complete();
});
} else {
_loading.completeError(e);
Logger.e("[Camera Player] Error loading stream: $e");
}
}); });
} else { } else {
_streamUrl = '${ConnectionManager().httpWebHost}/api/camera_proxy_stream/${_entity _loadMJPEG().then((_) {
.entityId}?token=${_entity.attributes['access_token']}';
_jsMessageChannelName = 'HA_${_entity.entityId.replaceAll('.', '_')}';
rootBundle.loadString('assets/html/cameraView.html').then((file) {
_webViewHtml = Uri.dataFromString(
file.replaceFirst('{{stream_url}}', _streamUrl).replaceFirst('{{message_channel}}', _jsMessageChannelName),
mimeType: 'text/html',
encoding: Encoding.getByName('utf-8')
).toString();
_loading.complete(); _loading.complete();
}); });
} }
return _loading.future; return _loading.future;
} }
Future _loadMJPEG() async {
_streamUrl = '${ConnectionManager().httpWebHost}/api/camera_proxy_stream/${_entity
.entityId}?token=${_entity.attributes['access_token']}';
_jsMessageChannelName = 'HA_${_entity.entityId.replaceAll('.', '_')}';
var file = await rootBundle.loadString('assets/html/cameraView.html');
_webViewHtml = Uri.dataFromString(
file.replaceFirst('{{stream_url}}', _streamUrl).replaceFirst('{{message_channel}}', _jsMessageChannelName),
mimeType: 'text/html',
encoding: Encoding.getByName('utf-8')
).toString();
}
Widget _buildScreen() { Widget _buildScreen() {
Widget screenWidget; Widget screenWidget;
if (!_isLoaded) { if (!_isLoaded) {

View File

@ -25,9 +25,9 @@ class DefaultEntityContainer extends StatelessWidget {
Divider(), Divider(),
Text( Text(
"${entityModel.entityWrapper.entity.displayName}", "${entityModel.entityWrapper.entity.displayName}",
style: Theme.of(context).textTheme.body1.copyWith( style: HAClientTheme().getLinkTextStyle(context).copyWith(
color: Theme.of(context).primaryColor decoration: TextDecoration.none
), )
) )
], ],
); );

View File

@ -102,14 +102,16 @@ part 'entities/alarm_control_panel/widgets/alarm_control_panel_controls.widget.d
part 'entities/vacuum/vacuum_entity.class.dart'; part 'entities/vacuum/vacuum_entity.class.dart';
part 'entities/vacuum/widgets/vacuum_controls.dart'; part 'entities/vacuum/widgets/vacuum_controls.dart';
part 'entities/vacuum/widgets/vacuum_state_button.dart'; part 'entities/vacuum/widgets/vacuum_state_button.dart';
part 'pages/settings.page.dart'; part 'pages/settings/connection_settings.part.dart';
part 'pages/purchase.page.dart'; part 'pages/purchase.page.dart';
part 'pages/widgets/product_purchase.widget.dart'; part 'pages/widgets/product_purchase.widget.dart';
part 'pages/widgets/page_loading_indicator.dart'; part 'pages/widgets/page_loading_indicator.dart';
part 'pages/widgets/page_loading_error.dart'; part 'pages/widgets/page_loading_error.dart';
part 'pages/panel.page.dart'; part 'pages/panel.page.dart';
part 'pages/main/main.page.dart'; part 'pages/main/main.page.dart';
part 'pages/integration_settings.page.dart'; part 'pages/settings/integration_settings.part.dart';
part 'pages/settings/app_settings.page.dart';
part 'pages/settings/lookandfeel_settings.part.dart';
part 'pages/zha_page.dart'; part 'pages/zha_page.dart';
part 'home_assistant.class.dart'; part 'home_assistant.class.dart';
part 'pages/log.page.dart'; part 'pages/log.page.dart';
@ -147,7 +149,7 @@ EventBus eventBus = new EventBus();
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging(); final FirebaseMessaging _firebaseMessaging = FirebaseMessaging();
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = new FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = new FlutterLocalNotificationsPlugin();
const String appName = "HA Client"; const String appName = "HA Client";
const appVersionNumber = "0.8.2"; const appVersionNumber = "0.8.4";
const appVersionAdd = ""; const appVersionAdd = "";
const appVersion = "$appVersionNumber$appVersionAdd"; const appVersion = "$appVersionNumber$appVersionAdd";
@ -166,15 +168,21 @@ void main() async {
SyncfusionLicense.registerLicense(secrets['syncfusion_license_key']); SyncfusionLicense.registerLicense(secrets['syncfusion_license_key']);
FlutterError.onError = (FlutterErrorDetails details) { FlutterError.onError = (FlutterErrorDetails details) {
Logger.e(" Caut Flutter runtime error: ${details.exception}"); Logger.e("Caut Flutter runtime error: ${details.exception}");
if (Logger.isInDebugMode) { if (Logger.isInDebugMode) {
FlutterError.dumpErrorToConsole(details); FlutterError.dumpErrorToConsole(details);
} }
Crashlytics.instance.recordFlutterError(details); Crashlytics.instance.recordFlutterError(details);
}; };
WidgetsFlutterBinding.ensureInitialized();
SharedPreferences prefs = await SharedPreferences.getInstance();
AppTheme theme = AppTheme.values[prefs.getInt('app-theme') ?? AppTheme.defaultTheme.index];
runZoned(() { runZoned(() {
runApp(new HAClientApp()); runApp(new HAClientApp(
theme: theme,
));
}, onError: (error, stack) { }, onError: (error, stack) {
_reportError(error, stack); _reportError(error, stack);
}); });
@ -182,22 +190,34 @@ void main() async {
class HAClientApp extends StatefulWidget { class HAClientApp extends StatefulWidget {
final AppTheme theme;
const HAClientApp({Key key, this.theme: AppTheme.defaultTheme}) : super(key: key);
@override @override
_HAClientAppState createState() => new _HAClientAppState(); _HAClientAppState createState() => new _HAClientAppState();
} }
class _HAClientAppState extends State<HAClientApp> { class _HAClientAppState extends State<HAClientApp> {
StreamSubscription<List<PurchaseDetails>> _subscription; StreamSubscription<List<PurchaseDetails>> _purchaseUpdateSubscription;
StreamSubscription _themeChangeSubscription;
AppTheme _currentTheme = AppTheme.defaultTheme;
@override @override
void initState() { void initState() {
InAppPurchaseConnection.enablePendingPurchases(); InAppPurchaseConnection.enablePendingPurchases();
final Stream purchaseUpdates = final Stream purchaseUpdates =
InAppPurchaseConnection.instance.purchaseUpdatedStream; InAppPurchaseConnection.instance.purchaseUpdatedStream;
_subscription = purchaseUpdates.listen((purchases) { _purchaseUpdateSubscription = purchaseUpdates.listen((purchases) {
_handlePurchaseUpdates(purchases); _handlePurchaseUpdates(purchases);
}); });
_currentTheme = widget.theme;
_themeChangeSubscription = eventBus.on<ChangeThemeEvent>().listen((event){
setState(() {
_currentTheme = event.theme;
});
});
workManager.Workmanager.initialize( workManager.Workmanager.initialize(
updateDeviceLocationIsolate, updateDeviceLocationIsolate,
isInDebugMode: false isInDebugMode: false
@ -226,14 +246,15 @@ class _HAClientAppState extends State<HAClientApp> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return new MaterialApp( return new MaterialApp(
title: appName, title: appName,
theme: HAClientTheme().lightTheme, theme: HAClientTheme().getThemeData(_currentTheme),
darkTheme: HAClientTheme().darkTheme, darkTheme: HAClientTheme().darkTheme,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
initialRoute: "/", initialRoute: "/",
routes: { routes: {
"/": (context) => MainPage(title: 'HA Client'), "/": (context) => MainPage(title: 'HA Client'),
"/connection-settings": (context) => ConnectionSettingsPage(title: "Settings"), "/app-settings": (context) => AppSettingsPage(),
"/integration-settings": (context) => IntegrationSettingsPage(title: "Integration settings"), "/connection-settings": (context) => AppSettingsPage(showSection: AppSettingsSection.connectionSettings),
"/integration-settings": (context) => AppSettingsPage(showSection: AppSettingsSection.integrationSettings),
"/putchase": (context) => PurchasePage(title: "Support app development"), "/putchase": (context) => PurchasePage(title: "Support app development"),
"/play-media": (context) => PlayMediaPage( "/play-media": (context) => PlayMediaPage(
mediaUrl: "${ModalRoute.of(context).settings.arguments != null ? (ModalRoute.of(context).settings.arguments as Map)['url'] : ''}", mediaUrl: "${ModalRoute.of(context).settings.arguments != null ? (ModalRoute.of(context).settings.arguments as Map)['url'] : ''}",
@ -278,7 +299,8 @@ class _HAClientAppState extends State<HAClientApp> {
@override @override
void dispose() { void dispose() {
_subscription.cancel(); _purchaseUpdateSubscription.cancel();
_themeChangeSubscription.cancel();
super.dispose(); super.dispose();
} }
} }

View File

@ -20,6 +20,7 @@ class ConnectionManager {
String oauthUrl; String oauthUrl;
String webhookId; String webhookId;
bool settingsLoaded = false; bool settingsLoaded = false;
int appIntegrationVersion;
bool get isAuthenticated => _token != null; bool get isAuthenticated => _token != null;
StreamSubscription _socketSubscription; StreamSubscription _socketSubscription;
Duration connectTimeout = Duration(seconds: 15); Duration connectTimeout = Duration(seconds: 15);
@ -43,6 +44,7 @@ class ConnectionManager {
_domain = prefs.getString('hassio-domain'); _domain = prefs.getString('hassio-domain');
_port = prefs.getString('hassio-port'); _port = prefs.getString('hassio-port');
webhookId = prefs.getString('app-webhook-id'); webhookId = prefs.getString('app-webhook-id');
appIntegrationVersion = prefs.getInt('app-integration-version') ?? 0;
displayHostname = "$_domain:$_port"; displayHostname = "$_domain:$_port";
_webSocketAPIEndpoint = _webSocketAPIEndpoint =
"${prefs.getString('hassio-protocol')}://$_domain:$_port/api/websocket"; "${prefs.getString('hassio-protocol')}://$_domain:$_port/api/websocket";

View File

@ -2,9 +2,11 @@ part of '../main.dart';
class MobileAppIntegrationManager { class MobileAppIntegrationManager {
static const INTEGRATION_VERSION = 3;
static final _appRegistrationData = { static final _appRegistrationData = {
"app_version": "$appVersion",
"device_name": "", "device_name": "",
"app_version": "$appVersion",
"manufacturer": DeviceInfoManager().manufacturer, "manufacturer": DeviceInfoManager().manufacturer,
"model": DeviceInfoManager().model, "model": DeviceInfoManager().model,
"os_version": DeviceInfoManager().osVersion, "os_version": DeviceInfoManager().osVersion,
@ -22,6 +24,7 @@ class MobileAppIntegrationManager {
Logger.d("Mobile app was not registered yet or need to be reseted. Registering..."); Logger.d("Mobile app was not registered yet or need to be reseted. Registering...");
var registrationData = Map.from(_appRegistrationData); var registrationData = Map.from(_appRegistrationData);
registrationData.addAll({ registrationData.addAll({
"device_id": "${DeviceInfoManager().unicDeviceId}",
"app_id": "ha_client", "app_id": "ha_client",
"app_name": "$appName", "app_name": "$appName",
"os_name": DeviceInfoManager().osName, "os_name": DeviceInfoManager().osName,
@ -34,9 +37,11 @@ class MobileAppIntegrationManager {
).then((response) { ).then((response) {
Logger.d("Processing registration responce..."); Logger.d("Processing registration responce...");
var responseObject = json.decode(response); var responseObject = json.decode(response);
ConnectionManager().webhookId = responseObject["webhook_id"];
ConnectionManager().appIntegrationVersion = INTEGRATION_VERSION;
SharedPreferences.getInstance().then((prefs) { SharedPreferences.getInstance().then((prefs) {
prefs.setString("app-webhook-id", responseObject["webhook_id"]); prefs.setString("app-webhook-id", responseObject["webhook_id"]);
ConnectionManager().webhookId = responseObject["webhook_id"]; prefs.setInt('app-integration-version', INTEGRATION_VERSION);
completer.complete(); completer.complete();
eventBus.fire(ShowPopupDialogEvent( eventBus.fire(ShowPopupDialogEvent(
@ -65,18 +70,29 @@ class MobileAppIntegrationManager {
includeAuthHeader: false, includeAuthHeader: false,
data: json.encode(updateData) data: json.encode(updateData)
).then((response) { ).then((response) {
if (response == null || response.isEmpty) { var registrationData;
Logger.d("No registration data in response. MobileApp integration was removed"); try {
registrationData = json.decode(response);
} catch (e) {
registrationData = null;
}
if (registrationData == null || registrationData.isEmpty) {
Logger.d("No registration data in response. MobileApp integration was removed or broken");
_askToRegisterApp(); _askToRegisterApp();
} else { } else {
Logger.d("App registration works fine"); if (INTEGRATION_VERSION > ConnectionManager().appIntegrationVersion) {
if (showOkDialog) { Logger.d('App registration needs to be updated');
eventBus.fire(ShowPopupDialogEvent( _askToRemoveAndRegisterApp();
title: "All good", } else {
body: "HA Client integration with your Home Assistant server works fine", Logger.d('App registration works fine');
positiveText: "Nice!", if (showOkDialog) {
negativeText: "Ok" eventBus.fire(ShowPopupDialogEvent(
)); title: "All good",
body: "HA Client integration with your Home Assistant server works fine",
positiveText: "Nice!",
negativeText: "Ok"
));
}
} }
} }
completer.complete(); completer.complete();
@ -105,10 +121,22 @@ class MobileAppIntegrationManager {
} }
} }
static void _askToRemoveAndRegisterApp() {
eventBus.fire(ShowPopupDialogEvent(
title: "Mobile app integration needs to be updated",
body: "You need to update HA Client integration to continue using notifications and location tracking. Please remove 'Mobile App' integration for this device from your Home Assistant and restart Home Assistant. Then go back to HA Client to create app integration again.",
positiveText: "Ok",
negativeText: "Report an issue",
onNegative: () {
Launcher.launchURL("https://github.com/estevez-dev/ha_client/issues/new");
},
));
}
static void _askToRegisterApp() { static void _askToRegisterApp() {
eventBus.fire(ShowPopupDialogEvent( eventBus.fire(ShowPopupDialogEvent(
title: "App integration was removed", title: "App integration is broken",
body: "Looks like app integration was removed from your Home Assistant. HA Client needs to be registered on your Home Assistant server to make it possible to use notifications and other useful stuff.", body: "Looks like app integration was removed from your Home Assistant or it needs to be updated. HA Client needs to be registered on your Home Assistant server to make it possible to use notifications and location tracking. Please remove 'Mobile App' integration for this device from your Home Assistant before registering and restart Home Assistant. Then go back here.",
positiveText: "Register now", positiveText: "Register now",
negativeText: "Cancel", negativeText: "Cancel",
onPositive: () { onPositive: () {

View File

@ -14,7 +14,7 @@ class StartupUserMessagesManager {
bool _supportAppDevelopmentMessageShown; bool _supportAppDevelopmentMessageShown;
bool _whatsNewMessageShown; bool _whatsNewMessageShown;
static final _supportAppDevelopmentMessageKey = "user-message-shown-support-development_3"; static final _supportAppDevelopmentMessageKey = "user-message-shown-support-development_3";
static final _whatsNewMessageKey = "user-message-shown-whats-new-887"; static final _whatsNewMessageKey = "user-message-shown-whats-new-888";
void checkMessagesToShow() async { void checkMessagesToShow() async {
SharedPreferences prefs = await SharedPreferences.getInstance(); SharedPreferences prefs = await SharedPreferences.getInstance();

View File

@ -1,5 +1,7 @@
part of '../main.dart'; part of '../main.dart';
enum AppTheme {darkTheme, defaultTheme, haTheme}
class HAClientTheme { class HAClientTheme {
static const TextTheme textTheme = TextTheme( static const TextTheme textTheme = TextTheme(
@ -76,7 +78,23 @@ class HAClientTheme {
HAClientTheme._internal(); HAClientTheme._internal();
final ThemeData lightTheme = ThemeData.from( ThemeData getThemeData(AppTheme theme) {
switch (theme) {
case AppTheme.darkTheme:
return darkTheme;
break;
case AppTheme.defaultTheme:
return defaultTheme;
break;
case AppTheme.haTheme:
return homeAssistantTheme;
break;
default:
return defaultTheme;
}
}
final ThemeData defaultTheme = ThemeData.from(
colorScheme: ColorScheme( colorScheme: ColorScheme(
primary: Color.fromRGBO(112, 154, 193, 1), primary: Color.fromRGBO(112, 154, 193, 1),
primaryVariant: Color.fromRGBO(68, 115, 158, 1), primaryVariant: Color.fromRGBO(68, 115, 158, 1),
@ -107,6 +125,37 @@ class HAClientTheme {
) )
); );
final ThemeData homeAssistantTheme = ThemeData.from(
colorScheme: ColorScheme(
primary: Color.fromRGBO(2, 165, 238, 1),
primaryVariant: Color.fromRGBO(68, 115, 158, 1),
secondary: Color.fromRGBO(253, 216, 53, 1),
secondaryVariant: Color.fromRGBO(222, 181, 2, 1),
background: Color.fromRGBO(250, 250, 250, 1),
surface: Colors.white,
error: Colors.red,
onPrimary: Colors.white,
onSecondary: Colors.black87,
onBackground: Colors.black87,
onSurface: Colors.black87,
onError: Colors.white,
brightness: Brightness.light
),
textTheme: ThemeData.light().textTheme.copyWith(
display1: textTheme.display1.copyWith(color: Colors.black54),
display2: textTheme.display2.copyWith(color: Colors.redAccent),
headline: textTheme.headline.copyWith(color: Colors.black87),
title: textTheme.title.copyWith(color: Colors.black87),
subhead: textTheme.subhead.copyWith(color: Colors.black54),
body1: textTheme.body1.copyWith(color: Colors.black87),
body2: textTheme.body2.copyWith(color: Colors.black87),
subtitle: textTheme.subtitle.copyWith(color: Colors.black45),
caption: textTheme.caption.copyWith(color: Colors.black45),
overline: textTheme.overline.copyWith(color: Colors.black26),
button: textTheme.button.copyWith(color: Colors.white),
)
);
final ThemeData darkTheme = ThemeData.from( final ThemeData darkTheme = ThemeData.from(
colorScheme: ColorScheme( colorScheme: ColorScheme(
primary: Color.fromRGBO(112, 154, 193, 1), primary: Color.fromRGBO(112, 154, 193, 1),

View File

@ -12,61 +12,52 @@ class EntityViewPage extends StatefulWidget {
class _EntityViewPageState extends State<EntityViewPage> { class _EntityViewPageState extends State<EntityViewPage> {
StreamSubscription _refreshDataSubscription; StreamSubscription _refreshDataSubscription;
StreamSubscription _stateSubscription; StreamSubscription _stateSubscription;
Entity entity; Entity _entity;
Entity forwardToMainPage;
bool _popScheduled = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_stateSubscription = eventBus.on<StateChangedEvent>().listen((event) { _stateSubscription = eventBus.on<StateChangedEvent>().listen((event) {
if (event.entityId == widget.entityId) { if (event.entityId == widget.entityId) {
entity = HomeAssistant().entities.get(widget.entityId);
Logger.d("[Entity page] State change event handled: ${event.entityId}"); Logger.d("[Entity page] State change event handled: ${event.entityId}");
setState(() {}); setState(() {
_getEntity();
});
} }
}); });
_refreshDataSubscription = eventBus.on<RefreshDataFinishedEvent>().listen((event) { _refreshDataSubscription = eventBus.on<RefreshDataFinishedEvent>().listen((event) {
entity = HomeAssistant().entities.get(widget.entityId); Logger.d("[Entity page] Refresh data event handled");
setState(() {}); setState(() {
_getEntity();
});
}); });
entity = HomeAssistant().entities.get(widget.entityId); _getEntity();
}
_getEntity() {
_entity = HomeAssistant().entities.get(widget.entityId);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget body; String entityNameToDisplay = '${(_entity?.displayName ?? widget.entityId) ?? ''}';
if (MediaQuery.of(context).size.width >= Sizes.tabletMinWidth) {
if (!_popScheduled) {
_popScheduled = true;
_popAfterBuild();
}
body = PageLoadingIndicator();
} else {
body = EntityPageLayout(entity: entity);
}
return new Scaffold( return new Scaffold(
appBar: new AppBar( appBar: new AppBar(
leading: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){ leading: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){
Navigator.pop(context); Navigator.pop(context);
}), }),
title: new Text("${entity.displayName}"), title: new Text(entityNameToDisplay),
), ),
body: body, body: _entity == null ? PageLoadingError(
errorText: 'Entity is not available $entityNameToDisplay',
) : EntityPageLayout(entity: _entity),
); );
} }
_popAfterBuild() async {
forwardToMainPage = entity;
await Future.delayed(Duration(milliseconds: 300));
Navigator.of(context).pop();
}
@override @override
void dispose(){ void dispose(){
if (_stateSubscription != null) _stateSubscription.cancel(); if (_stateSubscription != null) _stateSubscription.cancel();
if (_refreshDataSubscription != null) _refreshDataSubscription.cancel(); if (_refreshDataSubscription != null) _refreshDataSubscription.cancel();
eventBus.fire(ShowEntityPageEvent(entity: forwardToMainPage));
super.dispose(); super.dispose();
} }
} }

View File

@ -1,202 +0,0 @@
part of '../main.dart';
class IntegrationSettingsPage extends StatefulWidget {
IntegrationSettingsPage({Key key, this.title}) : super(key: key);
final String title;
@override
_IntegrationSettingsPageState createState() => new _IntegrationSettingsPageState();
}
class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
int _locationInterval = LocationManager().defaultUpdateIntervalMinutes;
bool _locationTrackingEnabled = false;
bool _wait = false;
@override
void initState() {
super.initState();
_loadSettings();
}
_loadSettings() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.reload();
SharedPreferences.getInstance().then((prefs) {
setState(() {
_locationTrackingEnabled = prefs.getBool("location-enabled") ?? false;
_locationInterval = prefs.getInt("location-interval") ?? LocationManager().defaultUpdateIntervalMinutes;
if (_locationInterval % 5 != 0) {
_locationInterval = 5 * (_locationInterval ~/ 5);
}
});
});
}
void incLocationInterval() {
if (_locationInterval < 720) {
setState(() {
_locationInterval = _locationInterval + 5;
});
}
}
void decLocationInterval() {
if (_locationInterval > 5) {
setState(() {
_locationInterval = _locationInterval - 5;
});
}
}
restart() {
eventBus.fire(ShowPopupDialogEvent(
title: "Are you sure you want to restart Home Assistant?",
body: "This will restart your Home Assistant server.",
positiveText: "Sure. Make it so",
negativeText: "What?? No!",
onPositive: () {
ConnectionManager().callService(domain: "homeassistant", service: "restart");
},
));
}
stop() {
eventBus.fire(ShowPopupDialogEvent(
title: "Are you sure you want to STOP Home Assistant?",
body: "This will STOP your Home Assistant server. It means that your web interface as well as HA Client will not work untill you'll find a way to start your server using ssh or something.",
positiveText: "Sure. Make it so",
negativeText: "What?? No!",
onPositive: () {
ConnectionManager().callService(domain: "homeassistant", service: "stop");
},
));
}
updateRegistration() {
MobileAppIntegrationManager.checkAppRegistration(showOkDialog: true);
}
resetRegistration() {
eventBus.fire(ShowPopupDialogEvent(
title: "Waaaait",
body: "If you don't whant to have duplicate integrations and entities in your HA for your current device, first you need to remove MobileApp integration from Integration settings in HA and restart server.",
positiveText: "Done it already",
negativeText: "Ok, I will",
onPositive: () {
MobileAppIntegrationManager.checkAppRegistration(showOkDialog: true, forceRegister: true);
},
));
}
_switchLocationTrackingState(bool state) async {
if (state) {
await LocationManager().updateDeviceLocation();
}
await LocationManager().setSettings(_locationTrackingEnabled, _locationInterval);
setState(() {
_wait = false;
});
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
leading: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){
Navigator.pop(context);
}),
title: new Text(widget.title),
),
body: ListView(
scrollDirection: Axis.vertical,
padding: const EdgeInsets.all(20.0),
children: <Widget>[
Text("Location tracking", style: Theme.of(context).textTheme.title),
Container(height: Sizes.rowPadding,),
InkWell(
onTap: () => Launcher.launchURLInCustomTab(context: context, url: "http://ha-client.app/docs#location-tracking"),
child: Text(
"Please read documentation!",
style: Theme.of(context).textTheme.subhead.copyWith(
color: Colors.blue,
decoration: TextDecoration.underline
)
),
),
Container(height: Sizes.rowPadding,),
Row(
children: <Widget>[
Text("Enable device location tracking"),
Switch(
value: _locationTrackingEnabled,
onChanged: _wait ? null : (value) {
setState(() {
_locationTrackingEnabled = value;
_wait = true;
});
_switchLocationTrackingState(value);
},
),
],
),
Container(height: Sizes.rowPadding,),
Text("Location update interval in minutes:"),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
//Expanded(child: Container(),),
FlatButton(
padding: EdgeInsets.all(0.0),
child: Text("-", style: Theme.of(context).textTheme.title),
onPressed: () => decLocationInterval(),
),
Text("$_locationInterval", style: Theme.of(context).textTheme.title),
FlatButton(
padding: EdgeInsets.all(0.0),
child: Text("+", style: Theme.of(context).textTheme.title),
onPressed: () => incLocationInterval(),
),
],
),
Divider(),
Text("Integration status", style: Theme.of(context).textTheme.title),
Container(height: Sizes.rowPadding,),
Text(
"${HomeAssistant().userName}'s ${DeviceInfoManager().model}, ${DeviceInfoManager().osName} ${DeviceInfoManager().osVersion}",
style: Theme.of(context).textTheme.subtitle,
),
Container(height: 6.0,),
Text("Here you can manually check if HA Client integration with your Home Assistant works fine. As mobileApp integration in Home Assistant is still in development, this is not 100% correct check."),
//Divider(),
Row(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
RaisedButton(
color: Colors.blue,
onPressed: () => updateRegistration(),
child: Text("Check integration", style: Theme.of(context).textTheme.button)
),
Container(width: 10.0,),
RaisedButton(
color: Colors.redAccent,
onPressed: () => resetRegistration(),
child: Text("Reset integration", style: Theme.of(context).textTheme.button)
)
],
),
]
),
);
}
@override
void dispose() {
LocationManager().setSettings(_locationTrackingEnabled, _locationInterval);
super.dispose();
}
}

View File

@ -25,7 +25,6 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
int _previousViewCount; int _previousViewCount;
bool _showLoginButton = false; bool _showLoginButton = false;
bool _preventAppRefresh = false; bool _preventAppRefresh = false;
Entity _entityToShow;
@override @override
void initState() { void initState() {
@ -125,9 +124,6 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
} }
await HomeAssistant().fetchData(uiOnly).then((_) { await HomeAssistant().fetchData(uiOnly).then((_) {
_hideBottomBar(); _hideBottomBar();
if (_entityToShow != null) {
_entityToShow = HomeAssistant().entities.get(_entityToShow.entityId);
}
}).catchError((e) { }).catchError((e) {
if (e is HAError) { if (e is HAError) {
_setErrorState(e); _setErrorState(e);
@ -302,12 +298,12 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
} }
void _showEntityPage(String entityId) { void _showEntityPage(String entityId) {
setState(() { Navigator.push(
_entityToShow = HomeAssistant().entities?.get(entityId); context,
if (_entityToShow != null) { MaterialPageRoute(
_mainScrollController?.jumpTo(0); builder: (context) => EntityViewPage(entityId: entityId),
} )
}); );
/*if (_entityToShow!= null && MediaQuery.of(context).size.width < Sizes.tabletMinWidth) { /*if (_entityToShow!= null && MediaQuery.of(context).size.width < Sizes.tabletMinWidth) {
Navigator.push( Navigator.push(
context, context,
@ -366,20 +362,12 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
} }
menuItems.addAll([ menuItems.addAll([
Divider(), Divider(),
ListTile(
leading: Icon(MaterialDesignIcons.getIconDataFromIconName("mdi:server-network")),
title: Text("Connection settings"),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).pushNamed('/connection-settings');
},
),
ListTile( ListTile(
leading: Icon(MaterialDesignIcons.getIconDataFromIconName("mdi:cellphone-settings-variant")), leading: Icon(MaterialDesignIcons.getIconDataFromIconName("mdi:cellphone-settings-variant")),
title: Text("Integration settings"), title: Text("App settings"),
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
Navigator.of(context).pushNamed('/integration-settings'); Navigator.of(context).pushNamed('/app-settings');
}, },
) )
]); ]);
@ -599,7 +587,6 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
} }
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>(); final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
final ScrollController _mainScrollController = ScrollController();
Widget _buildScaffoldBody(bool empty) { Widget _buildScaffoldBody(bool empty) {
List<PopupMenuItem<String>> serviceMenuItems = []; List<PopupMenuItem<String>> serviceMenuItems = [];
@ -705,28 +692,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
); );
} }
} else { } else {
if (_entityToShow != null && MediaQuery.of(context).size.width >= Sizes.tabletMinWidth) { mainScrollBody = HomeAssistant().ui.build(context, _viewsTabController);
mainScrollBody = Flex(
direction: Axis.horizontal,
children: <Widget>[
Expanded(
child: HomeAssistant().ui.build(context, _viewsTabController),
),
Container(
width: Sizes.mainPageScreenSeparatorWidth,
color: Colors.blue,
),
ConstrainedBox(
constraints: BoxConstraints.tightFor(width: Sizes.entityPageMaxWidth),
child: EntityPageLayout(entity: _entityToShow, showClose: true,),
)
],
);
} else if (_entityToShow != null) {
mainScrollBody = EntityPageLayout(entity: _entityToShow, showClose: true,);
} else {
mainScrollBody = HomeAssistant().ui.build(context, _viewsTabController);
}
} }
return NestedScrollView( return NestedScrollView(
@ -782,7 +748,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
_scaffoldKey.currentState.openDrawer(); _scaffoldKey.currentState.openDrawer();
}, },
), ),
bottom: (empty || _entityToShow != null) ? null : TabBar( bottom: empty ? null : TabBar(
controller: _viewsTabController, controller: _viewsTabController,
tabs: buildUIViewTabs(), tabs: buildUIViewTabs(),
isScrollable: true, isScrollable: true,
@ -792,7 +758,6 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
]; ];
}, },
body: mainScrollBody, body: mainScrollBody,
controller: _mainScrollController,
); );
} }
@ -857,22 +822,12 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
body: _buildScaffoldBody(true) body: _buildScaffoldBody(true)
); );
} else { } else {
return WillPopScope( return Scaffold(
child: Scaffold( key: _scaffoldKey,
key: _scaffoldKey, drawer: _buildAppDrawer(),
drawer: _buildAppDrawer(), primary: false,
primary: false, bottomNavigationBar: bottomBar,
bottomNavigationBar: bottomBar, body: _buildScaffoldBody(false)
body: _buildScaffoldBody(false)
),
onWillPop: () {
if (_entityToShow != null) {
eventBus.fire(ShowEntityPageEvent());
return Future.value(false);
} else {
return Future.value(true);
}
},
); );
} }
} }

View File

@ -62,7 +62,7 @@ class _PurchasePageState extends State<PurchasePage> {
} }
} }
Widget _buildProducts() { List<Widget> _buildProducts() {
List<Widget> productWidgets = []; List<Widget> productWidgets = [];
for (ProductDetails product in _products) { for (ProductDetails product in _products) {
productWidgets.add( productWidgets.add(
@ -72,10 +72,7 @@ class _PurchasePageState extends State<PurchasePage> {
purchased: _purchases.any((purchase) { return purchase.productID == product.id;}),) purchased: _purchases.any((purchase) { return purchase.productID == product.id;}),)
); );
} }
return ListView( return productWidgets;
scrollDirection: Axis.vertical,
children: productWidgets
);
} }
void _buyProduct(ProductDetails product) { void _buyProduct(ProductDetails product) {
@ -87,12 +84,28 @@ class _PurchasePageState extends State<PurchasePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget body; List<Widget> body;
if (!_loaded) { if (!_loaded) {
body = _error.isEmpty ? PageLoadingIndicator() : PageLoadingError(errorText: _error); body = [_error.isEmpty ? PageLoadingIndicator() : PageLoadingError(errorText: _error)];
} else { } else {
body = _buildProducts(); body = _buildProducts();
} }
body.add(
Card(
child: Container(
height: 80,
child: InkWell(
child: Image.network('https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif'),
onTap: () {
Launcher.launchURLInCustomTab(
context: context,
url: 'https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=ARWGETZD2D83Q&source=url'
);
},
)
),
)
);
return new Scaffold( return new Scaffold(
appBar: new AppBar( appBar: new AppBar(
leading: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){ leading: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){
@ -100,7 +113,10 @@ class _PurchasePageState extends State<PurchasePage> {
}), }),
title: new Text(widget.title), title: new Text(widget.title),
), ),
body: body, body: ListView(
scrollDirection: Axis.vertical,
children: body
),
); );
} }

View File

@ -1,227 +0,0 @@
part of '../main.dart';
class ConnectionSettingsPage extends StatefulWidget {
ConnectionSettingsPage({Key key, this.title}) : super(key: key);
final String title;
@override
_ConnectionSettingsPageState createState() => new _ConnectionSettingsPageState();
}
class _ConnectionSettingsPageState extends State<ConnectionSettingsPage> {
String _hassioDomain = "";
String _newHassioDomain = "";
String _hassioPort = "";
String _newHassioPort = "";
String _socketProtocol = "wss";
String _newSocketProtocol = "wss";
String _longLivedToken = "";
String _newLongLivedToken = "";
bool _useLovelace = true;
bool _newUseLovelace = true;
String oauthUrl;
bool useOAuth = false;
@override
void initState() {
super.initState();
_loadSettings();
}
_loadSettings() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
final storage = new FlutterSecureStorage();
try {
useOAuth = prefs.getBool("oauth-used") ?? true;
} catch (e) {
useOAuth = true;
}
if (!useOAuth) {
try {
_longLivedToken = _newLongLivedToken =
await storage.read(key: "hacl_llt");
} catch (e) {
_longLivedToken = _newLongLivedToken = "";
await storage.delete(key: "hacl_llt");
}
}
setState(() {
_hassioDomain = _newHassioDomain = prefs.getString("hassio-domain")?? "";
_hassioPort = _newHassioPort = prefs.getString("hassio-port") ?? "";
_socketProtocol = _newSocketProtocol = prefs.getString("hassio-protocol") ?? 'wss';
try {
_useLovelace = _newUseLovelace = prefs.getBool("use-lovelace") ?? true;
} catch (e) {
_useLovelace = _newUseLovelace = true;
}
});
}
bool _checkConfigChanged() {
return (
(_newHassioPort != _hassioPort) ||
(_newHassioDomain != _hassioDomain) ||
(_newSocketProtocol != _socketProtocol) ||
(_newUseLovelace != _useLovelace) ||
(_newLongLivedToken != _longLivedToken));
}
_saveSettings() async {
_newHassioDomain = _newHassioDomain.trim();
if (_newHassioDomain.startsWith("http") && _newHassioDomain.indexOf("//") > 0) {
_newHassioDomain.startsWith("https") ? _newSocketProtocol = "wss" : _newSocketProtocol = "ws";
_newHassioDomain = _newHassioDomain.split("//")[1];
}
_newHassioDomain = _newHassioDomain.split("/")[0];
if (_newHassioDomain.contains(":")) {
List<String> domainAndPort = _newHassioDomain.split(":");
_newHassioDomain = domainAndPort[0];
_newHassioPort = domainAndPort[1];
}
SharedPreferences prefs = await SharedPreferences.getInstance();
final storage = new FlutterSecureStorage();
if (_newLongLivedToken.isNotEmpty) {
_newLongLivedToken = _newLongLivedToken.trim();
prefs.setBool("oauth-used", false);
await storage.write(key: "hacl_llt", value: _newLongLivedToken);
} else if (!useOAuth) {
await storage.delete(key: "hacl_llt");
}
prefs.setString("hassio-domain", _newHassioDomain);
if (_newHassioPort == null || _newHassioPort.isEmpty) {
_newHassioPort = _newSocketProtocol == "wss" ? "443" : "80";
} else {
_newHassioPort = _newHassioPort.trim();
}
prefs.setString("hassio-port", _newHassioPort);
prefs.setString("hassio-protocol", _newSocketProtocol);
prefs.setString("hassio-res-protocol", _newSocketProtocol == "wss" ? "https" : "http");
prefs.setBool("use-lovelace", _newUseLovelace);
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
leading: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){
Navigator.pop(context);
}),
title: new Text(widget.title),
actions: <Widget>[
IconButton(
icon: Icon(Icons.check),
onPressed: (){
if (_checkConfigChanged()) {
Logger.d("Settings changed. Saving...");
_saveSettings().then((r) {
Navigator.pop(context);
eventBus.fire(SettingsChangedEvent(true));
});
} else {
Logger.d("Settings was not changed");
Navigator.pop(context);
}
}
)
],
),
body: ListView(
scrollDirection: Axis.vertical,
padding: const EdgeInsets.all(20.0),
children: <Widget>[
Text(
"Connection settings",
style: Theme.of(context).textTheme.headline,
),
new Row(
children: [
Text("Use ssl (HTTPS)"),
Switch(
value: (_newSocketProtocol == "wss"),
onChanged: (value) {
setState(() {
_newSocketProtocol = value ? "wss" : "ws";
});
},
)
],
),
new TextField(
decoration: InputDecoration(
labelText: "Home Assistant domain or ip address"
),
controller: TextEditingController.fromValue(TextEditingValue(text: _newHassioDomain)),
onChanged: (value) {
_newHassioDomain = value;
}
),
new TextField(
decoration: InputDecoration(
labelText: "Home Assistant port (default is 8123)"
),
controller: TextEditingController.fromValue(TextEditingValue(text: _newHassioPort)),
onChanged: (value) {
_newHassioPort = value;
}
),
new Text(
"Try ports 80 and 443 if default is not working and you don't know why.",
style: Theme.of(context).textTheme.caption,
),
Padding(
padding: EdgeInsets.only(top: 20.0),
child: Text(
"UI",
style: Theme.of(context).textTheme.headline,
),
),
new Row(
children: [
Text("Use Lovelace UI"),
Switch(
value: _newUseLovelace,
onChanged: (value) {
setState(() {
_newUseLovelace = value;
});
},
)
],
),
Text(
"Authentication settings",
style: Theme.of(context).textTheme.headline,
),
Container(height: 10.0,),
Text(
"You can leave this field blank to make app generate new long-lived token automatically by asking you to login to your Home Assistant. Use this field only if you still want to use manually generated long-lived token. Leave it blank if you don't understand what we are talking about.",
style: Theme.of(context).textTheme.body1.copyWith(
color: Colors.redAccent
),
),
new TextField(
decoration: InputDecoration(
labelText: "Long-lived token"
),
controller: TextEditingController.fromValue(TextEditingValue(text: _newLongLivedToken)),
onChanged: (value) {
_newLongLivedToken = value;
}
),
],
),
);
}
@override
void dispose() {
super.dispose();
}
}

View File

@ -0,0 +1,104 @@
part of '../../main.dart';
enum AppSettingsSection {menu, connectionSettings, integrationSettings, lookAndFeel}
class AppSettingsPage extends StatefulWidget {
final AppSettingsSection showSection;
AppSettingsPage({Key key, this.showSection: AppSettingsSection.menu}) : super(key: key);
@override
_AppSettingsPageState createState() => new _AppSettingsPageState();
}
class _AppSettingsPageState extends State<AppSettingsPage> {
var _currentSection;
@override
void initState() {
super.initState();
_currentSection = widget.showSection;
}
Widget _buildMenuItem(BuildContext context, IconData icon,String title, AppSettingsSection section) {
return ListTile(
title: Text(title, style: Theme.of(context).textTheme.subhead),
leading: Icon(icon),
trailing: Icon(Icons.keyboard_arrow_right),
onTap: () {
setState(() {
_currentSection = section;
});
},
);
}
Widget _buildMenu(BuildContext context) {
return ListView(
children: <Widget>[
_buildMenuItem(context, MaterialDesignIcons.getIconDataFromIconName('mdi:network'), 'Connection settings', AppSettingsSection.connectionSettings),
_buildMenuItem(context, MaterialDesignIcons.getIconDataFromIconName('mdi:cellphone-android'), 'Integration settings', AppSettingsSection.integrationSettings),
_buildMenuItem(context, MaterialDesignIcons.getIconDataFromIconName('mdi:brush'), 'Look and feel', AppSettingsSection.lookAndFeel),
],
);
}
@override
Widget build(BuildContext context) {
Widget section;
String title;
switch (_currentSection) {
case AppSettingsSection.menu: {
section = _buildMenu(context);
title = 'App settings';
break;
}
case AppSettingsSection.connectionSettings: {
section = ConnectionSettingsPage();
title = 'App settings - Connection';
break;
}
case AppSettingsSection.integrationSettings: {
section = IntegrationSettingsPage();
title = 'App settings - Integration';
break;
}
case AppSettingsSection.lookAndFeel: {
section = LookAndFeelSettingsPage();
title = 'App settings - Look&Feel';
break;
}
default:
title = ':(';
section = PageLoadingIndicator();
}
return WillPopScope(
child: Scaffold(
appBar: new AppBar(
leading: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){
if (_currentSection == AppSettingsSection.menu) {
Navigator.pop(context);
} else {
setState(() {
_currentSection = AppSettingsSection.menu;
});
}
}),
title: Text(title),
),
body: section
),
onWillPop: () {
if (_currentSection == AppSettingsSection.menu) {
return Future.value(true);
} else {
setState(() {
_currentSection = AppSettingsSection.menu;
});
return Future.value(false);
}
},
);
}
}

View File

@ -0,0 +1,192 @@
part of '../../main.dart';
class ConnectionSettingsPage extends StatefulWidget {
ConnectionSettingsPage({Key key, this.title}) : super(key: key);
final String title;
@override
_ConnectionSettingsPageState createState() => new _ConnectionSettingsPageState();
}
class _ConnectionSettingsPageState extends State<ConnectionSettingsPage> {
String _hassioDomain = "";
String _newHassioDomain = "";
String _hassioPort = "";
String _newHassioPort = "";
String _socketProtocol = "wss";
String _newSocketProtocol = "wss";
String _longLivedToken = "";
String _newLongLivedToken = "";
String oauthUrl;
bool useOAuth = false;
@override
void initState() {
super.initState();
_loadSettings();
}
_loadSettings() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
final storage = new FlutterSecureStorage();
try {
useOAuth = prefs.getBool("oauth-used") ?? true;
} catch (e) {
useOAuth = true;
}
if (!useOAuth) {
try {
_longLivedToken = _newLongLivedToken =
await storage.read(key: "hacl_llt");
} catch (e) {
_longLivedToken = _newLongLivedToken = "";
await storage.delete(key: "hacl_llt");
}
}
setState(() {
_hassioDomain = _newHassioDomain = prefs.getString("hassio-domain")?? "";
_hassioPort = _newHassioPort = prefs.getString("hassio-port") ?? "";
_socketProtocol = _newSocketProtocol = prefs.getString("hassio-protocol") ?? 'wss';
});
}
bool _checkConfigChanged() {
return (
(_newHassioPort != _hassioPort) ||
(_newHassioDomain != _hassioDomain) ||
(_newSocketProtocol != _socketProtocol) ||
(_newLongLivedToken != _longLivedToken));
}
_saveSettings() async {
_newHassioDomain = _newHassioDomain.trim();
if (_newHassioDomain.startsWith("http") && _newHassioDomain.indexOf("//") > 0) {
_newHassioDomain.startsWith("https") ? _newSocketProtocol = "wss" : _newSocketProtocol = "ws";
_newHassioDomain = _newHassioDomain.split("//")[1];
}
_newHassioDomain = _newHassioDomain.split("/")[0];
if (_newHassioDomain.contains(":")) {
List<String> domainAndPort = _newHassioDomain.split(":");
_newHassioDomain = domainAndPort[0];
_newHassioPort = domainAndPort[1];
}
SharedPreferences prefs = await SharedPreferences.getInstance();
final storage = new FlutterSecureStorage();
if (_newLongLivedToken.isNotEmpty) {
_newLongLivedToken = _newLongLivedToken.trim();
prefs.setBool("oauth-used", false);
await storage.write(key: "hacl_llt", value: _newLongLivedToken);
} else if (!useOAuth) {
await storage.delete(key: "hacl_llt");
}
prefs.setString("hassio-domain", _newHassioDomain);
if (_newHassioPort == null || _newHassioPort.isEmpty) {
_newHassioPort = _newSocketProtocol == "wss" ? "443" : "80";
} else {
_newHassioPort = _newHassioPort.trim();
}
prefs.setString("hassio-port", _newHassioPort);
prefs.setString("hassio-protocol", _newSocketProtocol);
prefs.setString("hassio-res-protocol", _newSocketProtocol == "wss" ? "https" : "http");
}
@override
Widget build(BuildContext context) {
return ListView(
scrollDirection: Axis.vertical,
padding: const EdgeInsets.all(20.0),
children: <Widget>[
Text(
"Connection settings",
style: Theme.of(context).textTheme.headline,
),
new Row(
children: [
Text("Use ssl (HTTPS)"),
Switch(
value: (_newSocketProtocol == "wss"),
onChanged: (value) {
setState(() {
_newSocketProtocol = value ? "wss" : "ws";
});
},
)
],
),
new TextField(
decoration: InputDecoration(
labelText: "Home Assistant domain or ip address"
),
controller: TextEditingController.fromValue(TextEditingValue(text: _newHassioDomain)),
onChanged: (value) {
_newHassioDomain = value;
}
),
new TextField(
decoration: InputDecoration(
labelText: "Home Assistant port (default is 8123)"
),
controller: TextEditingController.fromValue(TextEditingValue(text: _newHassioPort)),
onChanged: (value) {
_newHassioPort = value;
}
),
new Text(
"Try ports 80 and 443 if default is not working and you don't know why.",
style: Theme.of(context).textTheme.caption,
),
Text(
"Authentication settings",
style: Theme.of(context).textTheme.headline,
),
Container(height: 10.0,),
Text(
"You can leave this field blank to make app generate new long-lived token automatically by asking you to login to your Home Assistant. Use this field only if you still want to use manually generated long-lived token. Leave it blank if you don't understand what we are talking about.",
style: Theme.of(context).textTheme.body1.copyWith(
color: Colors.redAccent
),
),
new TextField(
decoration: InputDecoration(
labelText: "Long-lived token"
),
controller: TextEditingController.fromValue(TextEditingValue(text: _newLongLivedToken)),
onChanged: (value) {
_newLongLivedToken = value;
}
),
Container(
height: Sizes.rowPadding,
),
RaisedButton(
child: Text('Apply', style: Theme.of(context).textTheme.button),
color: Theme.of(context).primaryColorDark,
onPressed: () {
if (_checkConfigChanged()) {
Logger.d("Settings changed. Saving...");
_saveSettings().then((r) {
Navigator.pop(context);
eventBus.fire(SettingsChangedEvent(true));
});
} else {
Logger.d("Settings was not changed");
Navigator.pop(context);
}
},
)
],
);
}
@override
void dispose() {
super.dispose();
}
}

View File

@ -0,0 +1,194 @@
part of '../../main.dart';
class IntegrationSettingsPage extends StatefulWidget {
IntegrationSettingsPage({Key key, this.title}) : super(key: key);
final String title;
@override
_IntegrationSettingsPageState createState() => new _IntegrationSettingsPageState();
}
class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
int _locationInterval = LocationManager().defaultUpdateIntervalMinutes;
bool _locationTrackingEnabled = false;
bool _wait = false;
@override
void initState() {
super.initState();
_loadSettings();
}
_loadSettings() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.reload();
SharedPreferences.getInstance().then((prefs) {
setState(() {
_locationTrackingEnabled = prefs.getBool("location-enabled") ?? false;
_locationInterval = prefs.getInt("location-interval") ?? LocationManager().defaultUpdateIntervalMinutes;
if (_locationInterval % 5 != 0) {
_locationInterval = 5 * (_locationInterval ~/ 5);
}
});
});
}
void incLocationInterval() {
if (_locationInterval < 720) {
setState(() {
_locationInterval = _locationInterval + 5;
});
}
}
void decLocationInterval() {
if (_locationInterval > 5) {
setState(() {
_locationInterval = _locationInterval - 5;
});
}
}
restart() {
eventBus.fire(ShowPopupDialogEvent(
title: "Are you sure you want to restart Home Assistant?",
body: "This will restart your Home Assistant server.",
positiveText: "Sure. Make it so",
negativeText: "What?? No!",
onPositive: () {
ConnectionManager().callService(domain: "homeassistant", service: "restart");
},
));
}
stop() {
eventBus.fire(ShowPopupDialogEvent(
title: "Are you sure you want to STOP Home Assistant?",
body: "This will STOP your Home Assistant server. It means that your web interface as well as HA Client will not work untill you'll find a way to start your server using ssh or something.",
positiveText: "Sure. Make it so",
negativeText: "What?? No!",
onPositive: () {
ConnectionManager().callService(domain: "homeassistant", service: "stop");
},
));
}
updateRegistration() {
MobileAppIntegrationManager.checkAppRegistration(showOkDialog: true);
}
resetRegistration() {
eventBus.fire(ShowPopupDialogEvent(
title: "Waaaait",
body: "If you don't whant to have duplicate integrations and entities in your HA for your current device, first you need to remove MobileApp integration from Integration settings in HA and restart server.",
positiveText: "Done it already",
negativeText: "Ok, I will",
onPositive: () {
MobileAppIntegrationManager.checkAppRegistration(showOkDialog: true, forceRegister: true);
},
));
}
_switchLocationTrackingState(bool state) async {
if (state) {
await LocationManager().updateDeviceLocation();
}
await LocationManager().setSettings(_locationTrackingEnabled, _locationInterval);
setState(() {
_wait = false;
});
}
@override
Widget build(BuildContext context) {
return ListView(
scrollDirection: Axis.vertical,
padding: const EdgeInsets.all(20.0),
children: <Widget>[
Text("Location tracking", style: Theme.of(context).textTheme.title),
Container(height: Sizes.rowPadding,),
InkWell(
onTap: () => Launcher.launchURLInCustomTab(context: context, url: "http://ha-client.app/docs#location-tracking"),
child: Text(
"Please read documentation!",
style: Theme.of(context).textTheme.subhead.copyWith(
color: Colors.blue,
decoration: TextDecoration.underline
)
),
),
Container(height: Sizes.rowPadding,),
Row(
children: <Widget>[
Text("Enable device location tracking"),
Switch(
value: _locationTrackingEnabled,
onChanged: _wait ? null : (value) {
setState(() {
_locationTrackingEnabled = value;
_wait = true;
});
_switchLocationTrackingState(value);
},
),
],
),
Container(height: Sizes.rowPadding,),
Text("Location update interval in minutes:"),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
//Expanded(child: Container(),),
FlatButton(
padding: EdgeInsets.all(0.0),
child: Text("-", style: Theme.of(context).textTheme.title),
onPressed: () => decLocationInterval(),
),
Text("$_locationInterval", style: Theme.of(context).textTheme.title),
FlatButton(
padding: EdgeInsets.all(0.0),
child: Text("+", style: Theme.of(context).textTheme.title),
onPressed: () => incLocationInterval(),
),
],
),
Divider(),
Text("Integration status", style: Theme.of(context).textTheme.title),
Container(height: Sizes.rowPadding,),
Text(
"${HomeAssistant().userName}'s ${DeviceInfoManager().model}, ${DeviceInfoManager().osName} ${DeviceInfoManager().osVersion}",
style: Theme.of(context).textTheme.subtitle,
),
Container(height: 6.0,),
Text("Here you can manually check if HA Client integration with your Home Assistant works fine. As mobileApp integration in Home Assistant is still in development, this is not 100% correct check."),
//Divider(),
Row(
mainAxisSize: MainAxisSize.max,
children: <Widget>[
RaisedButton(
color: Colors.blue,
onPressed: () => updateRegistration(),
child: Text("Check integration", style: Theme.of(context).textTheme.button)
),
Container(width: 10.0,),
RaisedButton(
color: Colors.redAccent,
onPressed: () => resetRegistration(),
child: Text("Reset integration", style: Theme.of(context).textTheme.button)
)
],
),
]
);
}
@override
void dispose() {
LocationManager().setSettings(_locationTrackingEnabled, _locationInterval);
super.dispose();
}
}

View File

@ -0,0 +1,79 @@
part of '../../main.dart';
class LookAndFeelSettingsPage extends StatefulWidget {
LookAndFeelSettingsPage({Key key, this.title}) : super(key: key);
final String title;
@override
_LookAndFeelSettingsPageState createState() => new _LookAndFeelSettingsPageState();
}
class _LookAndFeelSettingsPageState extends State<LookAndFeelSettingsPage> {
AppTheme _currentTheme;
bool _changed = false;
@override
void initState() {
super.initState();
_loadSettings();
}
_loadSettings() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.reload();
SharedPreferences.getInstance().then((prefs) {
setState(() {
_currentTheme = AppTheme.values[prefs.getInt("app-theme") ?? AppTheme.defaultTheme];
});
});
}
_saveSettings(AppTheme theme) {
SharedPreferences.getInstance().then((prefs) {
prefs.setInt('app-theme', theme.index);
setState(() {
_currentTheme = theme;
eventBus.fire(ChangeThemeEvent(_currentTheme));
});
});
}
Map appThemeName = {
AppTheme.defaultTheme: 'Default',
AppTheme.haTheme: 'Home Assistant theme',
AppTheme.darkTheme: 'Dark theme'
};
@override
Widget build(BuildContext context) {
return ListView(
scrollDirection: Axis.vertical,
padding: const EdgeInsets.all(20.0),
children: <Widget>[
Text("Application theme:", style: Theme.of(context).textTheme.body2),
Container(height: Sizes.rowPadding),
DropdownButton<AppTheme>(
value: _currentTheme,
iconSize: 30.0,
isExpanded: true,
style: Theme.of(context).textTheme.title,
//hint: Text("Select ${caption.toLowerCase()}"),
items: AppTheme.values.map((value) {
return new DropdownMenuItem<AppTheme>(
value: value,
child: Text('${appThemeName[value]}'),
);
}).toList(),
onChanged: (theme) => _saveSettings(theme),
)
]
);
}
@override
void dispose() {
super.dispose();
}
}

View File

@ -24,7 +24,7 @@ class _WhatsNewPageState extends State<WhatsNewPage> {
error = ""; error = "";
}); });
http.Response response; http.Response response;
response = await http.get("http://ha-client.app/service/whats_new_0.8.2.md"); response = await http.get("http://ha-client.app/service/whats_new_0.8.3.md");
if (response.statusCode == 200) { if (response.statusCode == 200) {
setState(() { setState(() {
data = response.body; data = response.body;

View File

@ -28,6 +28,13 @@ class ReloadUIEvent {
ReloadUIEvent(); ReloadUIEvent();
} }
class ChangeThemeEvent {
final AppTheme theme;
ChangeThemeEvent(this.theme);
}
class StartAuthEvent { class StartAuthEvent {
String oauthUrl; String oauthUrl;
bool showButton; bool showButton;

View File

@ -7,9 +7,9 @@ class MaterialDesignIcons {
"binary_sensor": "mdi:checkbox-blank-circle-outline", "binary_sensor": "mdi:checkbox-blank-circle-outline",
"group": "mdi:google-circles-communities", "group": "mdi:google-circles-communities",
"sensor": "mdi:eye", "sensor": "mdi:eye",
"automation": "mdi:playlist-play", "automation": "mdi:robot",
"script": "mdi:file-document", "script": "mdi:script-text",
"input_boolean": "mdi:drawing", "input_boolean": "mdi:toggle-switch-outline",
"input_datetime": "mdi:clock", "input_datetime": "mdi:clock",
"input_number": "mdi:ray-vertex", "input_number": "mdi:ray-vertex",
"input_select": "mdi:format-list-bulleted", "input_select": "mdi:format-list-bulleted",

View File

@ -48,7 +48,7 @@ class HAView {
type: rawCardInfo['type'] ?? CardType.ENTITIES, type: rawCardInfo['type'] ?? CardType.ENTITIES,
columnsCount: rawCardInfo['columns'] ?? 4, columnsCount: rawCardInfo['columns'] ?? 4,
showName: (rawCardInfo['show_name'] ?? rawCard['show_name']) ?? true, showName: (rawCardInfo['show_name'] ?? rawCard['show_name']) ?? true,
showHeaderToggle: (rawCardInfo['show_header_toggle'] ?? rawCard['show_header_toggle']) ?? true, showHeaderToggle: (rawCardInfo['show_header_toggle'] ?? rawCard['show_header_toggle']) ?? false,
showState: (rawCardInfo['show_state'] ?? rawCard['show_state']) ?? true, showState: (rawCardInfo['show_state'] ?? rawCard['show_state']) ?? true,
showEmpty: (rawCardInfo['show_empty'] ?? rawCard['show_empty']) ?? true, showEmpty: (rawCardInfo['show_empty'] ?? rawCard['show_empty']) ?? true,
stateFilter: (rawCard['state_filter'] ?? rawCardInfo['state_filter']) ?? [], stateFilter: (rawCard['state_filter'] ?? rawCardInfo['state_filter']) ?? [],

View File

@ -1,7 +1,7 @@
name: hass_client name: hass_client
description: Home Assistant Android Client description: Home Assistant Android Client
version: 0.8.2+887 version: 0.8.4+892
environment: environment: