INITIAL_COMMIT
This commit is contained in:
84
lib/main.dart
Normal file
84
lib/main.dart
Normal 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
335
lib/map_page.dart
Normal 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
222
lib/owntracks_api.dart
Normal 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
391
lib/settings_page.dart
Normal 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
0
lib/settings_window.dart
Normal file
77
lib/user_path_bloc.dart
Normal file
77
lib/user_path_bloc.dart
Normal 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
26
lib/user_path_event.dart
Normal 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
39
lib/user_path_state.dart
Normal 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
18
lib/util.dart
Normal 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
100
lib/web_socket_cubit.dart
Normal 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();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user