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'; 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/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'; import 'package:ot_viewer_app/web_socket_cubit.dart'; import 'package:path_provider/path_provider.dart'; import 'owntracks_api.dart'; import 'dart:math' as math; Future getPath() async { final cacheDirectory = await getTemporaryDirectory(); return cacheDirectory.path; } class MapPage extends StatefulWidget { const MapPage({super.key}); @override createState() => _MapPageState(); } class _MapPageState extends State { final MapController _mapController = MapController(); @override void initState() { super.initState(); // _fetchPoints(); } @override Widget build(BuildContext context) { return BlocProvider( create: (context) { final cubit = LocationSubscribeCubit(); 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: (context, state) { if (tempPath.data == null) { return const Center(child: Text('Loading Map...')); } 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')), ), ], ), ], ), Positioned( bottom: 16, left: 16, child: TrackerSelector( mapController: _mapController, ), ), ]); }, ), ), ); } } class UserPath extends StatefulWidget { UserPath({super.key, required this.device, required this.settings}); (String, String) device; SettingsState settings; @override createState() => _UserPathState(); } 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: 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)); print("rebuild"); final _istate = state as MainUserPathState; // make markers final List markers = []; if (state.livePoints.isNotEmpty) { markers.add(Marker( width: 500, height: 100, point: state.livePoints.last.asLatLng, child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ GestureDetector( 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: const [ // BoxShadow(color: Colors.black, blurRadius: 4) ]), padding: const EdgeInsets.all(8), child: Text( "${widget.device.$1}:${widget.device.$2}", softWrap: false, ), ), ), const Icon( Icons.location_history, size: 32, ) ], ), alignment: Alignment.topCenter, rotate: true, )); } // Create fancy fade-out and blended line (TODO: make distance based. use flutter_map_math) List> segments = []; List colors = []; if (state.initialPoints.isNotEmpty) { 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(); // Split the points into segments and generate colors for (int i = 0; i < allPoints.length; i += pointsPerSegment) { int end = math.min(i + pointsPerSegment + 1, allPoints.length); segments.add(allPoints.sublist(i, end)); // Calculate the color for the segment double ratio = i / allPoints.length; Color color = Color.lerp(Colors.purple, Colors.blue, ratio)!; colors.add(color); } } // Create polylines for each segment with the corresponding color List polylines = []; for (int i = 0; i < segments.length; i++) { polylines.add( 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 ), ); } return Stack( children: [ PolylineLayer( key: ValueKey('${widget.device}_historyLines'), polylines: [ /* Polyline( points: state.initialPoints .map((e) => LatLng(e.lat, e.lon)) .toList(), strokeWidth: 4.0, color: Colors.blue, ), */ ...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, ), ]), MarkerLayer(markers: markers) ], ); }, ), ), ); } } showUserLocationModalBottomSheet(BuildContext context, (String, String) user) { showModalBottomSheet( context: context, builder: (bsContext) { return Container( // 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: 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, ), ), 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: 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(), ], ), ], ), ), ); }, ); }, ), ), ], ); }, ), ); }, ); } 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, ), ), ], ), ), ); }, ); }, ), ), ], ), ); } }