From fa129f17fb9390f3ddacfde21238f166f4ad98dd Mon Sep 17 00:00:00 2001 From: Yandrik Date: Sun, 17 Mar 2024 21:17:09 +0100 Subject: [PATCH] feat: added datepicker, better refresh, and lots of fixes --- lib/map_page.dart | 315 ++++++++++++++++++---------------------- lib/owntracks_api.dart | 78 ++++++---- lib/settings_page.dart | 198 +++++++++++-------------- lib/user_path_bloc.dart | 45 ++++-- pubspec.lock | 8 + pubspec.yaml | 1 + 6 files changed, 319 insertions(+), 326 deletions(-) diff --git a/lib/map_page.dart b/lib/map_page.dart index ba172cc..f863a9b 100644 --- a/lib/map_page.dart +++ b/lib/map_page.dart @@ -40,23 +40,6 @@ class _MapPageState extends State { // _fetchPoints(); } - /* - Future _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 Widget build(BuildContext context) { return BlocProvider( @@ -71,6 +54,9 @@ class _MapPageState extends State { future: getPath(), builder: (something, tempPath) => BlocBuilder( builder: (context, state) { + if (tempPath.data == null) { + return const Center(child: Text('Loading Map...')); + } return FlutterMap( options: const MapOptions( initialCenter: LatLng(48.3285, 9.8942), @@ -81,9 +67,9 @@ class _MapPageState extends State { urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', tileProvider: CachedTileProvider( 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)), // CurrentLocationLayer(), TODO: add permission RichAttributionWidget( @@ -104,7 +90,7 @@ class _MapPageState extends State { } class UserPath extends StatefulWidget { - UserPath({required this.device, required this.settings}); + UserPath({super.key, required this.device, required this.settings}); (String, String) device; SettingsState settings; @@ -116,10 +102,12 @@ class UserPath extends StatefulWidget { class _UserPathState extends State { @override Widget build(BuildContext ctx) { + print('rebuilding widget for ${widget.device}'); return BlocProvider( create: (context) { final bloc = UserPathBloc(widget.device, widget.settings); bloc.add(UserPathFullUpdate()); + // ONLY WORKS because gets rebuilt every time with settings update return bloc; }, child: BlocListener( @@ -128,12 +116,15 @@ class _UserPathState extends State { UserPathBloc userPathBloc = context.read(); if (state case LocationUpdateReceived(:final position, :final deviceId)) { if (userPathBloc.deviceId == state.deviceId) { - context.read().add(UserPathLiveSubscriptionUpdate(position)); + userPathBloc.add(UserPathLiveSubscriptionUpdate(position)); } } }, child: BlocBuilder( builder: (context, state) { + // TODO: change once smarter rebuilds are ready + context.read().add(UserPathLoginDataChanged(widget.settings)); + print("rebuild"); final _istate = state as MainUserPathState; // make markers @@ -211,6 +202,7 @@ class _UserPathState extends State { return Stack( children: [ PolylineLayer( + key: ValueKey('${widget.device}_historyLines'), polylines: [ /* Polyline( @@ -225,7 +217,7 @@ class _UserPathState extends State { ...polylines ], ), - PolylineLayer(polylines: [ + PolylineLayer(key: ValueKey('${widget.device}_liveLines'), polylines: [ Polyline( points: state.livePoints.map((e) => LatLng(e.lat, e.lon)).toList(), strokeWidth: 4.0, @@ -247,165 +239,148 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { context: context, builder: (bsContext) { return Container( - height: MediaQuery.of(bsContext).size.height * 0.5, + // height: MediaQuery.of(bsContext).size.height * 0.5, width: MediaQuery.of(bsContext).size.width, + height: 500, decoration: const BoxDecoration( color: Colors.black, borderRadius: BorderRadius.only(topLeft: Radius.circular(30), topRight: Radius.circular(30)), ), - padding: EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - // Wrap non-grid content in Flexible to manage space dynamically - child: Text( - '${user.$1}:${user.$2}', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, + padding: const EdgeInsets.all(32), + child: StreamBuilder( + stream: context.read().stream, + builder: (sheetContext, state) { + final istate = state.data as MainUserPathState? ?? context.read().state as MainUserPathState; + if (istate.livePoints.isEmpty) { + return Text("Couldn't find ${user.$1}:${user.$2}'s Location"); + } + final curLocation = istate.livePoints.last; + return Column( + // mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${user.$1}:${user.$2}', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), ), - ), - ), - const SizedBox(height: 16), - StreamBuilder( - stream: context.read().stream, - builder: (sheetContext, state) { - final istate = - state.data as MainUserPathState? ?? context.read().state as MainUserPathState; - 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 + Container( + decoration: BoxDecoration( + color: Colors.black, + border: Border.all(color: Colors.orange, width: 2), + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.all(16), + width: double.infinity, child: Column( - mainAxisSize: MainAxisSize.min, children: [ - Container( - decoration: BoxDecoration( - color: Colors.black, - border: Border.all(color: Colors.orange, width: 2), - borderRadius: BorderRadius.circular(10), - ), - 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( - bloc: GetIt.I.get(), - builder: (sheetContext, state) { - // get map camera - final mapController = MapController.of(context); - - final mapRotation = mapController.camera.rotation; - - List> 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(), - ], - ), - ], - ), - ), - ); - }, - ); - }, - ), + 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( + bloc: GetIt.I.get(), + builder: (sheetContext, state) { + // get map camera + final mapController = MapController.of(context); + + final mapRotation = mapController.camera.rotation; + + List> 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(), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + }, ), ); }, diff --git a/lib/owntracks_api.dart b/lib/owntracks_api.dart index c670149..3ddb571 100644 --- a/lib/owntracks_api.dart +++ b/lib/owntracks_api.dart @@ -8,9 +8,7 @@ import 'package:rust_core/option.dart'; import 'package:ws/ws.dart'; String _responseNiceErrorString(http.Response response) { - return 'Request failed: ${response.statusCode}: ${response.body.length > 500 - ? response.body.substring(0, 500) - : response.body}'; + return 'Request failed: ${response.statusCode}: ${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'; } const API_PREFIX = '/api/0'; @@ -26,8 +24,7 @@ class OwntracksApi { final String username; final String pass; - static Map _createBasicAuthHeader(String username, - String password, + static Map _createBasicAuthHeader(String username, String password, [Map? additionalHeaders]) { final credentials = base64Encode(utf8.encode('$username:$password')); final headers = { @@ -42,6 +39,36 @@ class OwntracksApi { return headers; } + Future> 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)[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 Future>> fetchPointsForDevice({ required String user, @@ -57,8 +84,7 @@ class OwntracksApi { 'to': (to ?? DateTime.now()).toIso8601String(), 'fields': 'lat,lon,isotst', }; - final uri = Uri.parse('$baseUrl$API_PREFIX/locations') - .replace(queryParameters: queryParams); + final uri = Uri.parse('$baseUrl$API_PREFIX/locations').replace(queryParameters: queryParams); var auth_header = _createBasicAuthHeader(username, pass); @@ -67,18 +93,13 @@ class OwntracksApi { headers: auth_header, ); - print( - '${response.statusCode}: ${response.body.length > 500 ? response.body - .substring(0, 500) : response.body}'); + print('${response.statusCode}: ${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); if (response.statusCode == 200) { - final jsonData = - (json.decode(response.body) as Map)['data'] as List; + final jsonData = (json.decode(response.body) as Map)['data'] as List; return Ok(jsonData.map((point) => Point.fromJson(point)).toList()); } else { - return bail( - "couldn't get point data for device '$user:$device': ${_responseNiceErrorString( - response)}"); + return bail("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); if (response.statusCode != 200) { - return bail( - "couldn't get user list from server: ${_responseNiceErrorString( - response)}"); + return bail("couldn't get user list from server: ${_responseNiceErrorString(response)}"); } final users = List.from(json.decode(response.body)['results']); @@ -101,14 +120,10 @@ class OwntracksApi { Map> map = {}; for (String user in users) { - var response = await http.get( - uri.replace(queryParameters: {'user': user}), - headers: authHeader); + var response = await http.get(uri.replace(queryParameters: {'user': user}), headers: authHeader); if (response.statusCode != 200) { - return bail( - "couldn't get devices list for user '$user': ${_responseNiceErrorString( - response)}"); + return bail("couldn't get devices list for user '$user': ${_responseNiceErrorString(response)}"); } map[user] = List.from(jsonDecode(response.body)['results']); @@ -125,22 +140,21 @@ class OwntracksApi { Option<(String, String)> onlyDeviceId = None, }) async { const retryInterval = ( - min: Duration(milliseconds: 500), - max: Duration(seconds: 15), + min: Duration(milliseconds: 500), + max: Duration(seconds: 15), ); Map headers = {}; - if (onlyDeviceId case Some(:final v)){ - headers.putIfAbsent('X-Limit-User', () => v.$1); - headers.putIfAbsent('X-Limit-Device', () => v.$2); + if (onlyDeviceId case Some(:final v)) { + headers.putIfAbsent('X-Limit-User', () => v.$1); + headers.putIfAbsent('X-Limit-Device', () => v.$2); } final client = WebSocketClient(kIsWeb - ? WebSocketOptions.common(connectionRetryInterval: retryInterval) + ? WebSocketOptions.common(connectionRetryInterval: retryInterval) : WebSocketOptions.vm( - connectionRetryInterval: retryInterval, - headers: _createBasicAuthHeader(username, pass)..addAll(headers))); + connectionRetryInterval: retryInterval, headers: _createBasicAuthHeader(username, pass)..addAll(headers))); // Listen to messages client.stream.listen(onMessage); diff --git a/lib/settings_page.dart b/lib/settings_page.dart index 8150399..1bc75ea 100644 --- a/lib/settings_page.dart +++ b/lib/settings_page.dart @@ -2,21 +2,23 @@ import 'dart:convert'; import 'package:anyhow/base.dart'; import 'package:bloc/bloc.dart'; +import 'package:duration_picker/duration_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:ot_viewer_app/owntracks_api.dart'; +import 'package:ot_viewer_app/util.dart'; import 'package:shared_preferences/shared_preferences.dart'; class SettingsState { - SettingsState({ - this.url = '', - this.username = '', - this.password = '', - this.usersToDevices = const {}, - this.activeDevices = const {}, - }); + SettingsState( + {this.url = '', + this.username = '', + this.password = '', + this.usersToDevices = const {}, + this.activeDevices = const {}, + this.historyTime = const Duration(days: 1)}); final String url; final String username; @@ -24,14 +26,12 @@ class SettingsState { final Map> usersToDevices; final Set<(String, String)> activeDevices; - // Copy constructor - SettingsState.copy(SettingsState source) - : url = source.url, - username = source.username, - password = source.password, - usersToDevices = Map.from(source.usersToDevices) - .map((key, value) => MapEntry(key, List.from(value))), - activeDevices = Set.from(source.activeDevices); + final Duration historyTime; + + @override + String toString() { + return 'SettingsState{url: $url, username: $username, password: $password, usersToDevices: $usersToDevices, activeDevices: $activeDevices, historyTime: $historyTime}'; + } @override bool operator ==(Object other) => @@ -42,7 +42,8 @@ class SettingsState { username == other.username && password == other.password && usersToDevices == other.usersToDevices && - activeDevices == other.activeDevices; + activeDevices == other.activeDevices && + historyTime == other.historyTime; @override int get hashCode => @@ -50,49 +51,10 @@ class SettingsState { username.hashCode ^ password.hashCode ^ usersToDevices.hashCode ^ - activeDevices.hashCode; - - @override - String toString() { - return 'SettingsState{url: $url, username: $username, password: $password, usersToDevices: $usersToDevices, activeDevices: $activeDevices}'; - } + activeDevices.hashCode ^ + historyTime.hashCode; } -/* - Future 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; - final Map> usersToDevices = - usersToDevicesJson.map((key, value) { - // Ensure the value is cast to a List - final List list = List.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 { SettingsCubit() : super(SettingsState()); @@ -134,26 +96,26 @@ class SettingsCubit extends HydratedCubit { @override SettingsState? fromJson(Map json) { // print("fromjson $json"); - final usersToDevicesMap = - (json['usersToDevices'] as Map?)?.map((key, value) { - final List list = List.from(value as List); - return MapEntry(key, list); - }) ?? - {}; + final usersToDevicesMap = (json['usersToDevices'] as Map?)?.map((key, value) { + final List list = List.from(value as List); + return MapEntry(key, list); + }) ?? + {}; return SettingsState( - url: (json['url'] ?? '') as String, - username: (json['username'] ?? '') as String, - password: (json['password'] ?? '') as String, - usersToDevices: usersToDevicesMap, - activeDevices: Set<(String, String)>.from( - List>.from(json['activeDevices']).map((e) { - if (e.length != 2) { - return null; - } else { - return (e[0], e[1]); - } - }).where((element) => element != null))); + url: (json['url'] ?? '') as String, + username: (json['username'] ?? '') as String, + password: (json['password'] ?? '') as String, + usersToDevices: usersToDevicesMap, + activeDevices: Set<(String, String)>.from(List>.from(json['activeDevices']).map((e) { + if (e.length != 2) { + return null; + } else { + return (e[0], e[1]); + } + }).where((element) => element != null)), + historyTime: Duration(hours: (json['historyTime'] ?? '24') as int), + ); } @override @@ -165,6 +127,7 @@ class SettingsCubit extends HydratedCubit { 'password': state.password, 'usersToDevices': state.usersToDevices, 'activeDevices': state.activeDevices.map((e) => [e.$1, e.$2]).toList(), + 'historyTime': state.historyTime.inHours, }; } @@ -174,6 +137,7 @@ class SettingsCubit extends HydratedCubit { String? password, Map>? usersToDevices, Set<(String, String)>? activeDevices, + Duration? historyTime, }) async { final currentState = state; @@ -184,6 +148,7 @@ class SettingsCubit extends HydratedCubit { password: password ?? currentState.password, usersToDevices: usersToDevices ?? currentState.usersToDevices, activeDevices: activeDevices ?? currentState.activeDevices, + historyTime: historyTime ?? currentState.historyTime, )); } } @@ -228,8 +193,7 @@ class _SettingsPageState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - border: Border.all( - color: Colors.redAccent.withOpacity(0.8), width: 2), + border: Border.all(color: Colors.redAccent.withOpacity(0.8), width: 2), borderRadius: BorderRadius.circular(10)), child: Column( mainAxisSize: MainAxisSize.min, @@ -238,12 +202,10 @@ class _SettingsPageState extends State { TextField( controller: _urlController, decoration: const InputDecoration( - labelText: - 'URL (e.g. https://your-owntracks.host.com)', + labelText: 'URL (e.g. https://your-owntracks.host.com)', border: OutlineInputBorder(), // Adds a border to the TextField - prefixIcon: Icon( - Icons.account_tree), // Adds an icon to the left + prefixIcon: Icon(Icons.account_tree), // Adds an icon to the left ), ), const SizedBox(height: 10), @@ -254,8 +216,7 @@ class _SettingsPageState extends State { labelText: 'Username', border: OutlineInputBorder(), // Adds a border to the TextField - prefixIcon: - Icon(Icons.person), // Adds an icon to the left + prefixIcon: Icon(Icons.person), // Adds an icon to the left ), ), const SizedBox(height: 10), @@ -268,8 +229,7 @@ class _SettingsPageState extends State { labelText: 'Password', border: OutlineInputBorder(), // Adds a border to the TextField - prefixIcon: - Icon(Icons.lock), // Adds an icon to the left + prefixIcon: Icon(Icons.lock), // Adds an icon to the left ), ), const SizedBox( @@ -278,8 +238,7 @@ class _SettingsPageState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.warning, - color: Colors.red, size: 16), + const Icon(Icons.warning, color: Colors.red, size: 16), // Small red exclamation mark icon const SizedBox(width: 4), // Space between icon and text @@ -287,8 +246,7 @@ class _SettingsPageState extends State { 'Password saved locally', style: TextStyle( color: Colors.red, - fontSize: - 12, // Small font size for the warning text + fontSize: 12, // Small font size for the warning text ), ), const Spacer(), @@ -300,37 +258,63 @@ class _SettingsPageState extends State { username: _usernameController.text, password: _passwordController.text, ); - + context.read().fetchDevices(); }, - icon: Icon(Icons.save), - label: Text('Save')) + icon: const Icon(Icons.save), + label: const Text('Save')) ], ), ]), ), const SizedBox(height: 16), + ElevatedButton( + onPressed: () async { + final settingsCubit = context.read(); + 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( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - border: Border.all( - color: Colors.deepPurple.withOpacity(0.8), width: 2), + border: Border.all(color: Colors.deepPurple.withOpacity(0.8), width: 2), borderRadius: BorderRadius.circular(10)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: List.from([ Center( child: ElevatedButton.icon( - onPressed: () => - context.read().fetchDevices(), + onPressed: () => context.read().fetchDevices(), icon: const Icon(Icons.refresh), label: const Text('Refresh Devices'), ), ), const SizedBox(height: 16), ]) + - List.from( - state.usersToDevices.entries.map((entry) { + List.from(state.usersToDevices.entries.map((entry) { return Column( children: [ Row( @@ -349,26 +333,20 @@ class _SettingsPageState extends State { const Divider(), Container( padding: const EdgeInsets.only(left: 8), - child: Column(children: - List.from(entry.value.map((device) { + child: Column(children: List.from(entry.value.map((device) { return Row( children: [ Checkbox( - value: state.activeDevices - .contains((entry.key, device)), + value: state.activeDevices.contains((entry.key, device)), onChanged: (newVal) { - var cubit = - context.read(); - Set<(String, String)> newSet = - Set.from(state.activeDevices); + var cubit = context.read(); + Set<(String, String)> newSet = Set.from(state.activeDevices); if (newVal ?? false) { newSet.add((entry.key, device)); } else { - newSet - .remove((entry.key, device)); + newSet.remove((entry.key, device)); } - cubit.updateSettings( - activeDevices: newSet); + cubit.updateSettings(activeDevices: newSet); }), const SizedBox(width: 8), Text("${entry.key}:$device"), diff --git a/lib/user_path_bloc.dart b/lib/user_path_bloc.dart index f047be5..a712c4f 100644 --- a/lib/user_path_bloc.dart +++ b/lib/user_path_bloc.dart @@ -22,20 +22,30 @@ class UserPathBloc extends Bloc { SettingsState settingsState; Option _ws = None; - OwntracksApi get _api => OwntracksApi( - baseUrl: settingsState.url, - username: settingsState.username, - pass: settingsState.password); + OwntracksApi get _api => + OwntracksApi(baseUrl: settingsState.url, username: settingsState.username, pass: settingsState.password); UserPathBloc(this.deviceId, this.settingsState) : super(MainUserPathState( initialPoints: const IListConst([]), livePoints: const IListConst([]), - from: DateTime.now().subtract(const Duration(days: 1)), - to: DateTime.now().add(const Duration(days: 365 * 100)))) { + from: DateTime.now().subtract(settingsState.historyTime), + to: DateTime.now())) /*.add(const Duration(days: 365 * 100)))) */ { on((event, emit) { + if (event.newSettings == settingsState) { + print('settings states the same, returning. states:\nold: $settingsState\nnew: ${event.newSettings}'); + return; + } 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((event, emit) { @@ -51,21 +61,28 @@ class UserPathBloc extends Bloc { print("fpu"); if (state is MainUserPathState) { final istate = state as MainUserPathState; - final history = await _api.fetchPointsForDevice( + var history = await _api.fetchPointsForDevice( user: deviceId.$1, device: deviceId.$2, from: istate.from, to: istate.to, ); - final Result> livePoints = - history.map((ok) => ok.isNotEmpty ? [ok.last] : []); + history = await history.toFutureResult().map((h) async { + if (h.isEmpty) { + final last = await _api.fetchLastPoint(user: deviceId.$1, device: deviceId.$2); + if (last.isOk()) { + return [last.unwrap()]; + } + } + return h; + }); + + final Result> livePoints = history.map((ok) => ok.isNotEmpty ? [ok.last] : []); emit(MainUserPathState( - initialPoints: - history.expect("Couldn't retrieve path history for $deviceId").lock, - livePoints: - livePoints.expect("Couldn\'t retrieve last (current) point").lock, + initialPoints: history.expect("Couldn't retrieve path history for $deviceId").lock, + livePoints: livePoints.expect("Couldn\'t retrieve last (current) point").lock, from: istate.from, to: istate.to)); } diff --git a/pubspec.lock b/pubspec.lock index d228d8a..0ce4d06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5b0e4cb..5ddb6aa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: flutter_map_math: ^0.1.7 intl: ^0.19.0 get_it: ^7.6.7 + duration_picker: ^1.1.1 dev_dependencies: flutter_test: