Adding OpenHaystack Mobile app

Co-Authored-By: Lukas Burg <lukas.burg@hemalu.de>
This commit is contained in:
MaxGranzow
2022-05-11 13:02:07 +02:00
committed by Alexander Heinrich
parent b65a6e6be0
commit 3d593a006c
182 changed files with 10499 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
class AccessoryColorSelector extends StatelessWidget {
/// This shows a color selector.
///
/// The color can be selected via a color field or by inputing explicit
/// RGB values.
const AccessoryColorSelector({ Key? key }) : super(key: key);
/// Displays the color selector with the [initialColor] preselected.
///
/// The selected color is returned if the user selects the save option.
/// Otherwise the selection is discarded with a null return value.
static Future<Color?> showColorSelection(BuildContext context, Color initialColor) async {
Color currentColor = initialColor;
return await showDialog<Color>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Pick a color'),
content: SingleChildScrollView(
child: ColorPicker(
hexInputBar: true,
pickerColor: currentColor,
onColorChanged: (Color newColor) {
currentColor = newColor;
},
)
),
actions: <Widget>[
ElevatedButton(
child: const Text('Save'),
onPressed: () {
Navigator.pop(context, currentColor);
},
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
throw UnimplementedError();
}
}

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:openhaystack_mobile/accessory/accessory_color_selector.dart';
import 'package:openhaystack_mobile/accessory/accessory_icon.dart';
import 'package:openhaystack_mobile/accessory/accessory_icon_selector.dart';
import 'package:openhaystack_mobile/accessory/accessory_model.dart';
import 'package:openhaystack_mobile/accessory/accessory_registry.dart';
import 'package:openhaystack_mobile/item_management/accessory_name_input.dart';
class AccessoryDetail extends StatefulWidget {
Accessory accessory;
/// A page displaying the editable information of a specific [accessory].
///
/// This shows the editable information of a specific [accessory] and
/// allows the user to edit them.
AccessoryDetail({
Key? key,
required this.accessory,
}) : super(key: key);
@override
_AccessoryDetailState createState() => _AccessoryDetailState();
}
class _AccessoryDetailState extends State<AccessoryDetail> {
// An accessory storing the changed values.
late Accessory newAccessory;
final _formKey = GlobalKey<FormState>();
@override
void initState() {
// Initialize changed accessory with existing accessory properties.
newAccessory = widget.accessory.clone();
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.accessory.name),
),
body: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
children: [
Center(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: AccessoryIcon(
size: 100,
icon: newAccessory.icon,
color: newAccessory.color,
),
),
Positioned(
bottom: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Container(
decoration: const BoxDecoration(
color: Color.fromARGB(255, 200, 200, 200),
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () async {
// Show icon selection
String? selectedIcon = await AccessoryIconSelector
.showIconSelection(context, newAccessory.rawIcon, newAccessory.color);
if (selectedIcon != null) {
setState(() {
newAccessory.setIcon(selectedIcon);
});
// Show color selection only when icon is selected
Color? selectedColor = await AccessoryColorSelector
.showColorSelection(context, newAccessory.color);
if (selectedColor != null) {
setState(() {
newAccessory.color = selectedColor;
});
}
}
},
icon: const Icon(Icons.edit),
),
),
),
),
],
),
),
AccessoryNameInput(
initialValue: newAccessory.name,
onChanged: (value) {
setState(() {
newAccessory.name = value;
});
},
),
SwitchListTile(
value: newAccessory.isActive,
title: const Text('Is Active'),
onChanged: (checked) {
setState(() {
newAccessory.isActive = checked;
});
},
),
SwitchListTile(
value: newAccessory.isDeployed,
title: const Text('Is Deployed'),
onChanged: (checked) {
setState(() {
newAccessory.isDeployed = checked;
});
},
),
ListTile(
title: OutlinedButton(
child: const Text('Save'),
onPressed: _formKey.currentState == null || !_formKey.currentState!.validate()
? null : () {
if (_formKey.currentState != null && _formKey.currentState!.validate()) {
// Update accessory with changed values
var accessoryRegistry = Provider.of<AccessoryRegistry>(context, listen: false);
accessoryRegistry.editAccessory(widget.accessory, newAccessory);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Changes saved!'),
),
);
}
},
),
),
ListTile(
title: ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) {
return Theme.of(context).errorColor;
},
),
),
child: const Text('Delete Accessory', style: TextStyle(color: Colors.white),),
onPressed: () {
// Delete accessory
var accessoryRegistry = Provider.of<AccessoryRegistry>(context, listen: false);
accessoryRegistry.removeAccessory(widget.accessory);
Navigator.pop(context);
},
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,106 @@
/// This class is used for de-/serializing data to the JSON transfer format.
class AccessoryDTO {
int id;
List<double> colorComponents;
String name;
double? lastDerivationTimestamp;
String? symmetricKey;
int? updateInterval;
String privateKey;
String icon;
bool isDeployed;
String colorSpaceName;
bool usesDerivation;
String? oldestRelevantSymmetricKey;
bool isActive;
/// Creates a transfer object to serialize to the JSON export format.
///
/// This implements the [toJson] method used by the Dart JSON serializer.
/// ```dart
/// var accessoryDTO = AccessoryDTO(...);
/// jsonEncode(accessoryDTO);
/// ```
AccessoryDTO({
required this.id,
required this.colorComponents,
required this.name,
this.lastDerivationTimestamp,
this.symmetricKey,
this.updateInterval,
required this.privateKey,
required this.icon,
required this.isDeployed,
required this.colorSpaceName,
required this.usesDerivation,
this.oldestRelevantSymmetricKey,
required this.isActive,
});
/// Creates a transfer object from deserialized JSON data.
///
/// The data is only decoded and not processed further.
///
/// Typically used with JSON decoder.
/// ```dart
/// String json = '...';
/// var accessoryDTO = AccessoryDTO.fromJSON(jsonDecode(json));
/// ```
///
/// This implements the [toJson] method used by the Dart JSON serializer.
/// ```dart
/// var accessoryDTO = AccessoryDTO(...);
/// jsonEncode(accessoryDTO);
/// ```
AccessoryDTO.fromJson(Map<String, dynamic> json)
: id = json['id'],
colorComponents = List.from(json['colorComponents'])
.map((val) => double.parse(val.toString())).toList(),
name = json['name'],
lastDerivationTimestamp = json['lastDerivationTimestamp'] ?? 0,
symmetricKey = json['symmetricKey'] ?? '',
updateInterval = json['updateInterval'] ?? 0,
privateKey = json['privateKey'],
icon = json['icon'],
isDeployed = json['isDeployed'],
colorSpaceName = json['colorSpaceName'],
usesDerivation = json['usesDerivation'] ?? false,
oldestRelevantSymmetricKey = json['oldestRelevantSymmetricKey'] ?? '',
isActive = json['isActive'];
/// Creates a JSON map of the serialized transfer object.
///
/// Typically used by JSON encoder.
/// ```dart
/// var accessoryDTO = AccessoryDTO(...);
/// jsonEncode(accessoryDTO);
/// ```
Map<String, dynamic> toJson() => usesDerivation ? {
// With derivation
'id': id,
'colorComponents': colorComponents,
'name': name,
'lastDerivationTimestamp': lastDerivationTimestamp,
'symmetricKey': symmetricKey,
'updateInterval': updateInterval,
'privateKey': privateKey,
'icon': icon,
'isDeployed': isDeployed,
'colorSpaceName': colorSpaceName,
'usesDerivation': usesDerivation,
'oldestRelevantSymmetricKey': oldestRelevantSymmetricKey,
'isActive': isActive,
} : {
// Without derivation (skip rolling key params)
'id': id,
'colorComponents': colorComponents,
'name': name,
'privateKey': privateKey,
'icon': icon,
'isDeployed': isDeployed,
'colorSpaceName': colorSpaceName,
'usesDerivation': usesDerivation,
'isActive': isActive,
};
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
class AccessoryIcon extends StatelessWidget {
/// The icon to display.
final IconData icon;
/// The color of the surrounding ring.
final Color color;
/// The size of the icon.
final double size;
/// Displays the icon in a colored ring.
///
/// The default size can be adjusted by setting the [size] parameter.
const AccessoryIcon({
Key? key,
this.icon = Icons.help,
this.color = Colors.grey,
this.size = 24,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(width: size / 6, color: color),
),
child: Padding(
padding: EdgeInsets.all(size / 12),
child: Icon(
icon,
size: size,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
class AccessoryIconModel {
/// A list of all available icons
static const List<String> icons = [
"creditcard.fill", "briefcase.fill", "case.fill", "latch.2.case.fill",
"key.fill", "mappin", "globe", "crown.fill",
"gift.fill", "car.fill", "bicycle", "figure.walk",
"heart.fill", "hare.fill", "tortoise.fill", "eye.fill",
];
/// A mapping from the cupertino icon names to the material icon names.
///
/// If the icons do not match, so a similar replacement is used.
static const iconMapping = {
'creditcard.fill': Icons.credit_card,
'briefcase.fill': Icons.business_center,
'case.fill': Icons.work,
'latch.2.case.fill': Icons.business_center,
'key.fill': Icons.vpn_key,
'mappin': Icons.place,
// 'pushpin': Icons.push_pin,
'globe': Icons.language,
'crown.fill': Icons.school,
'gift.fill': Icons.redeem,
'car.fill': Icons.directions_car,
'bicycle': Icons.pedal_bike,
'figure.walk': Icons.directions_walk,
'heart.fill': Icons.favorite,
'hare.fill': Icons.pets,
'tortoise.fill': Icons.bug_report,
'eye.fill': Icons.visibility,
};
/// Looks up the equivalent material icon for the cupertino icon [iconName].
static IconData? mapIcon(String iconName) {
return iconMapping[iconName];
}
}

View File

@@ -0,0 +1,76 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:openhaystack_mobile/accessory/accessory_icon_model.dart';
typedef IconChangeListener = void Function(String? newValue);
class AccessoryIconSelector extends StatelessWidget {
/// The existing icon used previously.
final String icon;
/// The existing color used previously.
final Color color;
/// A callback being called when the icon changes.
final IconChangeListener iconChanged;
/// This show an icon selector.
///
/// The icon can be selected from a list of available icons.
/// The icons are handled by the cupertino icon names.
const AccessoryIconSelector({
Key? key,
required this.icon,
required this.color,
required this.iconChanged,
}) : super(key: key);
/// Displays the icon selector with the [currentIcon] preselected in the [highlighColor].
///
/// The selected icon as a cupertino icon name is returned if the user selects an icon.
/// Otherwise the selection is discarded and a null value is returned.
static Future<String?> showIconSelection(BuildContext context, String currentIcon, Color highlighColor) async {
return await showDialog<String>(
context: context,
builder: (BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) => Dialog(
child: GridView.count(
primary: false,
padding: const EdgeInsets.all(20),
crossAxisSpacing: 10,
mainAxisSpacing: 10,
shrinkWrap: true,
crossAxisCount: min((constraints.maxWidth / 80).floor(), 8),
semanticChildCount: AccessoryIconModel.icons.length,
children: AccessoryIconModel.icons
.map((value) => IconButton(
icon: Icon(AccessoryIconModel.mapIcon(value)),
color: value == currentIcon ? highlighColor : null,
onPressed: () { Navigator.pop(context, value); },
)).toList(),
),
),
);
}
);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Color.fromARGB(255, 200, 200, 200),
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () async {
String? selectedIcon = await showIconSelection(context, icon, color);
if (selectedIcon != null) {
iconChanged(selectedIcon);
}
},
icon: Icon(AccessoryIconModel.mapIcon(icon)),
),
);
}
}

View File

@@ -0,0 +1,152 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:maps_launcher/maps_launcher.dart';
import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
import 'package:openhaystack_mobile/accessory/accessory_list_item.dart';
import 'package:openhaystack_mobile/accessory/accessory_list_item_placeholder.dart';
import 'package:openhaystack_mobile/accessory/accessory_registry.dart';
import 'package:openhaystack_mobile/accessory/no_accessories.dart';
import 'package:openhaystack_mobile/history/accessory_history.dart';
import 'package:openhaystack_mobile/location/location_model.dart';
class AccessoryList extends StatefulWidget {
final AsyncCallback loadLocationUpdates;
final void Function(LatLng point)? centerOnPoint;
/// Display a location overview all accessories in a concise list form.
///
/// For each accessory the name and last known locaiton information is shown.
/// Uses the accessories in the [AccessoryRegistry].
const AccessoryList({
Key? key,
required this.loadLocationUpdates,
this.centerOnPoint,
}): super(key: key);
@override
_AccessoryListState createState() => _AccessoryListState();
}
class _AccessoryListState extends State<AccessoryList> {
@override
Widget build(BuildContext context) {
return Consumer2<AccessoryRegistry, LocationModel>(
builder: (context, accessoryRegistry, locationModel, child) {
var accessories = accessoryRegistry.accessories;
// Show placeholder while accessories are loading
if (accessoryRegistry.loading){
return LayoutBuilder(
builder: (context, constraints) {
// Show as many accessory placeholder fitting into the vertical space.
// Minimum one, maximum 6 placeholders
var nrOfEntries = min(max((constraints.maxHeight / 64).floor(), 1), 6);
List<Widget> placeholderList = [];
for (int i = 0; i < nrOfEntries; i++) {
placeholderList.add(const AccessoryListItemPlaceholder());
}
return Scrollbar(
child: ListView(
children: placeholderList,
),
);
}
);
}
if (accessories.isEmpty) {
return const NoAccessoriesPlaceholder();
}
// TODO: Refresh Indicator for desktop
// Use pull to refresh method
return SlidableAutoCloseBehavior(child:
RefreshIndicator(
onRefresh: widget.loadLocationUpdates,
child: Scrollbar(
child: ListView(
children: accessories.map((accessory) {
// Calculate distance from users devices location
Widget? trailing;
if (locationModel.here != null && accessory.lastLocation != null) {
const Distance distance = Distance();
final double km = distance.as(LengthUnit.Kilometer, locationModel.here!, accessory.lastLocation!);
trailing = Text(km.toString() + 'km');
}
// Get human readable location
return Slidable(
endActionPane: ActionPane(
motion: const DrawerMotion(),
children: [
if (accessory.isDeployed) SlidableAction(
onPressed: (context) async {
if (accessory.lastLocation != null && accessory.isDeployed) {
var loc = accessory.lastLocation!;
await MapsLauncher.launchCoordinates(
loc.latitude, loc.longitude, accessory.name);
}
},
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
icon: Icons.directions,
label: 'Navigate',
),
if (accessory.isDeployed) SlidableAction(
onPressed: (context) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => AccessoryHistory(
accessory: accessory,
)),
);
},
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
icon: Icons.history,
label: 'History',
),
if (!accessory.isDeployed) SlidableAction(
onPressed: (context) {
var accessoryRegistry = Provider.of<AccessoryRegistry>(context, listen: false);
var newAccessory = accessory.clone();
newAccessory.isDeployed = true;
accessoryRegistry.editAccessory(accessory, newAccessory);
},
backgroundColor: Colors.green,
foregroundColor: Colors.white,
icon: Icons.upload_file,
label: 'Deploy',
),
],
),
child: Builder(
builder: (context) {
return AccessoryListItem(
accessory: accessory,
distance: trailing,
herePlace: locationModel.herePlace,
onTap: () {
var lastLocation = accessory.lastLocation;
if (lastLocation != null) {
widget.centerOnPoint?.call(lastLocation);
}
},
onLongPress: Slidable.of(context)?.openEndActionPane,
);
}
),
);
}).toList(),
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:geocoding/geocoding.dart';
import 'package:openhaystack_mobile/accessory/accessory_icon.dart';
import 'package:openhaystack_mobile/accessory/accessory_model.dart';
import 'package:intl/intl.dart';
class AccessoryListItem extends StatelessWidget {
/// The accessory to display the information for.
final Accessory accessory;
/// A trailing distance information widget.
final Widget? distance;
/// Address information about the accessories location.
final Placemark? herePlace;
final VoidCallback onTap;
final VoidCallback? onLongPress;
/// Displays the location of an accessory as a concise list item.
///
/// Shows the icon and name of the accessory, as well as the current
/// location and distance to the user's location (if known; `distance != null`)
const AccessoryListItem({
Key? key,
required this.accessory,
required this.onTap,
this.onLongPress,
this.distance,
this.herePlace,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder<Placemark?>(
future: accessory.place,
builder: (BuildContext context, AsyncSnapshot<Placemark?> snapshot) {
// Format the location of the accessory. Use in this order:
// * Address if known
// * Coordinates (latitude & longitude) if known
// * `Unknown` if unknown
String locationString = accessory.lastLocation != null
? '${accessory.lastLocation!.latitude}, ${accessory.lastLocation!.longitude}'
: 'Unknown';
if (snapshot.hasData && snapshot.data != null) {
Placemark place = snapshot.data!;
locationString = '${place.locality}, ${place.administrativeArea}';
if (herePlace != null && herePlace!.country != place.country) {
locationString = '${place.locality}, ${place.country}';
}
}
// Format published date in a human readable way
String? dateString = accessory.datePublished != null
? ' · ${DateFormat('dd.MM.yyyy kk:mm').format(accessory.datePublished!)}'
: '';
return ListTile(
onTap: onTap,
onLongPress: onLongPress,
title: Text(
accessory.name + (accessory.isDeployed ? '' : ' (not deployed)'),
style: TextStyle(
color: accessory.isDeployed
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).disabledColor,
),
),
subtitle: Text(locationString + dateString),
trailing: distance,
dense: true,
leading: AccessoryIcon(
icon: accessory.icon,
color: accessory.color,
),
);
},
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:openhaystack_mobile/accessory/accessory_list_item.dart';
import 'package:openhaystack_mobile/placeholder/avatar_placeholder.dart';
import 'package:openhaystack_mobile/placeholder/text_placeholder.dart';
class AccessoryListItemPlaceholder extends StatelessWidget {
/// A placeholder for an [AccessoryListItem] showing a loading animation.
const AccessoryListItemPlaceholder({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// Uses a similar layout to the actual accessory list item
return const ListTile(
title: TextPlaceholder(),
subtitle: TextPlaceholder(),
dense: true,
leading: AvatarPlaceholder(),
trailing: TextPlaceholder(width: 60),
);
}
}

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:geocoding/geocoding.dart';
import 'package:latlong2/latlong.dart';
import 'package:openhaystack_mobile/accessory/accessory_icon_model.dart';
import 'package:openhaystack_mobile/findMy/find_my_controller.dart';
import 'package:openhaystack_mobile/location/location_model.dart';
class Pair<T1, T2> {
final T1 a;
final T2 b;
Pair(this.a, this.b);
}
const defaultIcon = Icons.push_pin;
class Accessory {
/// The ID of the accessory key.
String id;
/// A hash of the public key.
/// An identifier for the private key stored separately in the key store.
String hashedPublicKey;
/// If the accessory uses rolling keys.
bool usesDerivation;
// Parameters for rolling keys (only relevant is usesDerivation == true)
String? symmetricKey;
double? lastDerivationTimestamp;
int? updateInterval;
String? oldestRelevantSymmetricKey;
/// The display name of the accessory.
String name;
/// The display icon of the accessory.
String _icon;
/// The display color of the accessory.
Color color;
/// If the accessory is active.
bool isActive;
/// If the accessory is already deployed
/// (and could therefore send locations).
bool isDeployed;
/// The timestamp of the last known location
/// (null if no location known).
DateTime? datePublished;
/// The last known locations coordinates
/// (null if no location known).
LatLng? _lastLocation;
/// A list of known locations over time.
List<Pair<LatLng, DateTime>> locationHistory = [];
/// Stores address information about the current location.
Future<Placemark?> place = Future.value(null);
/// Creates an accessory with the given properties.
Accessory({
required this.id,
required this.name,
required this.hashedPublicKey,
required this.datePublished,
this.isActive = false,
this.isDeployed = false,
LatLng? lastLocation,
String icon = 'mappin',
this.color = Colors.grey,
this.usesDerivation = false,
this.symmetricKey,
this.lastDerivationTimestamp,
this.updateInterval,
this.oldestRelevantSymmetricKey,
}): _icon = icon, _lastLocation = lastLocation, super() {
_init();
}
void _init() {
if (_lastLocation != null) {
place = LocationModel.getAddress(_lastLocation!);
}
}
/// Creates a new accessory with exactly the same properties of this accessory.
Accessory clone() {
return Accessory(
datePublished: datePublished,
id: id,
name: name,
hashedPublicKey: hashedPublicKey,
color: color,
icon: _icon,
isActive: isActive,
isDeployed: isDeployed,
lastLocation: lastLocation,
usesDerivation: usesDerivation,
symmetricKey: symmetricKey,
lastDerivationTimestamp: lastDerivationTimestamp,
updateInterval: updateInterval,
oldestRelevantSymmetricKey: oldestRelevantSymmetricKey,
);
}
/// Updates the properties of this accessor with the new values of the [newAccessory].
void update(Accessory newAccessory) {
datePublished = newAccessory.datePublished;
id = newAccessory.id;
name = newAccessory.name;
hashedPublicKey = newAccessory.hashedPublicKey;
color = newAccessory.color;
_icon = newAccessory._icon;
isActive = newAccessory.isActive;
isDeployed = newAccessory.isDeployed;
lastLocation = newAccessory.lastLocation;
}
/// The last known location of the accessory.
LatLng? get lastLocation {
return _lastLocation;
}
/// The last known location of the accessory.
set lastLocation(LatLng? newLocation) {
_lastLocation = newLocation;
if (_lastLocation != null) {
place = LocationModel.getAddress(_lastLocation!);
}
}
/// The display icon of the accessory.
IconData get icon {
IconData? icon = AccessoryIconModel.mapIcon(_icon);
return icon ?? defaultIcon;
}
/// The cupertino icon name.
String get rawIcon {
return _icon;
}
/// The display icon of the accessory.
setIcon (String icon) {
_icon = icon;
}
/// Creates an accessory from deserialized JSON data.
///
/// Uses the same format as in [toJson]
///
/// Typically used with JSON decoder.
/// ```dart
/// String json = '...';
/// var accessoryDTO = Accessory.fromJSON(jsonDecode(json));
/// ```
Accessory.fromJson(Map<String, dynamic> json)
: id = json['id'],
name = json['name'],
hashedPublicKey = json['hashedPublicKey'],
datePublished = json['datePublished'] != null
? DateTime.fromMillisecondsSinceEpoch(json['datePublished']) : null,
_lastLocation = json['latitude'] != null && json['longitude'] != null
? LatLng(json['latitude'].toDouble(), json['longitude'].toDouble()) : null,
isActive = json['isActive'],
isDeployed = json['isDeployed'],
_icon = json['icon'],
color = Color(int.parse(json['color'], radix: 16)),
usesDerivation = json['usesDerivation'] ?? false,
symmetricKey = json['symmetricKey'],
lastDerivationTimestamp = json['lastDerivationTimestamp'],
updateInterval = json['updateInterval'],
oldestRelevantSymmetricKey = json['oldestRelevantSymmetricKey'] {
_init();
}
/// Creates a JSON map of the serialized accessory.
///
/// Uses the same format as in [Accessory.fromJson].
///
/// Typically used by JSON encoder.
/// ```dart
/// var accessory = Accessory(...);
/// jsonEncode(accessory);
/// ```
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'hashedPublicKey': hashedPublicKey,
'datePublished': datePublished?.millisecondsSinceEpoch,
'latitude': _lastLocation?.latitude,
'longitude': _lastLocation?.longitude,
'isActive': isActive,
'isDeployed': isDeployed,
'icon': _icon,
'color': color.toString().split('(0x')[1].split(')')[0],
'usesDerivation': usesDerivation,
'symmetricKey': symmetricKey,
'lastDerivationTimestamp': lastDerivationTimestamp,
'updateInterval': updateInterval,
'oldestRelevantSymmetricKey': oldestRelevantSymmetricKey,
};
/// Returns the Base64 encoded hash of the advertisement key
/// (used to fetch location reports).
Future<String> getHashedAdvertisementKey() async {
var keyPair = await FindMyController.getKeyPair(hashedPublicKey);
return keyPair.getHashedAdvertisementKey();
}
/// Returns the Base64 encoded advertisement key
/// (sent out by the accessory via BLE).
Future<String> getAdvertisementKey() async {
var keyPair = await FindMyController.getKeyPair(hashedPublicKey);
return keyPair.getBase64AdvertisementKey();
}
/// Returns the Base64 encoded private key.
Future<String> getPrivateKey() async {
var keyPair = await FindMyController.getKeyPair(hashedPublicKey);
return keyPair.getBase64PrivateKey();
}
}

View File

@@ -0,0 +1,155 @@
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:openhaystack_mobile/accessory/accessory_model.dart';
import 'package:latlong2/latlong.dart';
import 'package:openhaystack_mobile/findMy/find_my_controller.dart';
import 'package:openhaystack_mobile/findMy/models.dart';
const accessoryStorageKey = 'ACCESSORIES';
class AccessoryRegistry extends ChangeNotifier {
final _storage = const FlutterSecureStorage();
final _findMyController = FindMyController();
List<Accessory> _accessories = [];
bool loading = false;
bool initialLoadFinished = false;
/// Creates the accessory registry.
///
/// This is used to manage the accessories of the user.
AccessoryRegistry() : super();
/// A list of the user's accessories.
UnmodifiableListView<Accessory> get accessories => UnmodifiableListView(_accessories);
/// Loads the user's accessories from persistent storage.
Future<void> loadAccessories() async {
loading = true;
String? serialized = await _storage.read(key: accessoryStorageKey);
if (serialized != null) {
List accessoryJson = json.decode(serialized);
List<Accessory> loadedAccessories =
accessoryJson.map((val) => Accessory.fromJson(val)).toList();
_accessories = loadedAccessories;
} else {
_accessories = [];
}
// For Debugging:
// await overwriteEverythingWithDemoDataForDebugging();
loading = false;
notifyListeners();
}
/// __USE ONLY FOR DEBUGGING PURPOSES__
///
/// __ALL PERSISTENT DATA WILL BE LOST!__
///
/// Overwrites all accessories in this registry with demo data for testing.
Future<void> overwriteEverythingWithDemoDataForDebugging() async {
// Delete everything to start with a fresh set of demo accessories
await _storage.deleteAll();
// Load demo accessories
List<Accessory> demoAccessories = [
Accessory(hashedPublicKey: 'TrnHrAM0ZrFSDeq1NN7ppmh0zYJotYiO09alVVF1mPI=',
id: '-5952179461995674635', name: 'Raspberry Pi', color: Colors.green,
datePublished: DateTime.fromMillisecondsSinceEpoch(1636390931651),
icon: 'gift.fill', lastLocation: LatLng(49.874739, 8.656280)),
Accessory(hashedPublicKey: 'TrnHrAM0ZrFSDeq1NN7ppmh0zYJotYiO09alVVF1mPI=',
id: '-5952179461995674635', name: 'My Bag', color: Colors.blue,
datePublished: DateTime.fromMillisecondsSinceEpoch(1636390931651),
icon: 'case.fill', lastLocation: LatLng(49.874739, 8.656280)),
Accessory(hashedPublicKey: 'TrnHrAM0ZrFSDeq1NN7ppmh0zYJotYiO09alVVF1mPI=',
id: '-5952179461995674635', name: 'Car', color: Colors.red,
datePublished: DateTime.fromMillisecondsSinceEpoch(1636390931651),
icon: 'car.fill', lastLocation: LatLng(49.874739, 8.656280)),
];
_accessories = demoAccessories;
// Store demo accessories for later use
await _storeAccessories();
// Import private key for demo accessories
// Public key hash is TrnHrAM0ZrFSDeq1NN7ppmh0zYJotYiO09alVVF1mPI=
await FindMyController.importKeyPair('siykvOCIEQRVDwrbjyZUXuBwsMi0Htm7IBmBIg==');
}
/// Fetches new location reports and matches them to their accessory.
Future<void> loadLocationReports() async {
List<Future<List<FindMyLocationReport>>> runningLocationRequests = [];
// request location updates for all accessories simultaneously
List<Accessory> currentAccessories = accessories;
for (var i = 0; i < currentAccessories.length; i++) {
var accessory = currentAccessories.elementAt(i);
var keyPair = await FindMyController.getKeyPair(accessory.hashedPublicKey);
var locationRequest = FindMyController.computeResults(keyPair);
runningLocationRequests.add(locationRequest);
}
// wait for location updates to succeed and update state afterwards
var reportsForAccessories = await Future.wait(runningLocationRequests);
for (var i = 0; i < currentAccessories.length; i++) {
var accessory = currentAccessories.elementAt(i);
var reports = reportsForAccessories.elementAt(i);
print("Found ${reports.length} reports for accessory '${accessory.name}'");
accessory.locationHistory = reports
.where((report) => report.latitude.abs() <= 90 && report.longitude.abs() < 90 )
.map((report) => Pair<LatLng, DateTime>(
LatLng(report.latitude, report.longitude),
report.timestamp ?? report.published,
))
.toList();
if (reports.isNotEmpty) {
var lastReport = reports.first;
accessory.lastLocation = LatLng(lastReport.latitude, lastReport.longitude);
accessory.datePublished = lastReport.timestamp ?? lastReport.published;
}
}
// Store updated lastLocation and datePublished for accessories
_storeAccessories();
initialLoadFinished = true;
notifyListeners();
}
/// Stores the user's accessories in persistent storage.
Future<void> _storeAccessories() async {
List jsonList = _accessories.map(jsonEncode).toList();
await _storage.write(key: accessoryStorageKey, value: jsonList.toString());
}
/// Adds a new accessory to this registry.
void addAccessory(Accessory accessory) {
_accessories.add(accessory);
_storeAccessories();
notifyListeners();
}
/// Removes [accessory] from this registry.
void removeAccessory(Accessory accessory) {
_accessories.remove(accessory);
// TODO: remove private key from keychain
_storeAccessories();
notifyListeners();
}
/// Updates [oldAccessory] with the values from [newAccessory].
void editAccessory(Accessory oldAccessory, Accessory newAccessory) {
oldAccessory.update(newAccessory);
_storeAccessories();
notifyListeners();
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:openhaystack_mobile/item_management/new_item_action.dart';
class NoAccessoriesPlaceholder extends StatelessWidget {
/// Displays a message that no accessories are present.
///
/// Allows the user to quickly add a new accessory.
const NoAccessoriesPlaceholder({ Key? key }) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Text(
'There\'s Nothing Here Yet\nAdd an accessory to get started.',
style: TextStyle(
fontSize: 20,
color: Colors.grey,
),
textAlign: TextAlign.center,
),
NewKeyAction(mini: true),
],
),
);
}
}