mirror of
https://github.com/seemoo-lab/openhaystack.git
synced 2026-05-25 01:42:45 +00:00
Adding OpenHaystack Mobile app
Co-Authored-By: Lukas Burg <lukas.burg@hemalu.de>
This commit is contained in:
committed by
Alexander Heinrich
parent
b65a6e6be0
commit
3d593a006c
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
166
openhaystack-mobile/lib/accessory/accessory_detail.dart
Normal file
166
openhaystack-mobile/lib/accessory/accessory_detail.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
openhaystack-mobile/lib/accessory/accessory_dto.dart
Normal file
106
openhaystack-mobile/lib/accessory/accessory_dto.dart
Normal 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,
|
||||
};
|
||||
|
||||
}
|
||||
40
openhaystack-mobile/lib/accessory/accessory_icon.dart
Normal file
40
openhaystack-mobile/lib/accessory/accessory_icon.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
openhaystack-mobile/lib/accessory/accessory_icon_model.dart
Normal file
39
openhaystack-mobile/lib/accessory/accessory_icon_model.dart
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
152
openhaystack-mobile/lib/accessory/accessory_list.dart
Normal file
152
openhaystack-mobile/lib/accessory/accessory_list.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
75
openhaystack-mobile/lib/accessory/accessory_list_item.dart
Normal file
75
openhaystack-mobile/lib/accessory/accessory_list_item.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
225
openhaystack-mobile/lib/accessory/accessory_model.dart
Normal file
225
openhaystack-mobile/lib/accessory/accessory_model.dart
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
155
openhaystack-mobile/lib/accessory/accessory_registry.dart
Normal file
155
openhaystack-mobile/lib/accessory/accessory_registry.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
30
openhaystack-mobile/lib/accessory/no_accessories.dart
Normal file
30
openhaystack-mobile/lib/accessory/no_accessories.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user