diff --git a/lib/main.dart b/lib/main.dart index 93bb58c..aba911f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ 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/refresh_cubit.dart'; import 'package:ot_viewer_app/settings_page.dart'; import 'package:path_provider/path_provider.dart'; import 'map_page.dart'; @@ -20,8 +21,22 @@ Future main() async { GetIt.I .registerSingleton(GlobalLocationStoreCubit()); + GetIt.I.registerSingleton(RefreshCubit()); - runApp(MyApp()); + runApp( + MultiBlocProvider( + providers: [ + BlocProvider( + lazy: false, + create: (BuildContext context) => SettingsCubit(), + ), + BlocProvider( + create: (BuildContext context) => GetIt.I.get(), + ), + ], + child: MyApp(), + ), + ); } class MyApp extends StatelessWidget { @@ -31,7 +46,17 @@ class MyApp extends StatelessWidget { title: 'OwnTracks Data Viewer', theme: ThemeData.dark(), home: Scaffold( - appBar: AppBar(title: const Text('OwnTrakcs Data Viewer')), + appBar: AppBar( + title: const Text('OwnTrakcs Data Viewer'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () async { + context.read().triggerRefresh(); + }, + ), + ], + ), body: const MainPage(), ), ); @@ -60,28 +85,24 @@ class _MainPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( - lazy: false, - create: (BuildContext context) => SettingsCubit(), - child: Scaffold( - body: IndexedStack( - index: _currentIndex, - children: _pages, - ), - bottomNavigationBar: BottomNavigationBar( - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.map), - label: 'Map', - ), - BottomNavigationBarItem( - icon: Icon(Icons.settings), - label: 'Settings', - ), - ], - currentIndex: _currentIndex, - onTap: _onItemTapped, - ), + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: _pages, + ), + bottomNavigationBar: BottomNavigationBar( + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.map), + label: 'Map', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings), + label: 'Settings', + ), + ], + currentIndex: _currentIndex, + onTap: _onItemTapped, ), ); } diff --git a/lib/map_page.dart b/lib/map_page.dart index f863a9b..a114c9d 100644 --- a/lib/map_page.dart +++ b/lib/map_page.dart @@ -13,6 +13,7 @@ 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/refresh_cubit.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'; @@ -34,6 +35,8 @@ class MapPage extends StatefulWidget { } class _MapPageState extends State { + final MapController _mapController = MapController(); + @override void initState() { super.initState(); @@ -48,40 +51,58 @@ class _MapPageState extends State { final settingsCubit = context.read(); cubit.subscribe(settingsCubit.state); settingsCubit.stream.listen((settings) => cubit.subscribe(settings)); + final refreshCubit = context.read(); + refreshCubit.stream.listen((_) => cubit.reconnect()); return cubit; }, child: FutureBuilder( future: getPath(), - builder: (something, tempPath) => BlocBuilder( + 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), - initialZoom: 13.0, + return Stack(children: [ + FlutterMap( + mapController: _mapController, + options: const MapOptions( + initialCenter: LatLng(48.3285, 9.8942), + initialZoom: 13.0, + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + tileProvider: CachedTileProvider( + maxStale: const Duration(days: 30), + store: HiveCacheStore(tempPath.data!, + hiveBoxName: 'HiveCacheStore')), + ), + ...state.activeDevices.map((id) => + UserPath(key: ValueKey(id), 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')), + ), + ], + ), + ], ), - children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - tileProvider: CachedTileProvider( - maxStale: const Duration(days: 30), - store: HiveCacheStore(tempPath.data!, hiveBoxName: 'HiveCacheStore')), + Positioned( + bottom: 16, + left: 16, + child: TrackerSelector( + mapController: _mapController, ), - ...state.activeDevices.map((id) => UserPath(key: ValueKey(id), 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')), - ), - ], - ), - ], - ); + ), + ]); }, ), ), @@ -110,20 +131,34 @@ class _UserPathState extends State { // ONLY WORKS because gets rebuilt every time with settings update 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 (userPathBloc.deviceId == state.deviceId) { - userPathBloc.add(UserPathLiveSubscriptionUpdate(position)); - } - } - }, + child: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (prevState, state) => state is LocationUpdateReceived, + listener: (context, state) { + UserPathBloc userPathBloc = context.read(); + if (state + case LocationUpdateReceived( + :final position, + :final deviceId + )) { + if (userPathBloc.deviceId == state.deviceId) { + userPathBloc.add(UserPathLiveSubscriptionUpdate(position)); + } + } + }), + BlocListener( + listener: (context, state) { + context.read().add(UserPathFullUpdate()); + }, + ) + ], child: BlocBuilder( builder: (context, state) { // TODO: change once smarter rebuilds are ready - context.read().add(UserPathLoginDataChanged(widget.settings)); + context + .read() + .add(UserPathLoginDataChanged(widget.settings)); print("rebuild"); final _istate = state as MainUserPathState; @@ -139,7 +174,8 @@ 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), @@ -170,7 +206,8 @@ 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(); @@ -193,8 +230,9 @@ 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 ), ); } @@ -217,13 +255,17 @@ class _UserPathState extends State { ...polylines ], ), - PolylineLayer(key: ValueKey('${widget.device}_liveLines'), polylines: [ - Polyline( - points: state.livePoints.map((e) => LatLng(e.lat, e.lon)).toList(), - strokeWidth: 4.0, - color: Colors.blue.shade200, - ), - ]), + PolylineLayer( + key: ValueKey('${widget.device}_liveLines'), + polylines: [ + Polyline( + points: state.livePoints + .map((e) => LatLng(e.lat, e.lon)) + .toList(), + strokeWidth: 4.0, + color: Colors.blue.shade200, + ), + ]), MarkerLayer(markers: markers) ], ); @@ -244,13 +286,15 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { height: 500, decoration: const BoxDecoration( 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: const EdgeInsets.all(32), child: StreamBuilder( stream: context.read().stream, builder: (sheetContext, state) { - final istate = state.data as MainUserPathState? ?? context.read().state as MainUserPathState; + 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"); } @@ -275,12 +319,15 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { 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)), + 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"); + return Text( + "${formatDuration(DateTime.now().difference(curLocation.timestamp))} ago"); }, ), ], @@ -291,7 +338,8 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { ), Expanded( // Ensure GridView.builder is within an Expanded widget - child: BlocBuilder( + child: BlocBuilder( bloc: GetIt.I.get(), builder: (sheetContext, state) { // get map camera @@ -299,22 +347,32 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { final mapRotation = mapController.camera.rotation; - List> locations = state.locations + 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}")); + ..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), + 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 distance = distanceBetween( + curLocation.lat, + curLocation.lon, + locations[index].value.lat, + locations[index].value.lon, + "meters"); double bearing = (bearingBetween( curLocation.lat, @@ -333,7 +391,8 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { margin: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.black, - border: Border.all(color: Colors.pink, width: 2), + border: + Border.all(color: Colors.pink, width: 2), borderRadius: BorderRadius.circular(10), ), padding: const EdgeInsets.all(16), @@ -343,7 +402,8 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { children: [ Text( "${locations[index].key.$1}:${locations[index].key.$2}", - style: const TextStyle(fontWeight: FontWeight.bold), + style: const TextStyle( + fontWeight: FontWeight.bold), ), const Spacer(), Text(formatDistance(distance ~/ 1)), @@ -359,11 +419,13 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { Row( children: [ StreamBuilder( - stream: Stream.periodic(const Duration(seconds: 1)), + 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)); + style: const TextStyle( + fontSize: 12)); }, ), const Spacer(), @@ -386,3 +448,81 @@ showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { }, ); } + +class TrackerSelector extends StatefulWidget { + final MapController mapController; + + const TrackerSelector({super.key, required this.mapController}); + + @override + State createState() => _TrackerSelectorState(); +} + +class _TrackerSelectorState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: _isExpanded ? MediaQuery.of(context).size.width - 32 : 48, + height: 48, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + IconButton( + icon: Icon(_isExpanded ? Icons.close : Icons.people), + onPressed: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + ), + if (_isExpanded) + Expanded( + child: BlocBuilder( + bloc: GetIt.I.get(), + builder: (context, state) { + return ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: state.locations.length, + itemBuilder: (context, index) { + final entry = state.locations.entries.elementAt(index); + return GestureDetector( + onTap: () => widget.mapController + .moveAndRotate(entry.value.asLatLng, 15, 0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.location_history, + color: Colors.white, + size: 24, + ), + Text( + "${entry.key.$1}:${entry.key.$2}", + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/refresh_cubit.dart b/lib/refresh_cubit.dart new file mode 100644 index 0000000..1ec86ba --- /dev/null +++ b/lib/refresh_cubit.dart @@ -0,0 +1,9 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +class RefreshCubit extends Cubit { + RefreshCubit() : super(DateTime.now()); + + void triggerRefresh() { + emit(DateTime.now()); + } +} diff --git a/lib/web_socket_cubit.dart b/lib/web_socket_cubit.dart index 5158483..c0ff14f 100644 --- a/lib/web_socket_cubit.dart +++ b/lib/web_socket_cubit.dart @@ -1,6 +1,6 @@ -// web_socket_cubit.dart import 'dart:async'; import 'dart:convert'; +import 'package:anyhow/anyhow.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ot_viewer_app/owntracks_api.dart'; import 'package:ot_viewer_app/settings_page.dart'; @@ -24,86 +24,126 @@ class LocationUpdateReceived extends LocationUpdateState { class LocationSubscribeCubit extends Cubit { Option _wsClient = None; + Option> _connectionCompleter = None; String url = ''; String username = ''; String pass = ''; LocationSubscribeCubit() : super(LocationUpdateUnconnected()); + // TODO: handle ongoing connection attempt by canceling? the loop? or smth subscribe(SettingsState settings) async { - // check if resubscribe is necessary (different URL) - if (settings.url == url && settings.username == username && settings.password == pass) { + if (settings.url == url && + settings.username == username && + settings.password == pass) { return; } else { url = settings.url; username = settings.username; pass = settings.password; } - + + await _wsConnectionEstablish(); + } + + reconnect() async { + print("reconnecting..."); + if (_wsClient.isSome()) { + await _wsClient.unwrap().close(); + _wsClient = None; + } else { + print("not connected, not reconnecting"); + return; + } + await _wsConnectionEstablish(); } Future _wsConnectionEstablish() async { + // If there's an ongoing connection attempt, wait for it + if (_connectionCompleter.isSome()) { + await _connectionCompleter.unwrap().future; + return; + } + + // Start new connection attempt + _connectionCompleter = Some(Completer()); + if (_wsClient.isSome()) { await _wsClient.unwrap().close(); _wsClient = None; } - - var ws = await OwntracksApi(baseUrl: url, username: username, pass: pass) - .createWebSocketConnection( - wsPath: 'last', - onMessage: (msg) { - if (msg is String) { - if (msg == 'LAST') { - return; - } - 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('/'); - if (topic == null || topic.length < 3) { - // 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) * 1000)); - - print(p); - - emit(LocationUpdateReceived(p, deviceId)); + + Result ws = bail('Not done yet'); + + while (!ws.isOk()) { + ws = await OwntracksApi(baseUrl: url, username: username, pass: pass) + .createWebSocketConnection( + wsPath: 'last', + onMessage: (msg) { + if (msg is String) { + if (msg == 'LAST') { + return; + } + 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('/'); + if (topic == null || topic.length < 3) { + // 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) * 1000)); + + print(p); + + emit(LocationUpdateReceived(p, deviceId)); + } + } catch (e) { + print('BUG: Couldn\'t parse WS message: $msg ($e)'); } - } catch (e) { - print('BUG: Couldn\'t parse WS message: $msg ($e)'); } - } - }, - onStateChange: (sc) { - switch (sc) { - case WebSocketClientState$Open(:final url): - _wsClient.map((wsc) => wsc.add('LAST')); - emit(LocationUpdateConnected()); - break; - default: - emit(LocationUpdateUnconnected()); - break; - } - print(sc); - }, - ); - - _wsClient = ws.expect("Estabilshing Websocket Conenction failed").toOption(); + }, + onStateChange: (sc) { + switch (sc) { + case WebSocketClientState$Open(:final url): + _wsClient.map((wsc) => wsc.add('LAST')); + emit(LocationUpdateConnected()); + break; + default: + emit(LocationUpdateUnconnected()); + break; + } + print(sc); + }, + ); + + if (!ws.isOk()) { + print( + 'Failed to connect to WebSocket: ${ws.unwrapErr()}\n, retrying in 1s'); + await Future.delayed(const Duration(seconds: 1)); + } + } + + _wsClient = + ws.expect("Estabilshing Websocket Conenction failed").toOption(); + + _connectionCompleter.unwrap().complete(); + _connectionCompleter = None; } @override