feat: added datepicker, better refresh, and lots of fixes

This commit is contained in:
Yandrik 2024-03-17 21:17:09 +01:00
parent 48476f6fe4
commit fa129f17fb
6 changed files with 319 additions and 326 deletions

View File

@ -40,23 +40,6 @@ class _MapPageState extends State<MapPage> {
// _fetchPoints(); // _fetchPoints();
} }
/*
Future<void> _fetchPoints() async {
final api = OwntracksApi(baseUrl: '', username: '', pass: '');
print(await api.getDevices());
final points = await api.fetchPointsForDevice(
user: 'yxk',
device: 'meow',
from: DateTime.now().subtract(const Duration(days: 1)));
setState(() {
_points = points.unwrapOr([])
.map((point) => LatLng(point.lat, point.lon))
.toList();
});
}
*/
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
@ -71,6 +54,9 @@ class _MapPageState extends State<MapPage> {
future: getPath(), future: getPath(),
builder: (something, tempPath) => BlocBuilder<SettingsCubit, SettingsState>( builder: (something, tempPath) => BlocBuilder<SettingsCubit, SettingsState>(
builder: (context, state) { builder: (context, state) {
if (tempPath.data == null) {
return const Center(child: Text('Loading Map...'));
}
return FlutterMap( return FlutterMap(
options: const MapOptions( options: const MapOptions(
initialCenter: LatLng(48.3285, 9.8942), initialCenter: LatLng(48.3285, 9.8942),
@ -81,9 +67,9 @@ class _MapPageState extends State<MapPage> {
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
tileProvider: CachedTileProvider( tileProvider: CachedTileProvider(
maxStale: const Duration(days: 30), maxStale: const Duration(days: 30),
store: HiveCacheStore(tempPath.data, hiveBoxName: 'HiveCacheStore')), store: HiveCacheStore(tempPath.data!, hiveBoxName: 'HiveCacheStore')),
), ),
...state.activeDevices.map((id) => UserPath(device: id, settings: state)), ...state.activeDevices.map((id) => UserPath(key: ValueKey(id), device: id, settings: state)),
const MapCompass.cupertino(rotationDuration: Duration(milliseconds: 600)), const MapCompass.cupertino(rotationDuration: Duration(milliseconds: 600)),
// CurrentLocationLayer(), TODO: add permission // CurrentLocationLayer(), TODO: add permission
RichAttributionWidget( RichAttributionWidget(
@ -104,7 +90,7 @@ class _MapPageState extends State<MapPage> {
} }
class UserPath extends StatefulWidget { class UserPath extends StatefulWidget {
UserPath({required this.device, required this.settings}); UserPath({super.key, required this.device, required this.settings});
(String, String) device; (String, String) device;
SettingsState settings; SettingsState settings;
@ -116,10 +102,12 @@ class UserPath extends StatefulWidget {
class _UserPathState extends State<UserPath> { class _UserPathState extends State<UserPath> {
@override @override
Widget build(BuildContext ctx) { Widget build(BuildContext ctx) {
print('rebuilding widget for ${widget.device}');
return BlocProvider( return BlocProvider(
create: (context) { create: (context) {
final bloc = UserPathBloc(widget.device, widget.settings); final bloc = UserPathBloc(widget.device, widget.settings);
bloc.add(UserPathFullUpdate()); bloc.add(UserPathFullUpdate());
// ONLY WORKS because gets rebuilt every time with settings update
return bloc; return bloc;
}, },
child: BlocListener<LocationSubscribeCubit, LocationUpdateState>( child: BlocListener<LocationSubscribeCubit, LocationUpdateState>(
@ -128,12 +116,15 @@ class _UserPathState extends State<UserPath> {
UserPathBloc userPathBloc = context.read<UserPathBloc>(); UserPathBloc userPathBloc = context.read<UserPathBloc>();
if (state case LocationUpdateReceived(:final position, :final deviceId)) { if (state case LocationUpdateReceived(:final position, :final deviceId)) {
if (userPathBloc.deviceId == state.deviceId) { if (userPathBloc.deviceId == state.deviceId) {
context.read<UserPathBloc>().add(UserPathLiveSubscriptionUpdate(position)); userPathBloc.add(UserPathLiveSubscriptionUpdate(position));
} }
} }
}, },
child: BlocBuilder<UserPathBloc, UserPathState>( child: BlocBuilder<UserPathBloc, UserPathState>(
builder: (context, state) { builder: (context, state) {
// TODO: change once smarter rebuilds are ready
context.read<UserPathBloc>().add(UserPathLoginDataChanged(widget.settings));
print("rebuild"); print("rebuild");
final _istate = state as MainUserPathState; final _istate = state as MainUserPathState;
// make markers // make markers
@ -211,6 +202,7 @@ class _UserPathState extends State<UserPath> {
return Stack( return Stack(
children: [ children: [
PolylineLayer( PolylineLayer(
key: ValueKey('${widget.device}_historyLines'),
polylines: [ polylines: [
/* /*
Polyline( Polyline(
@ -225,7 +217,7 @@ class _UserPathState extends State<UserPath> {
...polylines ...polylines
], ],
), ),
PolylineLayer(polylines: [ PolylineLayer(key: ValueKey('${widget.device}_liveLines'), polylines: [
Polyline( Polyline(
points: state.livePoints.map((e) => LatLng(e.lat, e.lon)).toList(), points: state.livePoints.map((e) => LatLng(e.lat, e.lon)).toList(),
strokeWidth: 4.0, strokeWidth: 4.0,
@ -247,165 +239,148 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) {
context: context, context: context,
builder: (bsContext) { builder: (bsContext) {
return Container( return Container(
height: MediaQuery.of(bsContext).size.height * 0.5, // height: MediaQuery.of(bsContext).size.height * 0.5,
width: MediaQuery.of(bsContext).size.width, width: MediaQuery.of(bsContext).size.width,
height: 500,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.black, color: Colors.black,
borderRadius: BorderRadius.only(topLeft: Radius.circular(30), topRight: Radius.circular(30)), borderRadius: BorderRadius.only(topLeft: Radius.circular(30), topRight: Radius.circular(30)),
), ),
padding: EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Column( child: StreamBuilder<UserPathState>(
mainAxisSize: MainAxisSize.min, stream: context.read<UserPathBloc>().stream,
children: [ builder: (sheetContext, state) {
Flexible( final istate = state.data as MainUserPathState? ?? context.read<UserPathBloc>().state as MainUserPathState;
// Wrap non-grid content in Flexible to manage space dynamically if (istate.livePoints.isEmpty) {
child: Text( return Text("Couldn't find ${user.$1}:${user.$2}'s Location");
'${user.$1}:${user.$2}', }
style: const TextStyle( final curLocation = istate.livePoints.last;
fontSize: 24, return Column(
fontWeight: FontWeight.bold, // mainAxisSize: MainAxisSize.min,
children: [
Text(
'${user.$1}:${user.$2}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
), ),
), Container(
), decoration: BoxDecoration(
const SizedBox(height: 16), color: Colors.black,
StreamBuilder<UserPathState>( border: Border.all(color: Colors.orange, width: 2),
stream: context.read<UserPathBloc>().stream, borderRadius: BorderRadius.circular(10),
builder: (sheetContext, state) { ),
final istate = padding: const EdgeInsets.all(16),
state.data as MainUserPathState? ?? context.read<UserPathBloc>().state as MainUserPathState; width: double.infinity,
if (istate.livePoints.isEmpty) {
return Text("Couldn't find ${user.$1}:${user.$2}'s Location");
}
final curLocation = istate.livePoints.last;
return Flexible(
// Use Flexible for dynamic content
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Text("(${curLocation.lat.toStringAsFixed(4)}, ${curLocation.lon.toStringAsFixed(4)})"),
decoration: BoxDecoration( Text(DateFormat('dd.MM.yyyy - kk:mm:ss').format(curLocation.timestamp)),
color: Colors.black, StreamBuilder(
border: Border.all(color: Colors.orange, width: 2), stream: Stream.periodic(const Duration(seconds: 1)),
borderRadius: BorderRadius.circular(10), builder: (context, _) {
), return Text("${formatDuration(DateTime.now().difference(curLocation.timestamp))} ago");
padding: const EdgeInsets.all(16), },
width: double.infinity,
child: Column(
children: [
Text("(${curLocation.lat.toStringAsFixed(4)}, ${curLocation.lon.toStringAsFixed(4)})"),
Text(DateFormat('dd.MM.yyyy - kk:mm:ss').format(curLocation.timestamp)),
StreamBuilder(
stream: Stream.periodic(const Duration(seconds: 1)),
builder: (context, _) {
return Text("${formatDuration(DateTime.now().difference(curLocation.timestamp))} ago");
},
),
],
),
),
const SizedBox(
height: 16,
),
Expanded(
// Ensure GridView.builder is within an Expanded widget
child: BlocBuilder<GlobalLocationStoreCubit, GlobalLocationStoreState>(
bloc: GetIt.I.get<GlobalLocationStoreCubit>(),
builder: (sheetContext, state) {
// get map camera
final mapController = MapController.of(context);
final mapRotation = mapController.camera.rotation;
List<MapEntry<(String, String), Point>> locations = state.locations
.remove(user) // remove this
.entries // get entries into a list, and sort alphabetically
.toList()
..sort((a, b) => "${a.key.$1}:${a.key.$2}".compareTo("${b.key.$1}:${b.key.$2}"));
if (locations.isEmpty) {
return const SizedBox();
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 4.0,
mainAxisSpacing: 4.0,
childAspectRatio: 2.8,
),
itemCount: locations.length,
itemBuilder: (BuildContext context, int index) {
// calculate distance and bearing
double distance = distanceBetween(curLocation.lat, curLocation.lon,
locations[index].value.lat, locations[index].value.lon, "meters");
double bearing = (bearingBetween(
curLocation.lat,
curLocation.lon,
locations[index].value.lat,
locations[index].value.lon,
) +
mapRotation) %
360;
print(distance);
print(bearing);
return Center(
child: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black,
border: Border.all(color: Colors.pink, width: 2),
borderRadius: BorderRadius.circular(10),
),
padding: EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Text(
"${locations[index].key.$1}:${locations[index].key.$2}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
Spacer(),
Text(formatDistance(distance ~/ 1)),
Transform.rotate(
angle: bearing * (math.pi / 180),
child: Icon(
Icons.arrow_upward,
color: Colors.blue,
),
)
],
),
Row(
children: [
StreamBuilder(
stream: Stream.periodic(const Duration(seconds: 1)),
builder: (context, _) {
return Text(
"${formatDuration(DateTime.now().difference(locations[index].value.timestamp))} ago",
style: const TextStyle(fontSize: 12)
);
},
),
Spacer(),
],
),
],
),
),
);
},
);
},
),
), ),
], ],
), ),
); ),
}, const SizedBox(
), height: 16,
], ),
Expanded(
// Ensure GridView.builder is within an Expanded widget
child: BlocBuilder<GlobalLocationStoreCubit, GlobalLocationStoreState>(
bloc: GetIt.I.get<GlobalLocationStoreCubit>(),
builder: (sheetContext, state) {
// get map camera
final mapController = MapController.of(context);
final mapRotation = mapController.camera.rotation;
List<MapEntry<(String, String), Point>> locations = state.locations
.remove(user) // remove this
.entries // get entries into a list, and sort alphabetically
.toList()
..sort((a, b) => "${a.key.$1}:${a.key.$2}".compareTo("${b.key.$1}:${b.key.$2}"));
if (locations.isEmpty) {
return const SizedBox();
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1, crossAxisSpacing: 4.0, mainAxisSpacing: 4.0, mainAxisExtent: 100),
itemCount: locations.length,
itemBuilder: (BuildContext context, int index) {
// calculate distance and bearing
double distance = distanceBetween(curLocation.lat, curLocation.lon,
locations[index].value.lat, locations[index].value.lon, "meters");
double bearing = (bearingBetween(
curLocation.lat,
curLocation.lon,
locations[index].value.lat,
locations[index].value.lon,
) +
mapRotation) %
360;
print(distance);
print(bearing);
return Center(
child: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black,
border: Border.all(color: Colors.pink, width: 2),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Text(
"${locations[index].key.$1}:${locations[index].key.$2}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
const Spacer(),
Text(formatDistance(distance ~/ 1)),
Transform.rotate(
angle: bearing * (math.pi / 180),
child: const Icon(
Icons.arrow_upward,
color: Colors.blue,
),
)
],
),
Row(
children: [
StreamBuilder(
stream: Stream.periodic(const Duration(seconds: 1)),
builder: (context, _) {
return Text(
"${formatDuration(DateTime.now().difference(locations[index].value.timestamp))} ago",
style: const TextStyle(fontSize: 12));
},
),
const Spacer(),
],
),
],
),
),
);
},
);
},
),
),
],
);
},
), ),
); );
}, },

View File

@ -8,9 +8,7 @@ import 'package:rust_core/option.dart';
import 'package:ws/ws.dart'; import 'package:ws/ws.dart';
String _responseNiceErrorString(http.Response response) { String _responseNiceErrorString(http.Response response) {
return 'Request failed: ${response.statusCode}: ${response.body.length > 500 return 'Request failed: ${response.statusCode}: ${response.body.length > 500 ? response.body.substring(0, 500) : response.body}';
? response.body.substring(0, 500)
: response.body}';
} }
const API_PREFIX = '/api/0'; const API_PREFIX = '/api/0';
@ -26,8 +24,7 @@ class OwntracksApi {
final String username; final String username;
final String pass; final String pass;
static Map<String, String> _createBasicAuthHeader(String username, static Map<String, String> _createBasicAuthHeader(String username, String password,
String password,
[Map<String, String>? additionalHeaders]) { [Map<String, String>? additionalHeaders]) {
final credentials = base64Encode(utf8.encode('$username:$password')); final credentials = base64Encode(utf8.encode('$username:$password'));
final headers = { final headers = {
@ -42,6 +39,36 @@ class OwntracksApi {
return headers; return headers;
} }
Future<Result<Point>> fetchLastPoint({
required String user,
required String device,
}) async {
final queryParams = {
'user': user,
'device': device,
'fields': 'lat,lon,isotst',
};
final uri = Uri.parse('$baseUrl$API_PREFIX/last').replace(queryParameters: queryParams);
var auth_header = _createBasicAuthHeader(username, pass);
final response = await http.get(
uri,
headers: auth_header,
);
// print('${response.statusCode}: ${response.body.length > 500 ? response.body.substring(0, 500) : response.body}');
if (response.statusCode == 200) {
final point = Point.fromJson((json.decode(response.body) as List<dynamic>)[0]);
// print(point);
return Ok(point);
} else {
return bail("couldn't get point data for device '$user:$device': ${_responseNiceErrorString(response)}");
}
}
// Method to fetch points from a device within a specified date range // Method to fetch points from a device within a specified date range
Future<Result<List<Point>>> fetchPointsForDevice({ Future<Result<List<Point>>> fetchPointsForDevice({
required String user, required String user,
@ -57,8 +84,7 @@ class OwntracksApi {
'to': (to ?? DateTime.now()).toIso8601String(), 'to': (to ?? DateTime.now()).toIso8601String(),
'fields': 'lat,lon,isotst', 'fields': 'lat,lon,isotst',
}; };
final uri = Uri.parse('$baseUrl$API_PREFIX/locations') final uri = Uri.parse('$baseUrl$API_PREFIX/locations').replace(queryParameters: queryParams);
.replace(queryParameters: queryParams);
var auth_header = _createBasicAuthHeader(username, pass); var auth_header = _createBasicAuthHeader(username, pass);
@ -67,18 +93,13 @@ class OwntracksApi {
headers: auth_header, headers: auth_header,
); );
print( print('${response.statusCode}: ${response.body.length > 500 ? response.body.substring(0, 500) : response.body}');
'${response.statusCode}: ${response.body.length > 500 ? response.body
.substring(0, 500) : response.body}');
if (response.statusCode == 200) { if (response.statusCode == 200) {
final jsonData = final jsonData = (json.decode(response.body) as Map<String, dynamic>)['data'] as List;
(json.decode(response.body) as Map<String, dynamic>)['data'] as List;
return Ok(jsonData.map((point) => Point.fromJson(point)).toList()); return Ok(jsonData.map((point) => Point.fromJson(point)).toList());
} else { } else {
return bail( return bail("couldn't get point data for device '$user:$device': ${_responseNiceErrorString(response)}");
"couldn't get point data for device '$user:$device': ${_responseNiceErrorString(
response)}");
} }
} }
@ -91,9 +112,7 @@ class OwntracksApi {
final response = await http.get(uri, headers: authHeader); final response = await http.get(uri, headers: authHeader);
if (response.statusCode != 200) { if (response.statusCode != 200) {
return bail( return bail("couldn't get user list from server: ${_responseNiceErrorString(response)}");
"couldn't get user list from server: ${_responseNiceErrorString(
response)}");
} }
final users = List<String>.from(json.decode(response.body)['results']); final users = List<String>.from(json.decode(response.body)['results']);
@ -101,14 +120,10 @@ class OwntracksApi {
Map<String, List<String>> map = {}; Map<String, List<String>> map = {};
for (String user in users) { for (String user in users) {
var response = await http.get( var response = await http.get(uri.replace(queryParameters: {'user': user}), headers: authHeader);
uri.replace(queryParameters: {'user': user}),
headers: authHeader);
if (response.statusCode != 200) { if (response.statusCode != 200) {
return bail( return bail("couldn't get devices list for user '$user': ${_responseNiceErrorString(response)}");
"couldn't get devices list for user '$user': ${_responseNiceErrorString(
response)}");
} }
map[user] = List<String>.from(jsonDecode(response.body)['results']); map[user] = List<String>.from(jsonDecode(response.body)['results']);
@ -125,22 +140,21 @@ class OwntracksApi {
Option<(String, String)> onlyDeviceId = None, Option<(String, String)> onlyDeviceId = None,
}) async { }) async {
const retryInterval = ( const retryInterval = (
min: Duration(milliseconds: 500), min: Duration(milliseconds: 500),
max: Duration(seconds: 15), max: Duration(seconds: 15),
); );
Map<String, String> headers = {}; Map<String, String> headers = {};
if (onlyDeviceId case Some(:final v)){ if (onlyDeviceId case Some(:final v)) {
headers.putIfAbsent('X-Limit-User', () => v.$1); headers.putIfAbsent('X-Limit-User', () => v.$1);
headers.putIfAbsent('X-Limit-Device', () => v.$2); headers.putIfAbsent('X-Limit-Device', () => v.$2);
} }
final client = WebSocketClient(kIsWeb final client = WebSocketClient(kIsWeb
? WebSocketOptions.common(connectionRetryInterval: retryInterval) ? WebSocketOptions.common(connectionRetryInterval: retryInterval)
: WebSocketOptions.vm( : WebSocketOptions.vm(
connectionRetryInterval: retryInterval, connectionRetryInterval: retryInterval, headers: _createBasicAuthHeader(username, pass)..addAll(headers)));
headers: _createBasicAuthHeader(username, pass)..addAll(headers)));
// Listen to messages // Listen to messages
client.stream.listen(onMessage); client.stream.listen(onMessage);

View File

@ -2,21 +2,23 @@ import 'dart:convert';
import 'package:anyhow/base.dart'; import 'package:anyhow/base.dart';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:duration_picker/duration_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:ot_viewer_app/owntracks_api.dart'; import 'package:ot_viewer_app/owntracks_api.dart';
import 'package:ot_viewer_app/util.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class SettingsState { class SettingsState {
SettingsState({ SettingsState(
this.url = '', {this.url = '',
this.username = '', this.username = '',
this.password = '', this.password = '',
this.usersToDevices = const {}, this.usersToDevices = const {},
this.activeDevices = const {}, this.activeDevices = const {},
}); this.historyTime = const Duration(days: 1)});
final String url; final String url;
final String username; final String username;
@ -24,14 +26,12 @@ class SettingsState {
final Map<String, List<String>> usersToDevices; final Map<String, List<String>> usersToDevices;
final Set<(String, String)> activeDevices; final Set<(String, String)> activeDevices;
// Copy constructor final Duration historyTime;
SettingsState.copy(SettingsState source)
: url = source.url, @override
username = source.username, String toString() {
password = source.password, return 'SettingsState{url: $url, username: $username, password: $password, usersToDevices: $usersToDevices, activeDevices: $activeDevices, historyTime: $historyTime}';
usersToDevices = Map.from(source.usersToDevices) }
.map((key, value) => MapEntry(key, List.from(value))),
activeDevices = Set.from(source.activeDevices);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@ -42,7 +42,8 @@ class SettingsState {
username == other.username && username == other.username &&
password == other.password && password == other.password &&
usersToDevices == other.usersToDevices && usersToDevices == other.usersToDevices &&
activeDevices == other.activeDevices; activeDevices == other.activeDevices &&
historyTime == other.historyTime;
@override @override
int get hashCode => int get hashCode =>
@ -50,49 +51,10 @@ class SettingsState {
username.hashCode ^ username.hashCode ^
password.hashCode ^ password.hashCode ^
usersToDevices.hashCode ^ usersToDevices.hashCode ^
activeDevices.hashCode; activeDevices.hashCode ^
historyTime.hashCode;
@override
String toString() {
return 'SettingsState{url: $url, username: $username, password: $password, usersToDevices: $usersToDevices, activeDevices: $activeDevices}';
}
} }
/*
Future<void> loadFromSharedPrefs() async {
final sp = await SharedPreferences.getInstance();
final url = sp.getString('url') ?? '';
final username = sp.getString('username') ?? '';
final password = sp.getString('password') ?? '';
// Decode the JSON string and then manually convert to the expected type
final usersToDevicesJson =
jsonDecode(sp.getString('usersToDevices') ?? '{}')
as Map<String, dynamic>;
final Map<String, List<String>> usersToDevices =
usersToDevicesJson.map((key, value) {
// Ensure the value is cast to a List<String>
final List<String> list = List<String>.from(value);
return MapEntry(key, list);
});
// Decode the JSON string for activeDevices. Adjust this part as needed based on your actual data structure
final activeDevicesJson =
jsonDecode(sp.getString('activeDevices') ?? '[]') as List;
final List<(String, String)> activeDevices = List.from(activeDevicesJson);
emit(SettingsState(
url: url,
username: username,
password: password,
usersToDevices: usersToDevices,
activeDevices: activeDevices,
));
}
*/
class SettingsCubit extends HydratedCubit<SettingsState> { class SettingsCubit extends HydratedCubit<SettingsState> {
SettingsCubit() : super(SettingsState()); SettingsCubit() : super(SettingsState());
@ -134,26 +96,26 @@ class SettingsCubit extends HydratedCubit<SettingsState> {
@override @override
SettingsState? fromJson(Map<String, dynamic> json) { SettingsState? fromJson(Map<String, dynamic> json) {
// print("fromjson $json"); // print("fromjson $json");
final usersToDevicesMap = final usersToDevicesMap = (json['usersToDevices'] as Map<String, dynamic>?)?.map((key, value) {
(json['usersToDevices'] as Map<String, dynamic>?)?.map((key, value) { final List<String> list = List<String>.from(value as List);
final List<String> list = List<String>.from(value as List); return MapEntry(key, list);
return MapEntry(key, list); }) ??
}) ?? {};
{};
return SettingsState( return SettingsState(
url: (json['url'] ?? '') as String, url: (json['url'] ?? '') as String,
username: (json['username'] ?? '') as String, username: (json['username'] ?? '') as String,
password: (json['password'] ?? '') as String, password: (json['password'] ?? '') as String,
usersToDevices: usersToDevicesMap, usersToDevices: usersToDevicesMap,
activeDevices: Set<(String, String)>.from( activeDevices: Set<(String, String)>.from(List<List<String>>.from(json['activeDevices']).map((e) {
List<List<String>>.from(json['activeDevices']).map((e) { if (e.length != 2) {
if (e.length != 2) { return null;
return null; } else {
} else { return (e[0], e[1]);
return (e[0], e[1]); }
} }).where((element) => element != null)),
}).where((element) => element != null))); historyTime: Duration(hours: (json['historyTime'] ?? '24') as int),
);
} }
@override @override
@ -165,6 +127,7 @@ class SettingsCubit extends HydratedCubit<SettingsState> {
'password': state.password, 'password': state.password,
'usersToDevices': state.usersToDevices, 'usersToDevices': state.usersToDevices,
'activeDevices': state.activeDevices.map((e) => [e.$1, e.$2]).toList(), 'activeDevices': state.activeDevices.map((e) => [e.$1, e.$2]).toList(),
'historyTime': state.historyTime.inHours,
}; };
} }
@ -174,6 +137,7 @@ class SettingsCubit extends HydratedCubit<SettingsState> {
String? password, String? password,
Map<String, List<String>>? usersToDevices, Map<String, List<String>>? usersToDevices,
Set<(String, String)>? activeDevices, Set<(String, String)>? activeDevices,
Duration? historyTime,
}) async { }) async {
final currentState = state; final currentState = state;
@ -184,6 +148,7 @@ class SettingsCubit extends HydratedCubit<SettingsState> {
password: password ?? currentState.password, password: password ?? currentState.password,
usersToDevices: usersToDevices ?? currentState.usersToDevices, usersToDevices: usersToDevices ?? currentState.usersToDevices,
activeDevices: activeDevices ?? currentState.activeDevices, activeDevices: activeDevices ?? currentState.activeDevices,
historyTime: historyTime ?? currentState.historyTime,
)); ));
} }
} }
@ -228,8 +193,7 @@ class _SettingsPageState extends State<SettingsPage> {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(color: Colors.redAccent.withOpacity(0.8), width: 2),
color: Colors.redAccent.withOpacity(0.8), width: 2),
borderRadius: BorderRadius.circular(10)), borderRadius: BorderRadius.circular(10)),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -238,12 +202,10 @@ class _SettingsPageState extends State<SettingsPage> {
TextField( TextField(
controller: _urlController, controller: _urlController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: labelText: 'URL (e.g. https://your-owntracks.host.com)',
'URL (e.g. https://your-owntracks.host.com)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
// Adds a border to the TextField // Adds a border to the TextField
prefixIcon: Icon( prefixIcon: Icon(Icons.account_tree), // Adds an icon to the left
Icons.account_tree), // Adds an icon to the left
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -254,8 +216,7 @@ class _SettingsPageState extends State<SettingsPage> {
labelText: 'Username', labelText: 'Username',
border: OutlineInputBorder(), border: OutlineInputBorder(),
// Adds a border to the TextField // Adds a border to the TextField
prefixIcon: prefixIcon: Icon(Icons.person), // Adds an icon to the left
Icon(Icons.person), // Adds an icon to the left
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -268,8 +229,7 @@ class _SettingsPageState extends State<SettingsPage> {
labelText: 'Password', labelText: 'Password',
border: OutlineInputBorder(), border: OutlineInputBorder(),
// Adds a border to the TextField // Adds a border to the TextField
prefixIcon: prefixIcon: Icon(Icons.lock), // Adds an icon to the left
Icon(Icons.lock), // Adds an icon to the left
), ),
), ),
const SizedBox( const SizedBox(
@ -278,8 +238,7 @@ class _SettingsPageState extends State<SettingsPage> {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.warning, const Icon(Icons.warning, color: Colors.red, size: 16),
color: Colors.red, size: 16),
// Small red exclamation mark icon // Small red exclamation mark icon
const SizedBox(width: 4), const SizedBox(width: 4),
// Space between icon and text // Space between icon and text
@ -287,8 +246,7 @@ class _SettingsPageState extends State<SettingsPage> {
'Password saved locally', 'Password saved locally',
style: TextStyle( style: TextStyle(
color: Colors.red, color: Colors.red,
fontSize: fontSize: 12, // Small font size for the warning text
12, // Small font size for the warning text
), ),
), ),
const Spacer(), const Spacer(),
@ -300,37 +258,63 @@ class _SettingsPageState extends State<SettingsPage> {
username: _usernameController.text, username: _usernameController.text,
password: _passwordController.text, password: _passwordController.text,
); );
context.read<SettingsCubit>().fetchDevices(); context.read<SettingsCubit>().fetchDevices();
}, },
icon: Icon(Icons.save), icon: const Icon(Icons.save),
label: Text('Save')) label: const Text('Save'))
], ],
), ),
]), ]),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
final settingsCubit = context.read<SettingsCubit>();
var resultingDuration = await showDurationPicker(
context: context,
initialTime: state.historyTime,
snapToMins: 60,
baseUnit: BaseUnit.hour,
);
if (resultingDuration != null) {
if (resultingDuration.inHours == 0) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Please pick a duration of 1h or more!')));
}
} else {
settingsCubit.updateSettings(historyTime: resultingDuration);
}
if (resultingDuration.inDays > 4) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Warning:\nLong durations might cause slowdowns and high data usage!')));
}
}
}
},
child: Text('Set Time to Load Points (current: ${formatDuration(state.historyTime)})')),
const SizedBox(height: 16),
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(color: Colors.deepPurple.withOpacity(0.8), width: 2),
color: Colors.deepPurple.withOpacity(0.8), width: 2),
borderRadius: BorderRadius.circular(10)), borderRadius: BorderRadius.circular(10)),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: List<Widget>.from([ children: List<Widget>.from([
Center( Center(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => onPressed: () => context.read<SettingsCubit>().fetchDevices(),
context.read<SettingsCubit>().fetchDevices(),
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('Refresh Devices'), label: const Text('Refresh Devices'),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
]) + ]) +
List<Widget>.from( List<Widget>.from(state.usersToDevices.entries.map((entry) {
state.usersToDevices.entries.map((entry) {
return Column( return Column(
children: [ children: [
Row( Row(
@ -349,26 +333,20 @@ class _SettingsPageState extends State<SettingsPage> {
const Divider(), const Divider(),
Container( Container(
padding: const EdgeInsets.only(left: 8), padding: const EdgeInsets.only(left: 8),
child: Column(children: child: Column(children: List<Widget>.from(entry.value.map((device) {
List<Widget>.from(entry.value.map((device) {
return Row( return Row(
children: [ children: [
Checkbox( Checkbox(
value: state.activeDevices value: state.activeDevices.contains((entry.key, device)),
.contains((entry.key, device)),
onChanged: (newVal) { onChanged: (newVal) {
var cubit = var cubit = context.read<SettingsCubit>();
context.read<SettingsCubit>(); Set<(String, String)> newSet = Set.from(state.activeDevices);
Set<(String, String)> newSet =
Set.from(state.activeDevices);
if (newVal ?? false) { if (newVal ?? false) {
newSet.add((entry.key, device)); newSet.add((entry.key, device));
} else { } else {
newSet newSet.remove((entry.key, device));
.remove((entry.key, device));
} }
cubit.updateSettings( cubit.updateSettings(activeDevices: newSet);
activeDevices: newSet);
}), }),
const SizedBox(width: 8), const SizedBox(width: 8),
Text("${entry.key}:$device"), Text("${entry.key}:$device"),

View File

@ -22,20 +22,30 @@ class UserPathBloc extends Bloc<UserPathEvent, UserPathState> {
SettingsState settingsState; SettingsState settingsState;
Option<WebSocketClient> _ws = None; Option<WebSocketClient> _ws = None;
OwntracksApi get _api => OwntracksApi( OwntracksApi get _api =>
baseUrl: settingsState.url, OwntracksApi(baseUrl: settingsState.url, username: settingsState.username, pass: settingsState.password);
username: settingsState.username,
pass: settingsState.password);
UserPathBloc(this.deviceId, this.settingsState) UserPathBloc(this.deviceId, this.settingsState)
: super(MainUserPathState( : super(MainUserPathState(
initialPoints: const IListConst([]), initialPoints: const IListConst([]),
livePoints: const IListConst([]), livePoints: const IListConst([]),
from: DateTime.now().subtract(const Duration(days: 1)), from: DateTime.now().subtract(settingsState.historyTime),
to: DateTime.now().add(const Duration(days: 365 * 100)))) { to: DateTime.now())) /*.add(const Duration(days: 365 * 100)))) */ {
on<UserPathLoginDataChanged>((event, emit) { on<UserPathLoginDataChanged>((event, emit) {
if (event.newSettings == settingsState) {
print('settings states the same, returning. states:\nold: $settingsState\nnew: ${event.newSettings}');
return;
}
settingsState = event.newSettings; settingsState = event.newSettings;
// TODO: restart live connections
emit(MainUserPathState.copy(
state as MainUserPathState,
from: DateTime.now().subtract(settingsState.historyTime),
));
print('settings states differ, doing full update');
add(UserPathFullUpdate());
}); });
on<UserPathLiveSubscriptionUpdate>((event, emit) { on<UserPathLiveSubscriptionUpdate>((event, emit) {
@ -51,21 +61,28 @@ class UserPathBloc extends Bloc<UserPathEvent, UserPathState> {
print("fpu"); print("fpu");
if (state is MainUserPathState) { if (state is MainUserPathState) {
final istate = state as MainUserPathState; final istate = state as MainUserPathState;
final history = await _api.fetchPointsForDevice( var history = await _api.fetchPointsForDevice(
user: deviceId.$1, user: deviceId.$1,
device: deviceId.$2, device: deviceId.$2,
from: istate.from, from: istate.from,
to: istate.to, to: istate.to,
); );
final Result<List<Point>> livePoints = history = await history.toFutureResult().map((h) async {
history.map((ok) => ok.isNotEmpty ? [ok.last] : []); if (h.isEmpty) {
final last = await _api.fetchLastPoint(user: deviceId.$1, device: deviceId.$2);
if (last.isOk()) {
return [last.unwrap()];
}
}
return h;
});
final Result<List<Point>> livePoints = history.map((ok) => ok.isNotEmpty ? [ok.last] : []);
emit(MainUserPathState( emit(MainUserPathState(
initialPoints: initialPoints: history.expect("Couldn't retrieve path history for $deviceId").lock,
history.expect("Couldn't retrieve path history for $deviceId").lock, livePoints: livePoints.expect("Couldn\'t retrieve last (current) point").lock,
livePoints:
livePoints.expect("Couldn\'t retrieve last (current) point").lock,
from: istate.from, from: istate.from,
to: istate.to)); to: istate.to));
} }

View File

@ -97,6 +97,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "3.2.2"
duration_picker:
dependency: "direct main"
description:
name: duration_picker
sha256: "052b34dac04c29f3849bb3817a26c5aebe9e5f0697c3a374be87db2b84d75753"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View File

@ -55,6 +55,7 @@ dependencies:
flutter_map_math: ^0.1.7 flutter_map_math: ^0.1.7
intl: ^0.19.0 intl: ^0.19.0
get_it: ^7.6.7 get_it: ^7.6.7
duration_picker: ^1.1.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: