INITIAL_COMMIT

This commit is contained in:
2024-03-12 21:41:04 +01:00
commit 5d617131ee
138 changed files with 6592 additions and 0 deletions

84
lib/main.dart Normal file
View File

@ -0,0 +1,84 @@
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/settings_page.dart';
import 'package:path_provider/path_provider.dart';
import 'map_page.dart';
import 'owntracks_api.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? HydratedStorage.webStorageDirectory
: await getApplicationDocumentsDirectory(),
);
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'OwnTracks Data Viewer',
theme: ThemeData.dark(),
home: Scaffold(
appBar: AppBar(title: const Text('OwnTrakcs Data Viewer')),
body: MainPage(),
),
);
}
}
class MainPage extends StatefulWidget {
const MainPage({super.key});
@override
createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
final List<Widget> _pages = [
const MapPage(),
SettingsPage(), // Assume this is your settings page widget
];
void _onItemTapped(int index) {
setState(() {
_currentIndex = index;
});
}
@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>[
BottomNavigationBarItem(
icon: Icon(Icons.map),
label: 'Map',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
currentIndex: _currentIndex,
onTap: _onItemTapped,
),
),
);
}
}

335
lib/map_page.dart Normal file
View File

@ -0,0 +1,335 @@
import 'package:bloc/bloc.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.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:intl/intl.dart';
import 'package:latlong2/latlong.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<String> getPath() async {
final cacheDirectory = await getTemporaryDirectory();
return cacheDirectory.path;
}
class MapPage extends StatefulWidget {
const MapPage({super.key});
@override
createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
@override
void initState() {
super.initState();
// _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
Widget build(BuildContext context) {
return BlocProvider(
create: (context) {
final cubit = LocationSubscribeCubit();
final settings_cubit = context.read<SettingsCubit>();
cubit.subscribe(settings_cubit.state);
settings_cubit.stream.listen((settings) => cubit.subscribe(settings));
return cubit;
},
child: FutureBuilder<String>(
future: getPath(),
builder: (something, temp_path) =>
BlocBuilder<SettingsCubit, SettingsState>(
builder: (context, state) {
return FlutterMap(
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(temp_path.data,
hiveBoxName: 'HiveCacheStore')),
),
...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')),
),
],
),
],
);
},
),
),
);
}
}
class UserPath extends StatefulWidget {
UserPath({required this.device, required this.settings});
(String, String) device;
SettingsState settings;
@override
createState() => _UserPathState();
}
class _UserPathState extends State<UserPath> {
@override
Widget build(BuildContext ctx) {
return BlocProvider(
create: (context) {
final bloc = UserPathBloc(widget.device, widget.settings);
bloc.add(UserPathFullUpdate());
return bloc;
},
child: BlocListener<LocationSubscribeCubit, LocationUpdateState>(
listener: (context, state) {
UserPathBloc userPathBloc = context.read<UserPathBloc>();
if (state
case LocationUpdateReceived(:final position, :final deviceId)) {
if (userPathBloc.deviceId == state.deviceId) {
context
.read<UserPathBloc>()
.add(UserPathLiveSubscriptionUpdate(position));
}
}
},
child: BlocBuilder<UserPathBloc, UserPathState>(
builder: (context, state) {
print("rebuild");
final _istate = state as MainUserPathState;
// make markers
final List<Marker> _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: [
// BoxShadow(color: Colors.black, blurRadius: 4)
]),
padding: EdgeInsets.all(8),
child: Text(
"${widget.device.$1}:${widget.device.$2}",
softWrap: false,
),
),
),
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<List<LatLng>> segments = [];
List<Color> 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<Polyline> 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(
polylines: [
/*
Polyline(
points: state.initialPoints
.map((e) => LatLng(e.lat, e.lon))
.toList(),
strokeWidth: 4.0,
color: Colors.blue,
),
*/
...polylines
],
),
PolylineLayer(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.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)),
),
padding: EdgeInsets.all(32),
child: Column(
children: [
Text(
'${user.$1}:${user.$2}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 16,),
Builder(
builder: (sheetContext) {
final state = context.watch<UserPathBloc>().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)
});
}

222
lib/owntracks_api.dart Normal file
View File

@ -0,0 +1,222 @@
import 'dart:convert';
import 'package:anyhow/anyhow.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
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}';
}
const API_PREFIX = '/api/0';
class OwntracksApi {
OwntracksApi({
required this.baseUrl,
required this.username,
required this.pass,
});
final String baseUrl;
final String username;
final String pass;
static Map<String, String> _createBasicAuthHeader(String username,
String password,
[Map<String, String>? additionalHeaders]) {
final credentials = base64Encode(utf8.encode('$username:$password'));
final headers = {
'Authorization': 'Basic $credentials',
};
// If there are additional headers, add them to the headers map
if (additionalHeaders != null) {
headers.addAll(additionalHeaders);
}
return headers;
}
// Method to fetch points from a device within a specified date range
Future<Result<List<Point>>> fetchPointsForDevice({
required String user,
required String device,
required DateTime from,
DateTime? to,
}) async {
// Constructing the URL with query parameters
final queryParams = {
'user': user,
'device': device,
'from': from.toIso8601String(),
'to': (to ?? DateTime.now()).toIso8601String(),
'fields': 'lat,lon,isotst',
};
final uri = Uri.parse('$baseUrl$API_PREFIX/locations')
.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 jsonData =
(json.decode(response.body) as Map<String, dynamic>)['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)}");
}
}
Future<Result<Map<String, List<String>>>> getDevices() async {
final uri = Uri.parse('$baseUrl$API_PREFIX/list');
print(uri);
final authHeader = _createBasicAuthHeader(username, pass);
final response = await http.get(uri, headers: authHeader);
if (response.statusCode != 200) {
return bail(
"couldn't get user list from server: ${_responseNiceErrorString(
response)}");
}
final users = List<String>.from(json.decode(response.body)['results']);
Map<String, List<String>> map = {};
for (String user in users) {
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)}");
}
map[user] = List<String>.from(jsonDecode(response.body)['results']);
}
return Ok(map);
}
// Method to create and return a WebSocket connection
Future<WebSocketClient> createWebSocketConnection({
required String wsPath,
required void Function(Object message) onMessage,
required void Function(WebSocketClientState stateChange) onStateChange,
Option<(String, String)> onlyDeviceId = None,
}) async {
const retryInterval = (
min: Duration(milliseconds: 500),
max: Duration(seconds: 15),
);
Map<String, String> headers = {};
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.vm(
connectionRetryInterval: retryInterval,
headers: _createBasicAuthHeader(username, pass)..addAll(headers)));
// Listen to messages
client.stream.listen(onMessage);
// Listen to state changes
client.stateChanges.listen(onStateChange);
// Connect to the WebSocket server
await client.connect("${baseUrl.replaceFirst('http', 'ws')}/ws/$wsPath");
// Return the connected client
return client;
}
}
class Point {
final double lat;
final double lon;
final DateTime timestamp;
Point({required this.lat, required this.lon, required this.timestamp});
factory Point.fromJson(Map<String, dynamic> json) {
return Point(
lat: json['lat'],
lon: json['lon'],
timestamp: DateTime.parse(json['isotst']),
);
}
LatLng get asLatLng => LatLng(lat, lon);
}
class Device {
final String id;
final String name;
Device({required this.id, required this.name});
factory Device.fromJson(Map<String, dynamic> json) {
return Device(
id: json['id'],
name: json['name'],
);
}
}
/*
Future<List<Point>> fetchPointsForDevice(String deviceId) async {
final response = await http.get(Uri.parse('$_baseUrl/devices/$deviceId/points'),
headers: <String, String> {
'authorization': base64Encode(utf8.encode('$username:$pass'))
}
);
print('${response.statusCode}: ${response.body}');
if (response.statusCode == 200) {
final jsonData = json.decode(response.body);
return (jsonData as List).map((point) => Point.fromJson(point)).toList();
} else {
throw Exception('Failed to load points');
}
}
Future<List<Device>> listAllDevices() async {
final response = await http.get(Uri.parse('$_baseUrl/devices'));
if (response.statusCode == 200) {
final jsonData = json.decode(response.body);
return (jsonData as List).map((device) => Device.fromJson(device)).toList();
} else {
throw Exception('Failed to load devices');
}
}
// Add more API calls as needed
}
*/

391
lib/settings_page.dart Normal file
View File

@ -0,0 +1,391 @@
import 'dart:convert';
import 'package:anyhow/base.dart';
import 'package:bloc/bloc.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:shared_preferences/shared_preferences.dart';
class SettingsState {
SettingsState({
this.url = '',
this.username = '',
this.password = '',
this.usersToDevices = const {},
this.activeDevices = const {},
});
final String url;
final String username;
final String password;
final Map<String, List<String>> 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);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SettingsState &&
runtimeType == other.runtimeType &&
url == other.url &&
username == other.username &&
password == other.password &&
usersToDevices == other.usersToDevices &&
activeDevices == other.activeDevices;
@override
int get hashCode =>
url.hashCode ^
username.hashCode ^
password.hashCode ^
usersToDevices.hashCode ^
activeDevices.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> {
SettingsCubit() : super(SettingsState());
@override
void onChange(Change<SettingsState> change) {
// TODO: implement onChange
super.onChange(change);
print(change);
}
fetchDevices() async {
if (state.url.isEmpty) {
throw Exception("No URL provided, cannot fetch devices list!");
}
final api = OwntracksApi(
baseUrl: state.url,
username: state.username,
pass: state.password,
);
switch (await api.getDevices()) {
case Ok(:final ok):
final currentState = state;
// Emit new state
emit(SettingsState(
url: currentState.url,
username: currentState.username,
password: currentState.password,
usersToDevices: ok,
activeDevices: currentState.activeDevices,
));
case Err(:final err):
throw Exception("Fetching devices list failed: $err");
}
}
@override
SettingsState? fromJson(Map<String, dynamic> json) {
// print("fromjson $json");
final usersToDevicesMap =
(json['usersToDevices'] as Map<String, dynamic>?)?.map((key, value) {
final List<String> list = List<String>.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<List<String>>.from(json['activeDevices']).map((e) {
if (e.length != 2) {
return null;
} else {
return (e[0], e[1]);
}
}).where((element) => element != null)));
}
@override
Map<String, dynamic>? toJson(SettingsState state) {
// print("tojson $state");
return {
'url': state.url,
'username': state.username,
'password': state.password,
'usersToDevices': state.usersToDevices,
'activeDevices': state.activeDevices.map((e) => [e.$1, e.$2]).toList(),
};
}
Future<void> updateSettings({
String? url,
String? username,
String? password,
Map<String, List<String>>? usersToDevices,
Set<(String, String)>? activeDevices,
}) async {
final currentState = state;
// Emit new state
emit(SettingsState(
url: url ?? currentState.url,
username: username ?? currentState.username,
password: password ?? currentState.password,
usersToDevices: usersToDevices ?? currentState.usersToDevices,
activeDevices: activeDevices ?? currentState.activeDevices,
));
}
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
final TextEditingController _urlController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
@override
void dispose() {
_urlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(left: 8, right: 8),
child: BlocConsumer<SettingsCubit, SettingsState>(
listener: (context, state) {
_urlController.text = state.url;
_usernameController.text = state.username;
_passwordController.text = state.password;
},
builder: (context, state) {
_urlController.text = state.url;
_usernameController.text = state.username;
_passwordController.text = state.password;
return SingleChildScrollView(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Colors.redAccent.withOpacity(0.8), width: 2),
borderRadius: BorderRadius.circular(10)),
child: Column(
mainAxisSize: MainAxisSize.min,
// To make the column wrap its content
children: <Widget>[
TextField(
controller: _urlController,
decoration: const InputDecoration(
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
),
),
const SizedBox(height: 10),
// Adds space between the two TextFields
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
// Adds a border to the TextField
prefixIcon:
Icon(Icons.person), // Adds an icon to the left
),
),
const SizedBox(height: 10),
// Adds space between the two TextFields
TextField(
controller: _passwordController,
obscureText: true,
// Hides the text input (for passwords)
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
// Adds a border to the TextField
prefixIcon:
Icon(Icons.lock), // Adds an icon to the left
),
),
const SizedBox(
height: 12,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.warning,
color: Colors.red, size: 16),
// Small red exclamation mark icon
const SizedBox(width: 4),
// Space between icon and text
const Text(
'Password saved locally',
style: TextStyle(
color: Colors.red,
fontSize:
12, // Small font size for the warning text
),
),
const Spacer(),
ElevatedButton.icon(
onPressed: () {
print('saving settings...');
context.read<SettingsCubit>().updateSettings(
url: _urlController.text,
username: _usernameController.text,
password: _passwordController.text,
);
context.read<SettingsCubit>().fetchDevices();
},
icon: Icon(Icons.save),
label: Text('Save'))
],
),
]),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Colors.deepPurple.withOpacity(0.8), width: 2),
borderRadius: BorderRadius.circular(10)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List<Widget>.from([
Center(
child: ElevatedButton.icon(
onPressed: () =>
context.read<SettingsCubit>().fetchDevices(),
icon: const Icon(Icons.refresh),
label: const Text('Refresh Devices'),
),
),
const SizedBox(height: 16),
]) +
List<Widget>.from(
state.usersToDevices.entries.map((entry) {
return Column(
children: [
Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 4),
Text(
entry.key,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
],
),
const Divider(),
Container(
padding: const EdgeInsets.only(left: 8),
child: Column(children:
List<Widget>.from(entry.value.map((device) {
return Row(
children: [
Checkbox(
value: state.activeDevices
.contains((entry.key, device)),
onChanged: (newVal) {
var cubit =
context.read<SettingsCubit>();
Set<(String, String)> newSet =
Set.from(state.activeDevices);
if (newVal ?? false) {
newSet.add((entry.key, device));
} else {
newSet
.remove((entry.key, device));
}
cubit.updateSettings(
activeDevices: newSet);
}),
const SizedBox(width: 8),
Text("${entry.key}:$device"),
],
);
}))),
),
const SizedBox(height: 32),
],
);
}))),
)
],
),
);
},
),
);
}
}

0
lib/settings_window.dart Normal file
View File

77
lib/user_path_bloc.dart Normal file
View File

@ -0,0 +1,77 @@
import 'dart:async';
import 'dart:convert';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:rust_core/option.dart';
import 'package:anyhow/anyhow.dart';
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:ot_viewer_app/owntracks_api.dart';
import 'package:ot_viewer_app/settings_page.dart';
import 'package:ws/ws.dart';
part 'user_path_event.dart';
part 'user_path_state.dart';
class UserPathBloc extends Bloc<UserPathEvent, UserPathState> {
final (String, String) deviceId;
SettingsState settingsState;
Option<WebSocketClient> _ws = None;
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)))) {
on<UserPathLoginDataChanged>((event, emit) {
settingsState = event.newSettings;
// TODO: restart live connections
});
on<UserPathLiveSubscriptionUpdate>((event, emit) {
print("DEBUG: adding point ${event.point} to bloc $deviceId");
emit(MainUserPathState.copy(
state as MainUserPathState,
// FIXME: inefficient as heck. Maybe use fast_immutable_collections package?
livePoints: (state as MainUserPathState).livePoints.add(event.point),
));
});
on<UserPathFullUpdate>((event, emit) async {
print("fpu");
if (state is MainUserPathState) {
final istate = state as MainUserPathState;
final history = await _api.fetchPointsForDevice(
user: deviceId.$1,
device: deviceId.$2,
from: istate.from,
to: istate.to,
);
final Result<List<Point>> 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,
from: istate.from,
to: istate.to));
}
});
}
@override
void onTransition(Transition<UserPathEvent, UserPathState> transition) {
print("upb $deviceId: $transition");
}
}

26
lib/user_path_event.dart Normal file
View File

@ -0,0 +1,26 @@
part of 'user_path_bloc.dart';
@immutable
abstract class UserPathEvent {}
final class UserPathLoginDataChanged extends UserPathEvent {
UserPathLoginDataChanged(this.newSettings);
final SettingsState newSettings;
}
final class UserPathTimeChanged extends UserPathEvent {
UserPathTimeChanged({this.from, this.to});
final DateTime? from;
final DateTime? to;
}
final class UserPathLiveSubscriptionUpdate extends UserPathEvent {
UserPathLiveSubscriptionUpdate(this.point);
final Point point;
}
final class UserPathFullUpdate extends UserPathEvent {}

39
lib/user_path_state.dart Normal file
View File

@ -0,0 +1,39 @@
part of 'user_path_bloc.dart';
@immutable
abstract class UserPathState {}
// class UserPathInitial extends UserPathState {
// UserPathInitial({required super.InitialPoints, required super.LivePoints, required super.from, required super.to});
// }
final class MainUserPathState extends UserPathState {
final IList<Point> initialPoints;
final IList<Point> livePoints;
final DateTime from;
final DateTime to;
final bool subscribed;
MainUserPathState({
required this.initialPoints,
required this.livePoints,
required this.from,
required this.to,
this.subscribed = false,
});
MainUserPathState.copy(MainUserPathState original,
{IList<Point>? initialPoints,
IList<Point>? livePoints,
DateTime? from,
DateTime? to,
bool? subscribed})
: this(
initialPoints: initialPoints ?? original.initialPoints,
livePoints: livePoints ?? original.livePoints,
from: from ?? original.from,
to: to ?? original.to,
subscribed: subscribed ?? original.subscribed);
}

18
lib/util.dart Normal file
View File

@ -0,0 +1,18 @@
String formatDuration(Duration duration) {
final days = duration.inDays;
final hours = duration.inHours.remainder(24);
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
String plural(int thing) => thing == 1 ? '' : 's';
if (days > 0) {
return '$days day${plural(days)}, $hours hour${plural(hours)}';
} else if (hours > 0) {
return '$hours hour${plural(hours)}, $minutes minute${plural(minutes)}';
} else if (minutes > 0) {
return '$minutes minute${plural(minutes)}, $seconds second${plural(seconds)}';
} else {
return '$seconds second${plural(seconds)}';
}
}

100
lib/web_socket_cubit.dart Normal file
View File

@ -0,0 +1,100 @@
// web_socket_cubit.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ot_viewer_app/owntracks_api.dart';
import 'package:ot_viewer_app/settings_page.dart';
import 'package:ws/ws.dart';
import 'package:rust_core/option.dart';
// Define the state. For simplicity, we're just using Map<String, dynamic> directly.
// You might want to define a more specific state class based on your application's needs.
abstract class LocationUpdateState {}
class LocationUpdateUnconnected extends LocationUpdateState {}
class LocationUpdateConnected extends LocationUpdateState {}
class LocationUpdateReceived extends LocationUpdateState {
Point position;
(String, String) deviceId;
LocationUpdateReceived(this.position, this.deviceId);
}
class LocationSubscribeCubit extends Cubit<LocationUpdateState> {
Option<WebSocketClient> _wsClient = None;
LocationSubscribeCubit() : super(LocationUpdateUnconnected());
subscribe(SettingsState settings) async {
if(_wsClient.isSome()) {
await _wsClient.unwrap().close();
_wsClient = None;
}
var ws = await OwntracksApi(
baseUrl: settings.url,
username: settings.username,
pass: settings.password)
.createWebSocketConnection(
wsPath: 'last',
onMessage: (msg) {
if (msg is String) {
if (msg == 'LAST') {
return;
}
try {
final Map<String, dynamic> 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]);
// build point
final p = Point(
lat: map['lat'] as double,
lon: map['lon'] as double,
timestamp:
DateTime.fromMillisecondsSinceEpoch(map['tst'] as int));
emit(LocationUpdateReceived(p, deviceId));
}
} catch (e) {
print('BUG: Couldn\'t parse WS message: $msg ($e)');
}
}
},
onStateChange: (sc) {
if (sc case WebSocketClientState$Open(:final url)) {
_wsClient.map((wsc) => wsc.add('LAST'));
}
print(sc);
},
);
_wsClient = Some(ws);
emit(LocationUpdateConnected());
}
@override
void onChange(Change<LocationUpdateState> change) {
print('loc_sub_cubit change: $change');
}
@override
Future<void> close() async {
await _wsClient.toFutureOption().map((conn) => conn.close());
return super.close();
}
}