Compare commits

..

1 Commits

Author SHA1 Message Date
c844e21e76 WIP: Location tracking using foreground service 2020-05-12 22:18:41 +00:00
30 changed files with 539 additions and 738 deletions

View File

@ -4,11 +4,15 @@
Visit [ha-client.app](http://ha-client.app/) for more info.
Download the app from [Google Play](https://play.google.com/store/apps/details?id=com.keyboardcrumbs.haclient)
Download the app from [Google Play](https://play.google.com/apps/testing/com.keyboardcrumbs.haclient)
Discuss it on [Discord](https://discord.gg/u9vq7QE) or at [Home Assistant community](https://community.home-assistant.io/c/mobile-apps/ha-client-android)
[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/estevez-dev/ha_client)
#### Last release build status
#### Stable CI build
[![Codemagic build status](https://api.codemagic.io/apps/5da8bdab9f20ef798f7c2c65/5eaef46c513b9f9b25eb7f1a/status_badge.svg)](https://codemagic.io/apps/5da8bdab9f20ef798f7c2c65/5eaef46c513b9f9b25eb7f1a/latest_build)
#### Beta CI build
[![Codemagic build status](https://api.codemagic.io/apps/5da8bdab9f20ef798f7c2c65/5db1862025dc3f0b0288a57a/status_badge.svg)](https://codemagic.io/apps/5da8bdab9f20ef798f7c2c65/5db1862025dc3f0b0288a57a/latest_build)
#### Pre-release CI build
[![Codemagic build status](https://api.codemagic.io/apps/5da8bdab9f20ef798f7c2c65/5da8bdab9f20ef798f7c2c64/status_badge.svg)](https://codemagic.io/apps/5da8bdab9f20ef798f7c2c65/5da8bdab9f20ef798f7c2c64/latest_build)

View File

@ -5,8 +5,13 @@
android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!--
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
-->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@ -53,6 +58,17 @@
</intent-filter>
</activity>
<receiver android:name="rekab.app.background_locator.LocatorBroadcastReceiver"
android:enabled="true"
android:exported="true"/>
<service android:name="rekab.app.background_locator.LocatorService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true"/>
<service android:name="rekab.app.background_locator.IsolateHolderService"
android:permission="android.permission.FOREGROUND_SERVICE"
android:exported="true"/>
<!--
<service
android:name="io.flutter.plugins.androidalarmmanager.AlarmService"
android:permission="android.permission.BIND_JOB_SERVICE"
@ -67,5 +83,6 @@
<action android:name="android.intent.action.BOOT_COMPLETED"></action>
</intent-filter>
</receiver>
-->
</application>
</manifest>

View File

@ -10,7 +10,7 @@ class Badges extends StatelessWidget {
List<EntityWrapper> entitiesToShow = badges.getEntitiesToShow();
if (entitiesToShow.isNotEmpty) {
if (AppSettings().scrollBadges) {
if (ConnectionManager().scrollBadges) {
return ConstrainedBox(
constraints: BoxConstraints.tightFor(height: 112),
child: SingleChildScrollView(

View File

@ -35,10 +35,8 @@ class CardData {
case CardType.ALARM_PANEL:
return AlarmPanelCardData(rawData);
break;
case CardType.LIGHT:
return LightCardData(rawData);
break;
case CardType.ENTITY_BUTTON:
case CardType.LIGHT:
case CardType.BUTTON:
case CardType.PICTURE_ENTITY:
return ButtonCardData(rawData);
@ -358,39 +356,6 @@ class AlarmPanelCardData extends CardData {
}
class LightCardData extends CardData {
String name;
String icon;
@override
Widget buildCardWidget() {
return LightCard(card: this);
}
LightCardData(rawData) : super(rawData) {
//Parsing card data
name = rawData['name'];
icon = rawData['icon'] is String ? rawData['icon'] : null;
//Parsing entity
var entitiId = rawData["entity"];
if (entitiId != null && entitiId is String) {
if (HomeAssistant().entities.isExist(entitiId)) {
entities.add(EntityWrapper(
entity: HomeAssistant().entities.get(entitiId),
overrideName: name,
overrideIcon: icon,
uiAction: EntityUIAction()..tapAction = EntityUIAction.toggle
));
} else {
entities.add(EntityWrapper(entity: Entity.missed(entitiId)));
}
} else {
entities.add(EntityWrapper(entity: Entity.missed('$entitiId')));
}
}
}
class ButtonCardData extends CardData {
String name;

View File

@ -1,161 +0,0 @@
part of '../main.dart';
class LightCard extends StatefulWidget {
final LightCardData card;
LightCard({Key key, this.card}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _LightCardState();
}
}
class _LightCardState extends State<LightCard> {
double _actualBrightness;
double _newBrightness;
bool _changedHere = false;
@override
void initState() {
super.initState();
}
void _setBrightness(double value, LightEntity entity) {
setState((){
_newBrightness = value;
_changedHere = true;
});
ConnectionManager().callService(
domain: entity.domain,
service: "turn_on",
entityId: entity.entityId,
data: {"brightness": value.round()}
);
}
@override
Widget build(BuildContext context) {
EntityWrapper entityWrapper = widget.card.entity;
LightEntity entity = entityWrapper.entity;
if (entityWrapper.entity.statelessType == StatelessEntityType.missed) {
return EntityModel(
entityWrapper: widget.card.entity,
child: MissedEntityWidget(),
handleTap: false,
);
}
entityWrapper.overrideName = widget.card.name ??
entityWrapper.displayName;
entityWrapper.overrideIcon = widget.card.icon ??
entityWrapper.icon;
if (!_changedHere) {
_actualBrightness = (entity.brightness ?? 0).toDouble();
_newBrightness = _actualBrightness;
} else {
_changedHere = false;
}
Color lightColor = entity.color?.toColor();
Color color;
if (lightColor != null && lightColor != Colors.white) {
color = lightColor;
} else {
color = Theme.of(context).accentColor;
}
return CardWrapper(
padding: EdgeInsets.all(4),
child: EntityModel(
entityWrapper: entityWrapper,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
ConstrainedBox(
constraints: BoxConstraints.loose(Size(200, 200)),
child: AspectRatio(
aspectRatio: 1,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
SfRadialGauge(
axes: <RadialAxis>[
RadialAxis(
onAxisTapped: (val) {
_setBrightness(val, entity);
},
maximum: 255,
minimum: 0,
showLabels: false,
showTicks: false,
axisLineStyle: AxisLineStyle(
thickness: 0.05,
thicknessUnit: GaugeSizeUnit.factor,
color: HAClientTheme().getDisabledStateColor(context)
),
pointers: <GaugePointer>[
RangePointer(
value: _actualBrightness,
sizeUnit: GaugeSizeUnit.factor,
width: 0.05,
color: color,
enableAnimation: true,
animationType: AnimationType.bounceOut,
),
MarkerPointer(
value: _newBrightness,
markerType: MarkerType.circle,
markerHeight: 20,
markerWidth: 20,
enableDragging: true,
onValueChangeEnd: (val) {
_setBrightness(val, entity);
},
color: HAClientTheme().getColorByEntityState(entity.state, context)
//enableAnimation: true,
//animationType: AnimationType.bounceOut,
)
]
)
],
),
FractionallySizedBox(
heightFactor: 0.4,
widthFactor: 0.4,
child: AspectRatio(
aspectRatio: 1,
child: InkResponse(
onTap: () => entityWrapper.handleTap(),
onLongPress: () => entityWrapper.handleHold(),
child: FittedBox(
fit: BoxFit.contain,
child: EntityIcon(
showBadge: false,
padding: EdgeInsets.all(0)
)
)
)
)
)
],
)
)
),
EntityName(
padding: EdgeInsets.all(0),
wordsWrap: true,
maxLines: 3,
textOverflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
)
],
),
handleTap: true
)
);
}
}

View File

@ -42,7 +42,7 @@ class _CameraStreamViewState extends State<CameraStreamView> {
_jsMessageChannelName = 'HA_${_entity.entityId.replaceAll('.', '_')}';
rootBundle.loadString('assets/html/cameraLiveView.html').then((file) {
_webViewHtml = Uri.dataFromString(
file.replaceFirst('{{stream_url}}', '${AppSettings().httpWebHost}${data["url"]}').replaceFirst('{{message_channel}}', _jsMessageChannelName),
file.replaceFirst('{{stream_url}}', '${ConnectionManager().httpWebHost}${data["url"]}').replaceFirst('{{message_channel}}', _jsMessageChannelName),
mimeType: 'text/html',
encoding: Encoding.getByName('utf-8')
).toString();
@ -69,7 +69,7 @@ class _CameraStreamViewState extends State<CameraStreamView> {
}
Future _loadMJPEG() async {
_streamUrl = '${AppSettings().httpWebHost}/api/camera_proxy_stream/${_entity
_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');

View File

@ -7,9 +7,8 @@ class EntityIcon extends StatelessWidget {
final EdgeInsetsGeometry imagePadding;
final double size;
final Color color;
final bool showBadge;
const EntityIcon({Key key, this.color, this.showBadge: true, this.size: Sizes.iconSize, this.padding: const EdgeInsets.all(0.0), this.iconPadding, this.imagePadding}) : super(key: key);
const EntityIcon({Key key, this.color, this.size: Sizes.iconSize, this.padding: const EdgeInsets.all(0.0), this.iconPadding, this.imagePadding}) : super(key: key);
int getDefaultIconByEntityId(String entityId, String deviceClass, String state) {
if (entityId == null) {
@ -49,23 +48,70 @@ class EntityIcon extends StatelessWidget {
);
} else {
if (entityWrapper.entityPicture != null) {
iconWidget = ClipOval(
child: CachedNetworkImage(
imageUrl: '${entityWrapper.entityPicture}',
width: size+12,
fit: BoxFit.cover,
height: size+12,
errorWidget: (context, str, dyn) {
return Padding(
padding: iconPadding ?? padding,
child: _buildIcon(entityWrapper, iconColor)
);
},
iconWidget = Container(
height: size+12,
width: size+12,
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
fit:BoxFit.cover,
image: CachedNetworkImageProvider(
"${entityWrapper.entityPicture}"
),
)
),
);
isPicture = true;
} else {
iconWidget = _buildIcon(entityWrapper, iconColor);
String iconName = entityWrapper.icon;
int iconCode = 0;
if (iconName.length > 0) {
iconCode = MaterialDesignIcons.getIconCodeByIconName(iconName);
} else {
iconCode = getDefaultIconByEntityId(entityWrapper.entity.entityId,
entityWrapper.entity.deviceClass, entityWrapper.entity.state); //
}
if (entityWrapper.entity is LightEntity &&
(entityWrapper.entity as LightEntity).supportColor &&
(entityWrapper.entity as LightEntity).color != null &&
(entityWrapper.entity as LightEntity).color.toColor() != Colors.white
) {
Color lightColor = (entityWrapper.entity as LightEntity).color.toColor();
iconWidget = Stack(
children: <Widget>[
Icon(
IconData(iconCode, fontFamily: 'Material Design Icons'),
size: size,
color: iconColor,
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: size / 3,
height: size / 3,
decoration: BoxDecoration(
color: lightColor,
shape: BoxShape.circle,
boxShadow: <BoxShadow>[
BoxShadow(
spreadRadius: 0,
blurRadius: 0,
offset: Offset(0.3, 0.3)
)
]
),
),
)
],
);
} else {
iconWidget = Icon(
IconData(iconCode, fontFamily: 'Material Design Icons'),
size: size,
color: iconColor,
);
}
}
}
EdgeInsetsGeometry computedPadding;
@ -81,58 +127,4 @@ class EntityIcon extends StatelessWidget {
child: iconWidget,
);
}
Widget _buildIcon(EntityWrapper entityWrapper, Color iconColor) {
Widget iconWidget;
String iconName = entityWrapper.icon;
int iconCode = 0;
if (iconName.length > 0) {
iconCode = MaterialDesignIcons.getIconCodeByIconName(iconName);
} else {
iconCode = getDefaultIconByEntityId(entityWrapper.entity.entityId,
entityWrapper.entity.deviceClass, entityWrapper.entity.state); //
}
if (showBadge && entityWrapper.entity is LightEntity &&
(entityWrapper.entity as LightEntity).supportColor &&
(entityWrapper.entity as LightEntity).color != null &&
(entityWrapper.entity as LightEntity).color.toColor() != Colors.white
) {
Color lightColor = (entityWrapper.entity as LightEntity).color.toColor();
iconWidget = Stack(
children: <Widget>[
Icon(
IconData(iconCode, fontFamily: 'Material Design Icons'),
size: size,
color: iconColor,
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: size / 3,
height: size / 3,
decoration: BoxDecoration(
color: lightColor,
shape: BoxShape.circle,
boxShadow: <BoxShadow>[
BoxShadow(
spreadRadius: 0,
blurRadius: 0,
offset: Offset(0.3, 0.3)
)
]
),
),
)
],
);
} else {
iconWidget = Icon(
IconData(iconCode, fontFamily: 'Material Design Icons'),
size: size,
color: iconColor,
);
}
return iconWidget;
}
}

View File

@ -3,7 +3,7 @@ part of '../main.dart';
class EntityWrapper {
String overrideName;
String overrideIcon;
final String overrideIcon;
final bool stateColor;
EntityUIAction uiAction;
Entity entity;
@ -61,7 +61,7 @@ class EntityWrapper {
case EntityUIAction.navigate: {
if (uiAction.tapService != null && uiAction.tapService.startsWith("/")) {
//TODO handle local urls
Launcher.launchURLInBrowser('${AppSettings().httpWebHost}${uiAction.tapService}');
Launcher.launchURLInBrowser('${ConnectionManager().httpWebHost}${uiAction.tapService}');
} else {
Launcher.launchURLInBrowser(uiAction.tapService);
}
@ -101,7 +101,7 @@ class EntityWrapper {
case EntityUIAction.navigate: {
if (uiAction.holdService != null && uiAction.holdService.startsWith("/")) {
//TODO handle local urls
Launcher.launchURLInBrowser('${AppSettings().httpWebHost}${uiAction.holdService}');
Launcher.launchURLInBrowser('${ConnectionManager().httpWebHost}${uiAction.holdService}');
} else {
Launcher.launchURLInBrowser(uiAction.holdService);
}
@ -141,7 +141,7 @@ class EntityWrapper {
case EntityUIAction.navigate: {
if (uiAction.doubleTapService != null && uiAction.doubleTapService.startsWith("/")) {
//TODO handle local urls
Launcher.launchURLInBrowser('${AppSettings().httpWebHost}${uiAction.doubleTapService}');
Launcher.launchURLInBrowser('${ConnectionManager().httpWebHost}${uiAction.doubleTapService}');
} else {
Launcher.launchURLInBrowser(uiAction.doubleTapService);
}

View File

@ -134,10 +134,8 @@ class _LightControlsWidgetState extends State<LightControlsWidget> {
_tmpBrightness = value.round();
});
},
min: 1,
max: 255,
divisions: 254,
label: '${val?.toInt() ?? ''}',
min: 1.0,
max: 255.0,
onChangeEnd: (value) => _setBrightness(entity, value),
value: val,
leading: Icon(Icons.brightness_5),
@ -157,12 +155,10 @@ class _LightControlsWidgetState extends State<LightControlsWidget> {
_tmpWhiteValue = value.round();
});
},
min: 0,
max: 255,
divisions: 255,
label: '$_tmpWhiteValue',
min: 0.0,
max: 255.0,
onChangeEnd: (value) => _setWhiteValue(entity, value),
value: _tmpWhiteValue?.toDouble() ?? 0.0,
value: _tmpWhiteValue == null ? 0.0 : _tmpWhiteValue.toDouble(),
leading: Icon(MaterialDesignIcons.getIconDataFromIconName("mdi:file-word-box")),
title: "White",
);
@ -192,8 +188,6 @@ class _LightControlsWidgetState extends State<LightControlsWidget> {
onChangeEnd: (value) => _setColorTemp(entity, value),
max: entity.maxMireds,
min: entity.minMireds,
divisions: (entity.maxMireds - entity.minMireds).toInt(),
label: '$_tmpColorTemp',
onChanged: (value) {
setState(() {
_changedHere = true;

View File

@ -13,10 +13,9 @@ class UniversalSlider extends StatefulWidget {
final double max;
final double value;
final int divisions;
final String label;
final EdgeInsets padding;
const UniversalSlider({Key key, this.onChanged, this.label, this.onChangeStart, this.activeColor, this.divisions, this.onChangeEnd, this.leading, this.closing, this.title, this.min, this.max, this.value, this.padding: const EdgeInsets.fromLTRB(Sizes.leftWidgetPadding, Sizes.rowPadding, Sizes.rightWidgetPadding, 0.0)}) : super(key: key);
const UniversalSlider({Key key, this.onChanged, this.onChangeStart, this.activeColor, this.divisions, this.onChangeEnd, this.leading, this.closing, this.title, this.min, this.max, this.value, this.padding: const EdgeInsets.fromLTRB(Sizes.leftWidgetPadding, Sizes.rowPadding, Sizes.rightWidgetPadding, 0.0)}) : super(key: key);
@override
State<StatefulWidget> createState() {
@ -29,10 +28,10 @@ class UniversalSliderState extends State<UniversalSlider> {
double _value;
bool _changeStarted = false;
bool _changedHere = false;
@override
void initState() {
_value = widget.value;
super.initState();
}
@ -40,11 +39,6 @@ class UniversalSliderState extends State<UniversalSlider> {
Widget build(BuildContext context) {
List <Widget> row = [];
List <Widget> col = [];
if (!_changedHere) {
_value = widget.value;
} else {
_changedHere = false;
}
if (widget.leading != null) {
row.add(widget.leading);
}
@ -55,7 +49,6 @@ class UniversalSliderState extends State<UniversalSlider> {
min: widget.min ?? 0,
max: widget.max ?? 100,
activeColor: widget.activeColor,
label: widget.label,
onChangeStart: (value) {
_changeStarted = true;
widget.onChangeStart?.call(value);
@ -64,7 +57,6 @@ class UniversalSliderState extends State<UniversalSlider> {
onChanged: (value) {
setState(() {
_value = value;
_changedHere = true;
});
widget.onChanged?.call(value);
},
@ -72,7 +64,6 @@ class UniversalSliderState extends State<UniversalSlider> {
_changeStarted = false;
setState(() {
_value = value;
_changedHere = true;
});
Timer(Duration(milliseconds: 500), () {
if (!_changeStarted) {

View File

@ -14,7 +14,7 @@ class HomeAssistant {
HomeAssistantUI ui;
Map _instanceConfig = {};
String _userName;
String currentDashboardPath;
String _lovelaceDashbordUrl;
HSVColor savedColor;
int savedPlayerPosition;
String sendToPlayerId;
@ -29,6 +29,8 @@ class HomeAssistant {
var _rawUserInfo;
var _rawPanels;
set lovelaceDashboardUrl(String val) => _lovelaceDashbordUrl = val;
List<Panel> panels = [];
Duration fetchTimeout = Duration(seconds: 30);
@ -61,7 +63,7 @@ class HomeAssistant {
_fetchCompleter = Completer();
List<Future> futures = [];
if (!uiOnly) {
if (entities == null) entities = EntityCollection(AppSettings().httpWebHost);
if (entities == null) entities = EntityCollection(ConnectionManager().httpWebHost);
futures.add(_getStates(null));
futures.add(_getConfig(null));
futures.add(_getUserInfo(null));
@ -75,6 +77,7 @@ class HomeAssistant {
if (isComponentEnabled('mobile_app')) {
_createUI();
_fetchCompleter.complete();
if (!uiOnly) MobileAppIntegrationManager.checkAppRegistration();
} else {
_fetchCompleter.completeError(HACException("Mobile app component not found", actions: [HAErrorAction.tryAgain(), HAErrorAction(type: HAErrorActionType.URL ,title: "Help",url: "http://ha-client.app/docs#mobile-app-integration")]));
}
@ -89,7 +92,7 @@ class HomeAssistant {
SharedPreferences prefs = await SharedPreferences.getInstance();
bool cached = prefs.getBool('cached');
if (cached != null && cached) {
if (entities == null) entities = EntityCollection(AppSettings().httpWebHost);
if (entities == null) entities = EntityCollection(ConnectionManager().httpWebHost);
try {
_getStates(prefs);
if (!autoUi) {
@ -191,9 +194,9 @@ class HomeAssistant {
} else {
Completer completer = Completer();
var additionalData;
if (AppSettings().haVersion >= 107 && currentDashboardPath != HomeAssistant.DEFAULT_DASHBOARD) {
if (ConnectionManager().haVersion >= 107 && _lovelaceDashbordUrl != HomeAssistant.DEFAULT_DASHBOARD) {
additionalData = {
'url_path': currentDashboardPath
'url_path': _lovelaceDashbordUrl
};
}
ConnectionManager().sendSocketMessage(

View File

@ -1,5 +1,7 @@
import 'dart:convert';
import 'dart:async';
import 'dart:isolate';
import 'dart:ui';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
@ -23,8 +25,11 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'plugins/dynamic_multi_column_layout.dart';
import 'plugins/spoiler_card.dart';
import 'package:workmanager/workmanager.dart' as workManager;
import 'package:geolocator/geolocator.dart';
//import 'package:workmanager/workmanager.dart' as workManager;
//import 'package:geolocator/geolocator.dart';
import 'package:background_locator/background_locator.dart';
import 'package:background_locator/location_dto.dart';
import 'package:background_locator/location_settings.dart';
import 'package:battery/battery.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter_webview_plugin/flutter_webview_plugin.dart' as standaloneWebview;
@ -142,7 +147,6 @@ part 'cards/horizontal_srack_card.dart';
part 'cards/markdown_card.dart';
part 'cards/media_control_card.dart';
part 'cards/unsupported_card.dart';
part 'cards/light_card.dart';
part 'cards/error_card.dart';
part 'cards/vertical_stack_card.dart';
part 'cards/glance_card.dart';
@ -154,14 +158,15 @@ part 'pages/whats_new.page.dart';
part 'pages/fullscreen.page.dart';
part 'popups.dart';
part 'cards/badges.dart';
part 'managers/app_settings.dart';
EventBus eventBus = new EventBus();
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging();
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = new FlutterLocalNotificationsPlugin();
const String appName = 'HA Client';
const String appVersion = String.fromEnvironment('versionName', defaultValue: '0.0.0');
const whatsNewUrl = 'http://ha-client.app/service/whats_new_1.1.0.md';
const appVersionNumber = '1.0.1';
final String appVersionAdd = secrets['version_type'] ?? '';
final String appVersion = '$appVersionNumber${appVersionAdd.isNotEmpty ? '-' : ''}$appVersionAdd';
const whatsNewUrl = 'http://ha-client.app/service/whats_new_1.0.1.md';
Future<void> _reportError(dynamic error, dynamic stackTrace) async {
// Print the exception to the console.
@ -186,11 +191,12 @@ void main() async {
};
WidgetsFlutterBinding.ensureInitialized();
AppSettings().loadAppTheme();
SharedPreferences prefs = await SharedPreferences.getInstance();
AppTheme theme = AppTheme.values[prefs.getInt('app-theme') ?? AppTheme.defaultTheme.index];
runZoned(() {
runApp(new HAClientApp(
theme: AppSettings().appTheme,
theme: theme,
));
}, onError: (error, stack) {
_reportError(error, stack);
@ -213,8 +219,11 @@ class _HAClientAppState extends State<HAClientApp> {
StreamSubscription _themeChangeSubscription;
AppTheme _currentTheme = AppTheme.defaultTheme;
ReceivePort port = ReceivePort();
@override
void initState() {
InAppPurchaseConnection.enablePendingPurchases();
final Stream purchaseUpdates =
InAppPurchaseConnection.instance.purchaseUpdatedStream;
@ -227,11 +236,18 @@ class _HAClientAppState extends State<HAClientApp> {
_currentTheme = event.theme;
});
});
/*
workManager.Workmanager.initialize(
updateDeviceLocationIsolate,
isInDebugMode: false
);
*/
super.initState();
IsolateNameServer.registerPortWithName(port.sendPort, LocationManager.isolateName);
port.listen((dynamic data) {
// do something with data
});
initPlatformState();
}
void _handlePurchaseUpdates(purchase) {
@ -252,6 +268,11 @@ class _HAClientAppState extends State<HAClientApp> {
}
}
Future<void> initPlatformState() async {
await BackgroundLocator.initialize();
}
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return new MaterialApp(
@ -284,7 +305,7 @@ class _HAClientAppState extends State<HAClientApp> {
"/quick-start": (context) => QuickStartPage(),
"/haclient_zha": (context) => ZhaPage(),
"/auth": (context) => new standaloneWebview.WebviewScaffold(
url: "${AppSettings().oauthUrl}",
url: "${ConnectionManager().oauthUrl}",
appBar: new AppBar(
leading: IconButton(
icon: Icon(Icons.help),

View File

@ -1,132 +0,0 @@
part of '../main.dart';
class AppSettings {
static final AppSettings _instance = AppSettings._internal();
factory AppSettings() {
return _instance;
}
AppSettings._internal();
String mobileAppDeviceName;
String _domain;
String _port;
String displayHostname;
String webSocketAPIEndpoint;
String httpWebHost;
String longLivedToken;
String tempToken;
String oauthUrl;
String webhookId;
double haVersion;
bool scrollBadges;
int appIntegrationVersion;
AppTheme appTheme;
final int defaultLocationUpdateIntervalMinutes = 20;
Duration locationUpdateInterval;
bool locationTrackingEnabled = false;
bool get isAuthenticated => longLivedToken != null;
bool get isTempAuthenticated => tempToken != null;
loadAppTheme() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
appTheme = AppTheme.values[prefs.getInt('app-theme') ?? AppTheme.defaultTheme.index];
}
Future load(bool full) async {
if (full) {
Logger.d('Loading settings...');
SharedPreferences prefs = await SharedPreferences.getInstance();
_domain = prefs.getString('hassio-domain');
_port = prefs.getString('hassio-port');
webhookId = prefs.getString('app-webhook-id');
mobileAppDeviceName = prefs.getString('app-integration-device-name');
appIntegrationVersion = prefs.getInt('app-integration-version') ?? 0;
scrollBadges = prefs.getBool('scroll-badges') ?? true;
displayHostname = "$_domain:$_port";
webSocketAPIEndpoint =
"${prefs.getString('hassio-protocol')}://$_domain:$_port/api/websocket";
httpWebHost =
"${prefs.getString('hassio-res-protocol')}://$_domain:$_port";
locationUpdateInterval = Duration(minutes: prefs.getInt("location-interval") ??
defaultLocationUpdateIntervalMinutes);
locationTrackingEnabled = prefs.getBool("location-enabled") ?? false;
Logger.d('Done. $_domain:$_port');
try {
final storage = new FlutterSecureStorage();
longLivedToken = await storage.read(key: "hacl_llt");
Logger.d("Long-lived token read successful");
oauthUrl = "$httpWebHost/auth/authorize?client_id=${Uri.encodeComponent(
'https://ha-client.app')}&redirect_uri=${Uri
.encodeComponent(
'https://ha-client.app/service/auth_callback.html')}";
} catch (e, stacktrace) {
Logger.e("Error reading secure storage: $e", stacktrace: stacktrace);
}
}
}
Future save(Map<String, dynamic> settings) async {
if (settings != null && settings.isNotEmpty) {
SharedPreferences prefs = await SharedPreferences.getInstance();
settings.forEach((k,v) async {
if (v is String) {
await prefs.setString(k, v);
} else if (v is bool) {
await prefs.setBool(k ,v);
} else if (v is int) {
await prefs.setInt(k ,v);
} else if (v is double) {
await prefs.setDouble(k, v);
} else {
Logger.e('Unknown setting type: <$k, $v>');
}
});
}
}
Future startAuth() {
return AuthManager().start(
oauthUrl: oauthUrl
).then((token) {
Logger.d("Token from AuthManager recived");
tempToken = token;
});
}
Future clearTokens() async {
longLivedToken = null;
tempToken = null;
try {
final storage = new FlutterSecureStorage();
await storage.delete(key: "hacl_llt");
} catch(e, stacktrace) {
Logger.e("Error clearing tokens: $e", stacktrace: stacktrace);
}
}
Future saveLongLivedToken(token) async {
longLivedToken = token;
tempToken = null;
try {
final storage = new FlutterSecureStorage();
await storage.write(key: "hacl_llt", value: "$longLivedToken");
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setBool("oauth-used", true);
} catch(e, stacktrace) {
Logger.e("Error saving long-lived token: $e", stacktrace: stacktrace);
}
}
bool isNotConfigured() {
return _domain == null && _port == null && webhookId == null && mobileAppDeviceName == null;
}
bool isSomethingMissed() {
return (_domain == null) || (_port == null) || (_domain.isEmpty) || (_port.isEmpty);
}
}

View File

@ -10,11 +10,25 @@ class ConnectionManager {
ConnectionManager._internal();
String _domain;
String _port;
String displayHostname;
String _webSocketAPIEndpoint;
String httpWebHost;
String _token;
String _tempToken;
String oauthUrl;
String webhookId;
double haVersion;
bool scrollBadges;
String mobileAppDeviceName;
bool settingsLoaded = false;
int appIntegrationVersion;
bool get isAuthenticated => _token != null;
StreamSubscription _socketSubscription;
Duration connectTimeout = Duration(seconds: 15);
bool isConnected = false;
bool settingsLoaded = false;
var onStateChangeCallback;
var onLovelaceUpdatedCallback;
@ -24,27 +38,70 @@ class ConnectionManager {
int _currentMessageId = 0;
Map<String, Completer> _messageResolver = {};
Future init({bool loadSettings, bool forceReconnect: false}) {
Future init({bool loadSettings, bool forceReconnect: false}) async {
Completer completer = Completer();
AppSettings().load(loadSettings).then((_) {
Logger.d('Checking config...');
if (AppSettings().isNotConfigured()) {
Logger.d('This is first start');
bool stopInit = false;
if (loadSettings) {
Logger.d("Loading settings...");
SharedPreferences prefs = await SharedPreferences.getInstance();
_domain = prefs.getString('hassio-domain');
_port = prefs.getString('hassio-port');
webhookId = prefs.getString('app-webhook-id');
appIntegrationVersion = prefs.getInt('app-integration-version') ?? 0;
mobileAppDeviceName = prefs.getString('app-integration-device-name');
scrollBadges = prefs.getBool('scroll-badges') ?? true;
displayHostname = "$_domain:$_port";
_webSocketAPIEndpoint =
"${prefs.getString('hassio-protocol')}://$_domain:$_port/api/websocket";
httpWebHost =
"${prefs.getString('hassio-res-protocol')}://$_domain:$_port";
Logger.d('$_domain$_port');
if (_domain == null && _port == null && webhookId == null && mobileAppDeviceName == null) {
completer.completeError(HACNotSetUpException());
} else if (AppSettings().isSomethingMissed()) {
stopInit = true;
} else if ((_domain == null) || (_port == null) ||
(_domain.isEmpty) || (_port.isEmpty)) {
completer.completeError(HACException.checkConnectionSettings());
} else if (!AppSettings().isAuthenticated) {
settingsLoaded = true;
AppSettings().startAuth().then((_) {
stopInit = true;
} else {
final storage = new FlutterSecureStorage();
try {
_token = await storage.read(key: "hacl_llt");
Logger.d("Long-lived token read successful");
oauthUrl = "$httpWebHost/auth/authorize?client_id=${Uri.encodeComponent(
'https://ha-client.app')}&redirect_uri=${Uri
.encodeComponent(
'https://ha-client.app/service/auth_callback.html')}";
settingsLoaded = true;
} catch (e, stacktrace) {
completer.completeError(HACException("Error reading login details", actions: [HAErrorAction.tryAgain(type: HAErrorActionType.FULL_RELOAD), HAErrorAction.loginAgain()]));
Logger.e("Error reading secure storage: $e", stacktrace: stacktrace);
stopInit = true;
}
}
} else {
if ((_domain == null) || (_port == null) ||
(_domain.isEmpty) || (_port.isEmpty)) {
completer.completeError(HACException.checkConnectionSettings());
stopInit = true;
}
}
if (!stopInit) {
if (_token == null) {
AuthManager().start(
oauthUrl: oauthUrl
).then((token) {
Logger.d("Token from AuthManager recived");
_tempToken = token;
_doConnect(completer: completer, forceReconnect: forceReconnect);
}).catchError((e) {
completer.completeError(e);
});
} else {
settingsLoaded = true;
_doConnect(completer: completer, forceReconnect: forceReconnect);
}
});
}
return completer.future;
}
@ -86,7 +143,7 @@ class ConnectionManager {
Logger.d("Socket connecting...");
try {
_socket = IOWebSocketChannel.connect(
AppSettings().webSocketAPIEndpoint, pingInterval: Duration(seconds: 15));
_webSocketAPIEndpoint, pingInterval: Duration(seconds: 15));
_socketSubscription = _socket.stream.listen(
(message) {
isConnected = true;
@ -102,9 +159,9 @@ class ConnectionManager {
} else if (data["type"] == "auth_ok") {
String v = data["ha_version"];
if (v != null && v.isNotEmpty) {
AppSettings().haVersion = double.tryParse(v.replaceFirst('0.','')) ?? 0;
haVersion = double.tryParse(v.replaceFirst('0.','')) ?? 0;
}
Logger.d("Home assistant version: $v (${AppSettings().haVersion})");
Logger.d("Home assistant version: $v ($haVersion)");
Crashlytics.instance.setString('ha_version', v);
Logger.d("[Connection] Subscribing to events");
sendSocketMessage(
@ -117,7 +174,7 @@ class ConnectionManager {
).whenComplete((){
_messageResolver["auth"]?.complete();
_messageResolver.remove("auth");
if (AppSettings().isAuthenticated) {
if (_token != null) {
if (!connecting.isCompleted) connecting.complete();
}
});
@ -211,21 +268,21 @@ class ConnectionManager {
Future _authenticate() {
Completer completer = Completer();
if (AppSettings().isAuthenticated) {
if (_token != null) {
Logger.d( "Long-lived token exist");
Logger.d( "[Sending] ==> auth request");
sendSocketMessage(
type: "auth",
additionalData: {"access_token": "${AppSettings().longLivedToken}"},
additionalData: {"access_token": "$_token"},
auth: true
).then((_) {
completer.complete();
}).catchError((e) => completer.completeError(e));
} else if (AppSettings().isTempAuthenticated != null) {
} else if (_tempToken != null) {
Logger.d("We have temp token. Loging in...");
sendSocketMessage(
type: "auth",
additionalData: {"access_token": "${AppSettings().tempToken}"},
additionalData: {"access_token": "$_tempToken"},
auth: true
).then((_) {
Logger.d("Requesting long-lived token...");
@ -243,17 +300,35 @@ class ConnectionManager {
return completer.future;
}
Future logout() async {
Future logout() {
Logger.d("Logging out");
await _disconnect();
await AppSettings().clearTokens();
Completer completer = Completer();
_disconnect().whenComplete(() {
_token = null;
_tempToken = null;
final storage = new FlutterSecureStorage();
storage.delete(key: "hacl_llt").whenComplete((){
completer.complete();
});
});
return completer.future;
}
Future _getLongLivedToken() {
Completer completer = Completer();
sendSocketMessage(type: "auth/long_lived_access_token", additionalData: {"client_name": "HA Client app ${DateTime.now().millisecondsSinceEpoch}", "lifespan": 365}).then((data) {
Logger.d("Got long-lived token.");
AppSettings().saveLongLivedToken(data);
_token = data;
_tempToken = null;
final storage = new FlutterSecureStorage();
storage.write(key: "hacl_llt", value: "$_token").then((_) {
SharedPreferences.getInstance().then((prefs) {
prefs.setBool("oauth-used", true);
completer.complete();
});
}).catchError((e) {
throw e;
});
}).catchError((e) {
completer.completeError(HACException("Authentication error: $e", actions: [HAErrorAction.reload(title: "Retry"), HAErrorAction.loginAgain(title: "Relogin")]));
});
@ -325,11 +400,11 @@ class ConnectionManager {
DateTime now = DateTime.now();
//String endTime = formatDate(now, [yyyy, '-', mm, '-', dd, 'T', HH, ':', nn, ':', ss, z]);
String startTime = formatDate(now.subtract(Duration(hours: 24)), [yyyy, '-', mm, '-', dd, 'T', HH, ':', nn, ':', ss, z]);
String url = "${AppSettings().httpWebHost}/api/history/period/$startTime?&filter_entity_id=$entityId";
String url = "$httpWebHost/api/history/period/$startTime?&filter_entity_id=$entityId";
Logger.d("[Sending] ==> HTTP /api/history/period/$startTime?&filter_entity_id=$entityId");
http.Response historyResponse;
historyResponse = await http.get(url, headers: {
"authorization": "Bearer ${AppSettings().longLivedToken}",
"authorization": "Bearer $_token",
"Content-Type": "application/json"
});
var history = json.decode(historyResponse.body);
@ -343,14 +418,14 @@ class ConnectionManager {
Future sendHTTPPost({String endPoint, String data, String contentType: "application/json", bool includeAuthHeader: true}) async {
Completer completer = Completer();
String url = "${AppSettings().httpWebHost}$endPoint";
String url = "$httpWebHost$endPoint";
Logger.d("[Sending] ==> HTTP $endPoint");
Map<String, String> headers = {};
if (contentType != null) {
headers["Content-Type"] = contentType;
}
if (includeAuthHeader) {
headers["authorization"] = "Bearer ${AppSettings().longLivedToken}";
headers["authorization"] = "Bearer $_token";
}
http.post(
url,

View File

@ -13,53 +13,82 @@ class LocationManager {
init();
}
final int defaultUpdateIntervalMinutes = 20;
final String backgroundTaskId = "haclocationtask0";
final String backgroundTaskTag = "haclocation";
Duration _updateInterval;
bool _isRunning;
void init() async {
if (AppSettings().locationTrackingEnabled) {
await _startLocationService();
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.reload();
_updateInterval = Duration(minutes: prefs.getInt("location-interval") ??
defaultUpdateIntervalMinutes);
_isRunning = prefs.getBool("location-enabled") ?? false;
if (_isRunning) {
//await _startLocationService();
}
}
setSettings(bool enabled, int interval) async {
if (interval != AppSettings().locationUpdateInterval.inMinutes) {
await _stopLocationService();
SharedPreferences prefs = await SharedPreferences.getInstance();
if (interval != _updateInterval.inMinutes) {
prefs.setInt("location-interval", interval);
_updateInterval = Duration(minutes: interval);
if (_isRunning) {
Logger.d("Stopping location tracking...");
_isRunning = false;
await _stopLocationService();
}
}
AppSettings().save({
'location-interval': interval,
'location-enabled': enabled
});
AppSettings().locationUpdateInterval = Duration(minutes: interval);
AppSettings().locationTrackingEnabled = enabled;
if (enabled && !AppSettings().locationTrackingEnabled) {
if (enabled && !_isRunning) {
Logger.d("Starting location tracking");
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setBool("location-enabled", enabled);
_isRunning = true;
await _startLocationService();
} else if (!enabled && AppSettings().locationTrackingEnabled) {
} else if (!enabled && _isRunning) {
Logger.d("Stopping location tracking...");
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setBool("location-enabled", enabled);
_isRunning = false;
await _stopLocationService();
}
}
_startLocationService() async {
String webhookId = AppSettings().webhookId;
String httpWebHost = AppSettings().httpWebHost;
Logger.d('Starting location tracking');
BackgroundLocator.registerLocationUpdate(
locationCallback,
//optional
androidNotificationCallback: locationNotificationCallback,
settings: LocationSettings(
notificationTitle: "HA Client location tracking",
notificationMsg: "HA Client is updating your device location",
wakeLockTime: 20,
autoStop: false,
interval: 10
),
);
/*
String webhookId = ConnectionManager().webhookId;
String httpWebHost = ConnectionManager().httpWebHost;
if (webhookId != null && webhookId.isNotEmpty) {
Duration interval;
int delayFactor;
int taskCount;
Logger.d("Starting location update for every ${AppSettings().locationUpdateInterval
Logger.d("Starting location update for every ${_updateInterval
.inMinutes} minutes...");
if (AppSettings().locationUpdateInterval.inMinutes == 10) {
if (_updateInterval.inMinutes == 10) {
interval = Duration(minutes: 20);
taskCount = 2;
delayFactor = 10;
} else if (AppSettings().locationUpdateInterval.inMinutes == 5) {
} else if (_updateInterval.inMinutes == 5) {
interval = Duration(minutes: 15);
taskCount = 3;
delayFactor = 5;
} else {
interval = AppSettings().locationUpdateInterval;
interval = _updateInterval;
taskCount = 1;
delayFactor = 0;
}
@ -85,20 +114,87 @@ class LocationManager {
);
}
}
*/
}
_stopLocationService() async {
Logger.d("Canceling previous schedule if any...");
await workManager.Workmanager.cancelAll();
Logger.d('Stopping location tracking');
IsolateNameServer.removePortNameMapping(isolateName);
BackgroundLocator.unRegisterLocationUpdate();
/*Logger.d("Canceling previous schedule if any...");
await workManager.Workmanager.cancelAll();*/
}
static const String isolateName = "HAClientLocatorIsolate";
static void locationCallback(LocationDto locationDto) async {
print('[Background location] Got location: $locationDto');
sendLocationData(locationDto);
final SendPort send = IsolateNameServer.lookupPortByName(isolateName);
send?.send(locationDto);
}
static Future<void> sendLocationData(LocationDto location) async {
print('[Background location] Loading settings...');
SharedPreferences prefs = await SharedPreferences.getInstance();
String domain = prefs.getString('hassio-domain');
String port = prefs.getString('hassio-port');
String webhookId = prefs.getString('app-webhook-id');
String httpWebHost =
"${prefs.getString('hassio-res-protocol')}://$domain:$port";
if (webhookId != null && webhookId.isNotEmpty) {
String url = "$httpWebHost/api/webhook/$webhookId";
Map<String, String> headers = {};
headers["Content-Type"] = "application/json";
Map data = {
"type": "update_location",
"data": {
"gps": [],
"gps_accuracy": 0,
"battery": 100
}
};
try {
if (location.longitude != null && location.latitude != null) {
data["data"]["gps"] = [location.latitude, location.longitude];
data["data"]["gps_accuracy"] = location.accuracy;
print('[Background location] Sending...');
try {
http.Response response = await http.post(
url,
headers: headers,
body: json.encode(data)
);
if (response.statusCode >= 200 && response.statusCode < 300) {
print('[Background location] Success!');
} else {
print('[Background location] Error sending data: ${response.statusCode}');
}
} catch(e) {
print('[Background location] Error sending data: $e');
}
} else {
print('[Background location] Error. Location is null');
}
} catch (e) {
print('[Background location] Error: $e');
}
}
}
static void locationNotificationCallback() {
print('[Background location] User clicked on the notification');
}
updateDeviceLocation() async {
/*
try {
Logger.d("[Foreground location] Started");
Geolocator geolocator = Geolocator();
var battery = Battery();
String webhookId = AppSettings().webhookId;
String httpWebHost = AppSettings().httpWebHost;
String webhookId = ConnectionManager().webhookId;
String httpWebHost = ConnectionManager().httpWebHost;
if (webhookId != null && webhookId.isNotEmpty) {
Logger.d("[Foreground location] Getting battery level...");
int batteryLevel = await battery.batteryLevel;
@ -135,10 +231,12 @@ class LocationManager {
} catch (e, stack) {
Logger.e('Foreground location error: ${e.toSTring()}', stacktrace: stack);
}
*/
}
}
/*
void updateDeviceLocationIsolate() {
workManager.Workmanager.executeTask((backgroundTask, data) async {
//print("[Background $backgroundTask] Started");
@ -227,3 +325,4 @@ void updateDeviceLocationIsolate() {
return true;
});
}
*/

View File

@ -12,7 +12,7 @@ class MobileAppIntegrationManager {
"os_version": DeviceInfoManager().osVersion,
"app_data": {
"push_token": "",
"push_url": "https://us-central1-ha-client-c73c4.cloudfunctions.net/pushNotifyV2"
"push_url": "https://us-central1-ha-client-c73c4.cloudfunctions.net/sendPushNotification"
}
};
@ -25,9 +25,9 @@ class MobileAppIntegrationManager {
static Future checkAppRegistration() {
Completer completer = Completer();
_appRegistrationData["device_name"] = AppSettings().mobileAppDeviceName ?? getDefaultDeviceName();
_appRegistrationData["device_name"] = ConnectionManager().mobileAppDeviceName ?? getDefaultDeviceName();
(_appRegistrationData["app_data"] as Map)["push_token"] = "${HomeAssistant().fcmToken}";
if (AppSettings().webhookId == null) {
if (ConnectionManager().webhookId == null) {
Logger.d("Mobile app was not registered yet. Registering...");
var registrationData = Map.from(_appRegistrationData);
registrationData.addAll({
@ -36,7 +36,7 @@ class MobileAppIntegrationManager {
"os_name": DeviceInfoManager().osName,
"supports_encryption": false,
});
if (AppSettings().haVersion >= 104) {
if (ConnectionManager().haVersion >= 104) {
registrationData.addAll({
"device_id": "${DeviceInfoManager().unicDeviceId}"
});
@ -48,12 +48,12 @@ class MobileAppIntegrationManager {
).then((response) {
Logger.d("Processing registration responce...");
var responseObject = json.decode(response);
AppSettings().webhookId = responseObject["webhook_id"];
AppSettings().appIntegrationVersion = INTEGRATION_VERSION;
AppSettings().save({
'app-webhook-id': responseObject["webhook_id"],
'app-integration-version': INTEGRATION_VERSION
}).then((prefs) {
ConnectionManager().webhookId = responseObject["webhook_id"];
ConnectionManager().appIntegrationVersion = INTEGRATION_VERSION;
SharedPreferences.getInstance().then((prefs) {
prefs.setString("app-webhook-id", responseObject["webhook_id"]);
prefs.setInt('app-integration-version', INTEGRATION_VERSION);
completer.complete();
eventBus.fire(ShowPopupEvent(
popup: Popup(
@ -84,7 +84,7 @@ class MobileAppIntegrationManager {
"data": _appRegistrationData
};
ConnectionManager().sendHTTPPost(
endPoint: "/api/webhook/${AppSettings().webhookId}",
endPoint: "/api/webhook/${ConnectionManager().webhookId}",
includeAuthHeader: false,
data: json.encode(updateData)
).then((response) {
@ -98,7 +98,7 @@ class MobileAppIntegrationManager {
Logger.w("No registration data in response. MobileApp integration was removed or broken");
_askToRegisterApp();
} else {
if (INTEGRATION_VERSION > AppSettings().appIntegrationVersion) {
if (INTEGRATION_VERSION > ConnectionManager().appIntegrationVersion) {
Logger.d('App registration needs to be updated');
_askToRemoveAndRegisterApp();
} else {
@ -128,11 +128,14 @@ class MobileAppIntegrationManager {
popup: Popup(
title: "App integration is not working properly",
body: "Something wrong with HA Client integration on your Home Assistant server. Please report this issue. You can try to remove Mobile App integration from Home Assistant and restart server to fix this issue.",
positiveText: "Report issue",
negativeText: "Close",
positiveText: "Report to GitHub",
negativeText: "Report to Discord",
onPositive: () {
Launcher.launchURLInBrowser("https://github.com/estevez-dev/ha_client/issues/new");
}
},
onNegative: () {
Launcher.launchURLInBrowser("https://discord.gg/u9vq7QE");
},
)
));
}

View File

@ -11,27 +11,19 @@ class StartupUserMessagesManager {
StartupUserMessagesManager._internal();
bool _needToshowDonateMessage;
bool _supportAppDevelopmentMessageShown;
bool _whatsNewMessageShown;
static final _donateMsgTimerKey = "user-msg-donate-timer";
static final _donateMsgShownKey = "user-msg-donate-shpown";
static final _supportAppDevelopmentMessageKey = "user-message-shown-support-development_3";
static final _whatsNewMessageKey = "user-msg-whats-new-url";
void checkMessagesToShow() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.reload();
var tInt = prefs.getInt(_donateMsgTimerKey);
if (tInt == null) {
prefs.setInt(_donateMsgTimerKey, DateTime.now().millisecondsSinceEpoch);
_needToshowDonateMessage = false;
} else {
bool wasShown = prefs.getBool(_donateMsgShownKey) ?? false;
_needToshowDonateMessage = (Duration(milliseconds: DateTime.now().millisecondsSinceEpoch - tInt).inDays >= 14) && !wasShown;
}
_supportAppDevelopmentMessageShown = prefs.getBool(_supportAppDevelopmentMessageKey) ?? false;
_whatsNewMessageShown = '${prefs.getString(_whatsNewMessageKey)}' == whatsNewUrl;
if (!_whatsNewMessageShown) {
_showWhatsNewMessage();
} else if (_needToshowDonateMessage) {
} else if (!_supportAppDevelopmentMessageShown) {
_showSupportAppDevelopmentMessage();
}
}
@ -42,16 +34,16 @@ class StartupUserMessagesManager {
title: "Hi!",
body: "As you may have noticed this app contains no ads. Also all app features are available for you for free. I'm not planning to change this in nearest future, but still you can support this application development materially. There is one-time payment available as well as several subscription options. Thanks.",
positiveText: "Show options",
negativeText: "Later",
negativeText: "Cancel",
onPositive: () {
SharedPreferences.getInstance().then((prefs) {
prefs.setBool(_donateMsgShownKey, true);
prefs.setBool(_supportAppDevelopmentMessageKey, true);
eventBus.fire(ShowPageEvent(path: "/putchase"));
});
},
onNegative: () {
SharedPreferences.getInstance().then((prefs) {
prefs.setInt(_donateMsgTimerKey, DateTime.now().millisecondsSinceEpoch);
prefs.setBool(_supportAppDevelopmentMessageKey, true);
});
}
)

View File

@ -99,11 +99,10 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
_subscribe().then((_) {
ConnectionManager().init(loadSettings: true, forceReconnect: true).then((__){
SharedPreferences.getInstance().then((prefs) {
HomeAssistant().currentDashboardPath = prefs.getString('lovelace_dashboard_url') ?? HomeAssistant.DEFAULT_DASHBOARD;
HomeAssistant().lovelaceDashboardUrl = prefs.getString('lovelace_dashboard_url') ?? HomeAssistant.DEFAULT_DASHBOARD;
_fetchData(useCache: true);
LocationManager();
StartupUserMessagesManager().checkMessagesToShow();
MobileAppIntegrationManager.checkAppRegistration();
});
}, onError: (e) {
if (e is HACNotSetUpException) {
@ -247,7 +246,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
void _showOAuth() {
_preventAppRefresh = true;
Navigator.of(context).pushNamed("/auth", arguments: {"url": AppSettings().oauthUrl});
Navigator.of(context).pushNamed("/auth", arguments: {"url": ConnectionManager().oauthUrl});
}
_setErrorState(HACException e) {
@ -450,10 +449,8 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
Widget _buildScaffoldBody(bool empty) {
List<Entity> activePlayers = [];
List<Entity> activeLights = [];
Color mediaMenuIconColor;
Color lightMenuIconColor;
List<PopupMenuItem<String>> serviceMenuItems = [];
List<PopupMenuItem<String>> mediaMenuItems = [];
int currentViewCount = HomeAssistant().ui?.views?.length ?? 0;
if (_previousViewCount != currentViewCount) {
@ -462,25 +459,72 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
_previousViewCount = currentViewCount;
}
if (AppSettings().isAuthenticated) {
serviceMenuItems.add(PopupMenuItem<String>(
child: new Text("Reload"),
value: "reload",
));
if (ConnectionManager().isAuthenticated) {
_showLoginButton = false;
serviceMenuItems.add(
PopupMenuItem<String>(
child: new Text("Logout"),
value: "logout",
));
}
Widget mediaMenuIcon;
int playersCount = 0;
if (!empty && !HomeAssistant().entities.isEmpty) {
activePlayers = HomeAssistant().entities.getByDomains(includeDomains: ["media_player"], stateFiler: [EntityState.paused, EntityState.playing, EntityState.idle]);
activeLights = HomeAssistant().entities.getByDomains(includeDomains: ["light"], stateFiler: [EntityState.on]);
List<Entity> activePlayers = HomeAssistant().entities.getByDomains(includeDomains: ["media_player"], stateFiler: [EntityState.paused, EntityState.playing, EntityState.idle]);
playersCount = activePlayers.length;
mediaMenuItems.addAll(
activePlayers.map((entity) => PopupMenuItem<String>(
child: Text(
"${entity.displayName}",
style: Theme.of(context).textTheme.body1.copyWith(
color: HAClientTheme().getColorByEntityState(entity.state, context)
)
),
value: "${entity.entityId}",
)).toList()
);
}
if (activePlayers.isNotEmpty) {
mediaMenuIconColor = Theme.of(context).accentColor;
mediaMenuItems.addAll([
PopupMenuItem<String>(
child: new Text("Play media..."),
value: "play_media",
)
]);
if (playersCount > 0) {
mediaMenuIcon = Stack(
overflow: Overflow.visible,
children: <Widget>[
Icon(MaterialDesignIcons.getIconDataFromIconName(
"mdi:television"), color: Colors.white,),
Positioned(
bottom: -4,
right: -4,
child: Container(
height: 16,
width: 16,
decoration: new BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
child: Center(
child: Text(
"$playersCount",
style: Theme.of(context).textTheme.caption.copyWith(
color: Colors.white
)
),
),
),
)
],
);
} else {
mediaMenuIconColor = Theme.of(context).primaryIconTheme.color;
}
if (activeLights.isNotEmpty) {
lightMenuIconColor = Theme.of(context).accentColor;
} else {
lightMenuIconColor = Theme.of(context).primaryIconTheme.color;
mediaMenuIcon = Icon(MaterialDesignIcons.getIconDataFromIconName(
"mdi:television"), color: Colors.white,);
}
Widget mainScrollBody;
if (empty) {
@ -531,34 +575,46 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
SliverAppBar(
floating: true,
pinned: true,
snap: false,
primary: true,
title: Text(HomeAssistant().locationName ?? ""),
actions: <Widget>[
PopupMenuButton(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: Icon(MaterialDesignIcons.getIconDataFromIconName(
"mdi:dots-vertical"), color: Theme.of(context).primaryIconTheme.color)
),
itemBuilder: (BuildContext context) {
List<PopupMenuEntry<String>> result = [
PopupMenuItem<String>(
child: new Text("Reload"),
value: "reload",
)
];
if (AppSettings().isAuthenticated) {
result.addAll([
PopupMenuDivider(),
PopupMenuItem<String>(
child: new Text("Logout"),
value: "logout",
)]);
IconButton(
icon: mediaMenuIcon,
onPressed: () {
showMenu(
position: RelativeRect.fromLTRB(MediaQuery.of(context).size.width, MediaQuery.of(context).padding.top + Sizes.iconSize + 20, 50, 0),
context: context,
items: mediaMenuItems
).then((String val) {
if (val == "play_media") {
Navigator.pushNamed(context, "/play-media", arguments: {"url": ""});
} else if (val != null) {
_showEntityPage(val);
}
});
}
return result;
},
),
IconButton(
icon: Icon(MaterialDesignIcons.getIconDataFromIconName(
"mdi:dots-vertical"), color: Colors.white,),
onPressed: () {
showMenu(
position: RelativeRect.fromLTRB(MediaQuery.of(context).size.width, MediaQuery.of(context).padding.top + Sizes.iconSize + 20, 0.0, 0.0),
context: context,
items: serviceMenuItems
).then((String val) {
HomeAssistant().lovelaceDashboardUrl = HomeAssistant.DEFAULT_DASHBOARD;
if (val == "reload") {
_quickLoad();
} else if (val == "logout") {
HomeAssistant().logout().then((_) {
_quickLoad();
});
}
});
}
)
],
leading: IconButton(
icon: Icon(Icons.menu),
@ -566,99 +622,6 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
_scaffoldKey.currentState.openDrawer();
},
),
expandedHeight: 130,
flexibleSpace: FlexibleSpaceBar(
title: Padding(
padding: EdgeInsets.only(bottom: 15),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
PopupMenuButton<String>(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 10),
child: Icon(
MaterialDesignIcons.getIconDataFromIconName("mdi:television"),
color: mediaMenuIconColor,
size: 20,
)
),
onSelected: (String val) {
if (val == "play_media") {
Navigator.pushNamed(context, "/play-media", arguments: {"url": ""});
} else if (val != null) {
_showEntityPage(val);
}
},
itemBuilder: (BuildContext context) {
List<PopupMenuEntry<String>> result = [
PopupMenuDivider(),
PopupMenuItem<String>(
child: new Text("Play media..."),
value: "play_media",
)
];
if (activePlayers.isNotEmpty) {
result.insertAll(0,
activePlayers.map((entity) => PopupMenuItem<String>(
child: Text(
"${entity.displayName}",
style: Theme.of(context).textTheme.body1.copyWith(
color: HAClientTheme().getColorByEntityState(entity.state, context)
)
),
value: "${entity.entityId}",
)).toList()
);
} else {
result.insert(0, PopupMenuItem<String>(
child: new Text("No active players"),
value: "_",
enabled: false,
));
}
return result;
}
),
PopupMenuButton<String>(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 10),
child: Icon(
MaterialDesignIcons.getIconDataFromIconName("mdi:lightbulb-outline"),
color: lightMenuIconColor,
size: 20
)
),
onSelected: (String val) {
if (val == 'turn_off_all') {
ConnectionManager().callService(
service: 'turn_off',
domain: 'light',
entityId: 'all'
);
} else if (val == 'turn_on_all') {
ConnectionManager().callService(
service: 'turn_on',
domain: 'light',
entityId: 'all'
);
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
PopupMenuItem<String>(
child: new Text("Turn on all lights"),
value: "turn_on_all",
),
PopupMenuItem<String>(
child: new Text("Turn off all ligts"),
value: "turn_off_all",
enabled: activeLights.isNotEmpty,
)
],
)
],
) ),
centerTitle: true,
),
bottom: empty ? null : TabBar(
controller: _viewsTabController,
tabs: buildUIViewTabs(),

View File

@ -39,7 +39,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
_buildMenuItem(context, MaterialDesignIcons.getIconDataFromIconName('mdi:network'), 'Connection settings', AppSettingsSection.connectionSettings),
_buildMenuItem(context, MaterialDesignIcons.getIconDataFromIconName('mdi:brush'), 'Look and feel', AppSettingsSection.lookAndFeel),
];
if (AppSettings().webhookId != null) {
if (ConnectionManager().webhookId != null) {
items.insert(1, _buildMenuItem(context, MaterialDesignIcons.getIconDataFromIconName('mdi:cellphone-android'), 'Integration settings', AppSettingsSection.integrationSettings));
}
return ListView(

View File

@ -31,7 +31,7 @@ class _ConnectionSettingsPageState extends State<ConnectionSettingsPage> {
}
_loadSettings() async {
_includeDeviceName = widget.quickStart || AppSettings().webhookId == null;
_includeDeviceName = widget.quickStart || ConnectionManager().webhookId == null;
SharedPreferences prefs = await SharedPreferences.getInstance();
String domain = prefs.getString('hassio-domain')?? '';
String port = prefs.getString('hassio-port') ?? '';

View File

@ -11,7 +11,7 @@ class IntegrationSettingsPage extends StatefulWidget {
class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
int _locationInterval = AppSettings().defaultLocationUpdateIntervalMinutes;
int _locationInterval = LocationManager().defaultUpdateIntervalMinutes;
bool _locationTrackingEnabled = false;
bool _wait = false;
@ -28,8 +28,7 @@ class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
SharedPreferences.getInstance().then((prefs) {
setState(() {
_locationTrackingEnabled = prefs.getBool("location-enabled") ?? false;
_locationInterval = prefs.getInt("location-interval") ??
AppSettings().defaultLocationUpdateIntervalMinutes;
_locationInterval = prefs.getInt("location-interval") ?? LocationManager().defaultUpdateIntervalMinutes;
if (_locationInterval % 5 != 0) {
_locationInterval = 5 * (_locationInterval ~/ 5);
}
@ -54,9 +53,11 @@ class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
}
_switchLocationTrackingState(bool state) async {
/*
if (state) {
await LocationManager().updateDeviceLocation();
}
*/
await LocationManager().setSettings(_locationTrackingEnabled, _locationInterval);
setState(() {
_wait = false;

View File

@ -44,7 +44,7 @@ class _LookAndFeelSettingsPageState extends State<LookAndFeelSettingsPage> {
Future _saveOther() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
AppSettings().scrollBadges = _scrollBadges;
ConnectionManager().scrollBadges = _scrollBadges;
await prefs.setBool('scroll-badges', _scrollBadges);
}

View File

@ -20,7 +20,7 @@ class _ConfigPanelWidgetState extends State<ConfigPanelWidget> {
Widget build(BuildContext context) {
return ListView(
children: [
LinkToWebConfig(name: "Home Assistant configuration", url: AppSettings().httpWebHost+"/config"),
LinkToWebConfig(name: "Home Assistant configuration", url: ConnectionManager().httpWebHost+"/config"),
],
);
}

View File

@ -31,15 +31,14 @@ class Panel {
if (componentName.startsWith('haclient')) {
Navigator.of(context).pushNamed(urlPath);
} else if (componentName == 'lovelace') {
HomeAssistant().currentDashboardPath = this.urlPath;
Logger.d('Switching to dashboard: ${this.urlPath}');
HomeAssistant().lovelaceDashboardUrl = this.urlPath;
HomeAssistant().autoUi = false;
SharedPreferences.getInstance().then((prefs) {
prefs.setString('lovelace_dashboard_url', this.urlPath);
eventBus.fire(ReloadUIEvent());
});
} else {
Launcher.launchAuthenticatedWebView(context: context, url: "${AppSettings().httpWebHost}/$urlPath", title: "${this.title}");
Launcher.launchAuthenticatedWebView(context: context, url: "${ConnectionManager().httpWebHost}/$urlPath", title: "${this.title}");
}
}

View File

@ -153,14 +153,14 @@ class RegisterAppPopup extends Popup {
Padding(
padding: EdgeInsets.all(20),
child: TextFormField(
initialValue: AppSettings().mobileAppDeviceName ?? MobileAppIntegrationManager.getDefaultDeviceName(),
initialValue: ConnectionManager().mobileAppDeviceName ?? MobileAppIntegrationManager.getDefaultDeviceName(),
onSaved: (newValue) {
String deviceName = newValue?.trim();
SharedPreferences.getInstance().then((prefs) {
prefs.remove("app-webhook-id");
prefs.setString('app-integration-device-name', deviceName);
AppSettings().webhookId = null;
AppSettings().mobileAppDeviceName = deviceName;
ConnectionManager().webhookId = null;
ConnectionManager().mobileAppDeviceName = deviceName;
Navigator.of(context).pop();
MobileAppIntegrationManager.checkAppRegistration();
});

View File

@ -16,7 +16,7 @@ class HomeAssistantUI {
int viewCounter = 0;
Logger.d("--Views count: ${rawLovelaceConfig['views'].length}");
rawLovelaceConfig["views"].forEach((rawView){
Logger.d("----view: ${rawView['path'] ?? viewCounter}");
Logger.d("----view id: ${rawView['id']}");
HAView view = HAView(
count: viewCounter,
rawData: rawView
@ -30,42 +30,8 @@ class HomeAssistantUI {
}
Map _generateLovelaceConfig() {
Map result = {
'title': 'Home'
};
List<Entity> left = HomeAssistant().entities.getByDomains(
excludeDomains: ['sensor','binary_sensor', 'device_tracker', 'person', 'sun']
);
List<Map> cards = [];
Map<String, Map> cardsByDomains = {};
left.forEach((Entity entity) {
if (entity is GroupEntity) {
cards.add({
'type': CardType.ENTITIES,
'title': entity.displayName,
'entities': entity.childEntities.map((e) => e.entityId)
});
} else if (entity is MediaPlayerEntity) {
cards.add({
'type': CardType.MEDIA_CONTROL,
'entity': entity.entityId
});
} else if (entity is AlarmControlPanelEntity) {
cards.add({
'type': CardType.ALARM_PANEL,
'entity': entity.entityId
});
} else if (cardsByDomains.containsKey(entity.domain)) {
cardsByDomains[entity.domain]['entities'].add(entity.entityId);
} else {
cardsByDomains[entity.domain] = {
'type': 'entities',
'entities': [entity.entityId],
'title': entity.domain
};
}
});
cards.addAll(cardsByDomains.values);
Map result = {};
result['title'] = 'Home';
result['views'] = [
{
'icon': 'mdi:home',
@ -74,7 +40,14 @@ class HomeAssistantUI {
).map(
(en) => en.entityId
).toList(),
'cards': cards
'cards': [{
'type': 'entities',
'entities': HomeAssistant().entities.getByDomains(
excludeDomains: ['sensor','binary_sensor', 'device_tracker', 'person', 'sun']
).map(
(en) => en.entityId
).toList()
}]
}
];
return result;

View File

@ -45,7 +45,7 @@ class Launcher {
if (viewState.type == standaloneWebview.WebViewState.startLoad) {
Logger.d("[WebView] Injecting external auth JS");
rootBundle.loadString('assets/js/externalAuth.js').then((js){
flutterWebViewPlugin.evalJavascript(js.replaceFirst("[token]", AppSettings().longLivedToken));
flutterWebViewPlugin.evalJavascript(js.replaceFirst("[token]", ConnectionManager()._token));
});
}
});

View File

@ -6,7 +6,6 @@ class HAView {
Entity linkedEntity;
String name;
String id;
String path;
String iconName;
final int count;
bool isPanel;
@ -16,7 +15,6 @@ class HAView {
name = rawData['title'];
iconName = rawData['icon'];
isPanel = rawData['panel'] ?? false;
path = rawData['path'] ?? '$count';
if (rawData['badges'] != null && !isPanel) {
badges = CardData.parse({

View File

@ -10,12 +10,15 @@ class ViewWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget cardsContainer;
Widget badgesContainer;
if (this.view.isPanel) {
cardsContainer = _buildPanelChild(context);
badgesContainer = Container(width: 0, height: 0);
return FractionallySizedBox(
widthFactor: 1,
heightFactor: 1,
child: _buildPanelChild(context),
);
} else {
Widget cardsContainer;
Widget badgesContainer;
if (this.view.badges != null && this.view.badges is BadgesData) {
badgesContainer = this.view.badges.buildCardWidget();
} else {
@ -47,17 +50,17 @@ class ViewWidget extends StatelessWidget {
} else {
cardsContainer = Container();
}
return SingleChildScrollView(
padding: EdgeInsets.all(0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
badgesContainer,
cardsContainer
],
),
);
}
return SingleChildScrollView(
padding: EdgeInsets.all(0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
badgesContainer,
cardsContainer
],
),
);
}
Widget _buildPanelChild(BuildContext context) {

View File

@ -1,7 +1,7 @@
name: hass_client
description: Home Assistant Android Client
version: 0.0.0+1144
version: 1.1.0+1100
environment:
@ -14,7 +14,7 @@ dependencies:
shared_preferences: ^0.5.6+1
path_provider: ^1.6.5
event_bus: ^1.1.1
cached_network_image: ^2.3.0-beta
cached_network_image: ^2.0.0
url_launcher: ^5.4.1
date_format: ^1.0.8
charts_flutter: ^0.8.1
@ -23,16 +23,17 @@ dependencies:
flutter_custom_tabs: ^0.6.0
flutter_webview_plugin: ^0.3.10+1
webview_flutter: ^0.3.19+7
firebase_messaging: ^6.0.15
firebase_messaging: ^6.0.13
flutter_secure_storage: ^3.3.3
device_info: ^0.4.1+4
flutter_local_notifications: ^1.1.6
geolocator: ^5.3.1
workmanager: ^0.2.2
#geolocator: ^5.3.1
background_locator: ^1.1.3+1
#workmanager: ^0.2.2
battery: ^1.0.0
firebase_crashlytics: ^0.1.3+3
syncfusion_flutter_core: ^18.1.52
syncfusion_flutter_gauges: ^18.1.52
syncfusion_flutter_core: ^18.1.48
syncfusion_flutter_gauges: ^18.1.48
dev_dependencies: