diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8fcad01..df90151 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + locations; + + GlobalLocationStoreState({required this.locations}); + + @override + String toString() => 'GlobalLocationStoreState(locations: $locations)'; +} + +// Define the Cubit +class GlobalLocationStoreCubit extends Cubit { + GlobalLocationStoreCubit() : super(GlobalLocationStoreState(locations: IMap(const {}))); + + // Function to update the points + void updatePoint(String user, String device, Point point) { + // Create a new map with the updated point + final updatedLocations = state.locations.add((user, device), point); + // Emit the new state + emit(GlobalLocationStoreState(locations: updatedLocations)); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 91061d6..93bb58c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,9 +2,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:ot_viewer_app/global_location_store.dart'; import 'package:ot_viewer_app/settings_page.dart'; import 'package:path_provider/path_provider.dart'; import 'map_page.dart'; +import 'package:get_it/get_it.dart'; import 'owntracks_api.dart'; Future main() async { @@ -16,6 +18,8 @@ Future main() async { : await getApplicationDocumentsDirectory(), ); + GetIt.I + .registerSingleton(GlobalLocationStoreCubit()); runApp(MyApp()); } @@ -28,7 +32,7 @@ class MyApp extends StatelessWidget { theme: ThemeData.dark(), home: Scaffold( appBar: AppBar(title: const Text('OwnTrakcs Data Viewer')), - body: MainPage(), + body: const MainPage(), ), ); } @@ -45,7 +49,7 @@ class _MainPageState extends State { int _currentIndex = 0; final List _pages = [ const MapPage(), - SettingsPage(), // Assume this is your settings page widget + const SettingsPage(), // Assume this is your settings page widget ]; void _onItemTapped(int index) { diff --git a/lib/map_page.dart b/lib/map_page.dart index f37f754..ba172cc 100644 --- a/lib/map_page.dart +++ b/lib/map_page.dart @@ -1,5 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -7,8 +8,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_cache/flutter_map_cache.dart'; import 'package:flutter_map_compass/flutter_map_compass.dart'; +import 'package:flutter_map_math/flutter_geo_math.dart'; +import 'package:get_it/get_it.dart'; import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; +import 'package:ot_viewer_app/global_location_store.dart'; import 'package:ot_viewer_app/settings_page.dart'; import 'package:ot_viewer_app/user_path_bloc.dart'; import 'package:ot_viewer_app/util.dart'; @@ -58,15 +62,14 @@ class _MapPageState extends State { return BlocProvider( create: (context) { final cubit = LocationSubscribeCubit(); - final settings_cubit = context.read(); - cubit.subscribe(settings_cubit.state); - settings_cubit.stream.listen((settings) => cubit.subscribe(settings)); + final settingsCubit = context.read(); + cubit.subscribe(settingsCubit.state); + settingsCubit.stream.listen((settings) => cubit.subscribe(settings)); return cubit; }, child: FutureBuilder( future: getPath(), - builder: (something, temp_path) => - BlocBuilder( + builder: (something, tempPath) => BlocBuilder( builder: (context, state) { return FlutterMap( options: const MapOptions( @@ -78,20 +81,16 @@ class _MapPageState extends State { urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', tileProvider: CachedTileProvider( maxStale: const Duration(days: 30), - store: HiveCacheStore(temp_path.data, - hiveBoxName: 'HiveCacheStore')), + store: HiveCacheStore(tempPath.data, hiveBoxName: 'HiveCacheStore')), ), - ...state.activeDevices - .map((id) => UserPath(device: id, settings: state)), - const MapCompass.cupertino( - rotationDuration: Duration(milliseconds: 600)), + ...state.activeDevices.map((id) => UserPath(device: id, settings: state)), + const MapCompass.cupertino(rotationDuration: Duration(milliseconds: 600)), // CurrentLocationLayer(), TODO: add permission RichAttributionWidget( attributions: [ TextSourceAttribution( 'OpenStreetMap contributors', - onTap: () => - (Uri.parse('https://openstreetmap.org/copyright')), + onTap: () => (Uri.parse('https://openstreetmap.org/copyright')), ), ], ), @@ -124,14 +123,12 @@ class _UserPathState extends State { return bloc; }, child: BlocListener( + listenWhen: (prevState, state) => state is LocationUpdateReceived, listener: (context, state) { UserPathBloc userPathBloc = context.read(); - if (state - case LocationUpdateReceived(:final position, :final deviceId)) { + if (state case LocationUpdateReceived(:final position, :final deviceId)) { if (userPathBloc.deviceId == state.deviceId) { - context - .read() - .add(UserPathLiveSubscriptionUpdate(position)); + context.read().add(UserPathLiveSubscriptionUpdate(position)); } } }, @@ -140,10 +137,10 @@ class _UserPathState extends State { print("rebuild"); final _istate = state as MainUserPathState; // make markers - final List _markers = []; + final List markers = []; if (state.livePoints.isNotEmpty) { - _markers.add(Marker( + markers.add(Marker( width: 500, height: 100, point: state.livePoints.last.asLatLng, @@ -151,25 +148,23 @@ class _UserPathState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ GestureDetector( - onTap: () => showUserLocationModalBottomSheet( - context, widget.device - ), + onTap: () => showUserLocationModalBottomSheet(context, widget.device), child: Container( decoration: BoxDecoration( color: Colors.black.withOpacity(0.85), // border: Border.all(color: Colors.lightBlue, width: 2), TODO: add border borderRadius: BorderRadius.circular(10), - boxShadow: [ + boxShadow: const [ // BoxShadow(color: Colors.black, blurRadius: 4) ]), - padding: EdgeInsets.all(8), + padding: const EdgeInsets.all(8), child: Text( "${widget.device.$1}:${widget.device.$2}", softWrap: false, ), ), ), - Icon( + const Icon( Icons.location_history, size: 32, ) @@ -184,8 +179,7 @@ class _UserPathState extends State { List> segments = []; List colors = []; if (state.initialPoints.isNotEmpty) { - final allPoints = - state.initialPoints.map((e) => LatLng(e.lat, e.lon)).toList(); + final allPoints = state.initialPoints.map((e) => LatLng(e.lat, e.lon)).toList(); final segmentCount = math.min(100, allPoints.length); final pointsPerSegment = (allPoints.length / segmentCount).ceil(); @@ -208,9 +202,8 @@ class _UserPathState extends State { Polyline( points: segments[i], strokeWidth: 4.0, - color: colors[i].withOpacity( - (math.pow(i, 2) / math.pow(segments.length, 2)) * 0.7 + - 0.3), // Fading effect + color: colors[i] + .withOpacity((math.pow(i, 2) / math.pow(segments.length, 2)) * 0.7 + 0.3), // Fading effect ), ); } @@ -234,14 +227,12 @@ class _UserPathState extends State { ), PolylineLayer(polylines: [ 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, color: Colors.blue.shade200, ), ]), - MarkerLayer(markers: _markers) + MarkerLayer(markers: markers) ], ); }, @@ -253,83 +244,170 @@ class _UserPathState extends State { showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { showModalBottomSheet( - context: context, - builder: (bsContext) { - return Container( - height: MediaQuery.of(bsContext).size.height * 0.26, - width: MediaQuery.of(bsContext).size.width, - decoration: BoxDecoration( - color: Colors.black, - // border: Border.all(color: Colors.blueAccent, width: 2), - borderRadius: BorderRadius.only(topLeft: Radius.circular(30), topRight: Radius.circular(30)), + context: context, + builder: (bsContext) { + return Container( + height: MediaQuery.of(bsContext).size.height * 0.5, + width: MediaQuery.of(bsContext).size.width, + 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: EdgeInsets.all(32), - child: Column( - 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 + 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(), + ], + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], ), - ), - SizedBox(height: 16,), - Builder( - builder: (sheetContext) { - final state = context.watch().state - as MainUserPathState; - - // get user's current location - // final _istate = state as MainUserPathState; - - if (state.livePoints.isEmpty) { - return Text( - "Couldn't find ${user.$1}:${user.$2}'s Location"); - } - - final curLocation = state.livePoints.last; - - // MapController.of(sheetContext).camera.pointToLatLng( - // math.Point(curLocation.lat, curLocation.lon)); - - return Column( - children: [ - Container( - decoration: BoxDecoration( - color: Colors.black, - border: Border.all(color: Colors.orange, width: 2), - borderRadius: BorderRadius.circular(10), - ), - padding: 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( - // rebuild every second for that ticking effect - // not hyper efficient, but it's only a text - stream: Stream.periodic( - const Duration(seconds: 1)), - builder: (context, _) { - return Text( - "${formatDuration(DateTime.now().difference(curLocation.timestamp))} ago"); - }), - ], - ), - ) - ], - ); - }, - ), - ], - )); - // title with icon, user name and device id - - // location as a lat, long (truncated to 4 comma spaces) - // time since last location update - // location to other users in a grid (2 rows) - }); + ); + }, + ), + ], + ), + ); + }, + ); } diff --git a/lib/owntracks_api.dart b/lib/owntracks_api.dart index f488650..c670149 100644 --- a/lib/owntracks_api.dart +++ b/lib/owntracks_api.dart @@ -118,7 +118,7 @@ class OwntracksApi { } // Method to create and return a WebSocket connection - Future createWebSocketConnection({ + Future> createWebSocketConnection({ required String wsPath, required void Function(Object message) onMessage, required void Function(WebSocketClientState stateChange) onStateChange, @@ -149,10 +149,15 @@ class OwntracksApi { client.stateChanges.listen(onStateChange); // Connect to the WebSocket server - await client.connect("${baseUrl.replaceFirst('http', 'ws')}/ws/$wsPath"); + try { + await client.connect("${baseUrl.replaceFirst('http', 'ws')}/ws/$wsPath"); + } catch (e) { + await client.disconnect(); + return bail("WebSocket connection to path $wsPath was unsuccessful: $e"); + } // Return the connected client - return client; + return Ok(client); } } @@ -163,6 +168,11 @@ class Point { Point({required this.lat, required this.lon, required this.timestamp}); + @override + String toString() { + return 'Point{lat: $lat, lon: $lon, timestamp: $timestamp}'; + } + factory Point.fromJson(Map json) { return Point( lat: json['lat'], diff --git a/lib/user_path_bloc.dart b/lib/user_path_bloc.dart index 76439ec..f047be5 100644 --- a/lib/user_path_bloc.dart +++ b/lib/user_path_bloc.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; +import 'package:get_it/get_it.dart'; +import 'package:ot_viewer_app/global_location_store.dart'; import 'package:rust_core/option.dart'; import 'package:anyhow/anyhow.dart'; import 'package:bloc/bloc.dart'; @@ -72,6 +74,16 @@ class UserPathBloc extends Bloc { @override void onTransition(Transition transition) { + super.onTransition(transition); + + if (transition.nextState is MainUserPathState) { + // add current location to global location thingy + final pt = (transition.nextState as MainUserPathState).livePoints.lastOrNull; + if (pt != null) { + GetIt.I.get().updatePoint(deviceId.$1, deviceId.$2, pt); + } + } + print("upb $deviceId: $transition"); } } diff --git a/lib/util.dart b/lib/util.dart index 881eb27..f604b24 100644 --- a/lib/util.dart +++ b/lib/util.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + String formatDuration(Duration duration) { final days = duration.inDays; final hours = duration.inHours.remainder(24); @@ -16,3 +18,81 @@ String formatDuration(Duration duration) { return '$seconds second${plural(seconds)}'; } } + +String formatDistance(int distanceInMeters) { + if (distanceInMeters < 1000) { + // If the distance is less than 1 kilometer, display it in meters. + return '${distanceInMeters}m'; + } else { + // If the distance is 1 kilometer or more, display it in kilometers and meters. + final kilometers = distanceInMeters ~/ 1000; // Integer division to get whole kilometers. + final meters = distanceInMeters % 1000; // Remainder to get the remaining meters. + if (meters == 0) { + // If there are no remaining meters, display only kilometers. + return '${kilometers}km'; + } else { + // If there are remaining meters, display both kilometers and meters. + return '${kilometers}km ${meters}m'; + } + } +} + + +double degreesToRadians(double degrees) { + return degrees * (pi / 180); +} + +double radiansToDegrees(double radians) { + return radians * (180 / pi); +} + +double bearingBetween(double lat1, double lon1, double lat2, double lon2) { + var dLon = degreesToRadians(lon2 - lon1); + var y = sin(dLon) * cos(degreesToRadians(lat2)); + var x = cos(degreesToRadians(lat1)) * sin(degreesToRadians(lat2)) - + sin(degreesToRadians(lat1)) * cos(degreesToRadians(lat2)) * cos(dLon); + var angle = atan2(y, x); + return (radiansToDegrees(angle) + 360) % 360; +} + +double distanceBetween( + double lat1, double lon1, double lat2, double lon2, String unit) { + const earthRadius = 6371; // in km + // assuming earth is a perfect sphere(it's not) + + // Convert degrees to radians + final lat1Rad = degreesToRadians(lat1); + final lon1Rad = degreesToRadians(lon1); + final lat2Rad = degreesToRadians(lat2); + final lon2Rad = degreesToRadians(lon2); + + final dLat = lat2Rad - lat1Rad; + final dLon = lon2Rad - lon1Rad; + + // Haversine formula + final a = pow(sin(dLat / 2), 2) + + cos(lat1Rad) * cos(lat2Rad) * pow(sin(dLon / 2), 2); + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + + final distance = earthRadius * c; + + return toRequestedUnit(unit, distance); + + // return distance; // in km +} + +double toRequestedUnit(String unit, double distanceInKm) { + switch (unit) { + case 'kilometers': + return distanceInKm; + case 'meters': + return distanceInKm * 1000; + case 'miles': + return (distanceInKm * 1000) / 1609.344; + case 'yards': + return distanceInKm * 1093.61; + case '': + return distanceInKm; + } + return distanceInKm; +} diff --git a/lib/web_socket_cubit.dart b/lib/web_socket_cubit.dart index 6054413..5158483 100644 --- a/lib/web_socket_cubit.dart +++ b/lib/web_socket_cubit.dart @@ -24,20 +24,33 @@ class LocationUpdateReceived extends LocationUpdateState { class LocationSubscribeCubit extends Cubit { Option _wsClient = None; + String url = ''; + String username = ''; + String pass = ''; LocationSubscribeCubit() : super(LocationUpdateUnconnected()); subscribe(SettingsState settings) async { + + // check if resubscribe is necessary (different URL) + if (settings.url == url && settings.username == username && settings.password == pass) { + return; + } else { + url = settings.url; + username = settings.username; + pass = settings.password; + } + + await _wsConnectionEstablish(); + } - if(_wsClient.isSome()) { + Future _wsConnectionEstablish() async { + if (_wsClient.isSome()) { await _wsClient.unwrap().close(); _wsClient = None; } - - var ws = await OwntracksApi( - baseUrl: settings.url, - username: settings.username, - pass: settings.password) + + var ws = await OwntracksApi(baseUrl: url, username: username, pass: pass) .createWebSocketConnection( wsPath: 'last', onMessage: (msg) { @@ -47,7 +60,7 @@ class LocationSubscribeCubit extends Cubit { } try { final Map map = jsonDecode(msg); - + if (map['_type'] == 'location') { // filter points (only the ones for this device pls!) final topic = (map['topic'] as String?)?.split('/'); @@ -55,17 +68,20 @@ class LocationSubscribeCubit extends Cubit { // couldn't reconstruct ID, bail return; } - + // build device_id final deviceId = (topic[1], topic[2]); - + + print(map); + // build point final p = Point( lat: map['lat'] as double, lon: map['lon'] as double, - timestamp: - DateTime.fromMillisecondsSinceEpoch(map['tst'] as int)); - + timestamp: DateTime.fromMillisecondsSinceEpoch((map['tst'] as int) * 1000)); + + print(p); + emit(LocationUpdateReceived(p, deviceId)); } } catch (e) { @@ -74,22 +90,25 @@ class LocationSubscribeCubit extends Cubit { } }, onStateChange: (sc) { - if (sc case WebSocketClientState$Open(:final url)) { - _wsClient.map((wsc) => wsc.add('LAST')); + switch (sc) { + case WebSocketClientState$Open(:final url): + _wsClient.map((wsc) => wsc.add('LAST')); + emit(LocationUpdateConnected()); + break; + default: + emit(LocationUpdateUnconnected()); + break; } print(sc); }, ); - - _wsClient = Some(ws); - emit(LocationUpdateConnected()); + + _wsClient = ws.expect("Estabilshing Websocket Conenction failed").toOption(); } - @override void onChange(Change change) { print('loc_sub_cubit change: $change'); - } @override diff --git a/pubspec.lock b/pubspec.lock index 4ff960e..d228d8a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -208,6 +208,14 @@ packages: description: flutter source: sdk version: "0.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: e6017ce7fdeaf218dc51a100344d8cb70134b80e28b760f8bb23c242437bafd7 + url: "https://pub.dev" + source: hosted + version: "7.6.7" hive: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5a8c429..5b0e4cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: dio_cache_interceptor_hive_store: ^3.2.2 flutter_map_math: ^0.1.7 intl: ^0.19.0 + get_it: ^7.6.7 dev_dependencies: flutter_test: