mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-07-17 01:27:22 +02:00
pretty broken, icons missing, login not working. browser opens though and cams still appear on map
This commit is contained in:
+106
-30
@@ -1,50 +1,77 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models/camera_profile.dart';
|
||||
import 'models/pending_upload.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/uploader.dart';
|
||||
|
||||
// ------------------ AddCameraSession ------------------
|
||||
class AddCameraSession {
|
||||
AddCameraSession({required this.profile, this.directionDegrees = 0});
|
||||
|
||||
CameraProfile profile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
}
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
AppState() {
|
||||
_profiles = [CameraProfile.alpr()];
|
||||
_enabled = {..._profiles}; // all enabled by default
|
||||
_init();
|
||||
}
|
||||
|
||||
// ---------- Auth ----------
|
||||
bool _loggedIn = false;
|
||||
bool get isLoggedIn => _loggedIn;
|
||||
void setLoggedIn(bool v) {
|
||||
_loggedIn = v;
|
||||
notifyListeners();
|
||||
}
|
||||
final _auth = AuthService();
|
||||
String? _username;
|
||||
|
||||
// ---------- Profiles & toggles ----------
|
||||
late final List<CameraProfile> _profiles;
|
||||
late final Set<CameraProfile> _enabled;
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
late final List<CameraProfile> _profiles = [CameraProfile.alpr()];
|
||||
final Set<CameraProfile> _enabled = {};
|
||||
|
||||
void toggleProfile(CameraProfile p, bool enable) {
|
||||
enable ? _enabled.add(p) : _enabled.remove(p);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<CameraProfile> get enabledProfiles => _profiles
|
||||
.where((p) => _enabled.contains(p))
|
||||
.toList(growable: false);
|
||||
|
||||
// ---------- Add-camera session ----------
|
||||
AddCameraSession? _session;
|
||||
AddCameraSession? get session => _session;
|
||||
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
|
||||
// ---------- Init ----------
|
||||
Future<void> _init() async {
|
||||
_enabled.addAll(_profiles);
|
||||
await _loadQueue();
|
||||
if (await _auth.isLoggedIn()) {
|
||||
_username = await _auth.login();
|
||||
}
|
||||
_startUploader();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Auth ----------
|
||||
Future<void> login() async {
|
||||
_username = await _auth.login();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Profiles ----------
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
List<CameraProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
void toggleProfile(CameraProfile p, bool e) {
|
||||
e ? _enabled.add(p) : _enabled.remove(p);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Add‑camera session ----------
|
||||
void startAddSession() {
|
||||
_session = AddCameraSession(profile: enabledProfiles.first);
|
||||
notifyListeners();
|
||||
@@ -59,7 +86,6 @@ class AppState extends ChangeNotifier {
|
||||
if (directionDeg != null) _session!.directionDegrees = directionDeg;
|
||||
if (profile != null) _session!.profile = profile;
|
||||
if (target != null) _session!.target = target;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cancelSession() {
|
||||
@@ -67,10 +93,6 @@ class AppState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Pending uploads ----------
|
||||
final List<PendingUpload> _queue = [];
|
||||
List<PendingUpload> get queue => List.unmodifiable(_queue);
|
||||
|
||||
void commitSession() {
|
||||
if (_session?.target == null) return;
|
||||
_queue.add(
|
||||
@@ -80,8 +102,62 @@ class AppState extends ChangeNotifier {
|
||||
profile: _session!.profile,
|
||||
),
|
||||
);
|
||||
_saveQueue();
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
// ---------- Uploader ----------
|
||||
void _startUploader() {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without auth or queue.
|
||||
if (_queue.isEmpty) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty) return;
|
||||
|
||||
final access = await _auth.getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
final item = _queue.first;
|
||||
final up = Uploader(access, () {
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
final ok = await up.upload(item);
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// give up until next launch
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Exposed getters ----------
|
||||
int get pendingCount => _queue.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,28 @@ class PendingUpload {
|
||||
final LatLng coord;
|
||||
final double direction;
|
||||
final CameraProfile profile;
|
||||
final DateTime queuedAt;
|
||||
int attempts;
|
||||
|
||||
PendingUpload({
|
||||
required this.coord,
|
||||
required this.direction,
|
||||
required this.profile,
|
||||
}) : queuedAt = DateTime.now();
|
||||
this.attempts = 0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lat': coord.latitude,
|
||||
'lon': coord.longitude,
|
||||
'dir': direction,
|
||||
'profile': profile.name,
|
||||
'attempts': attempts,
|
||||
};
|
||||
|
||||
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
|
||||
coord: LatLng(j['lat'], j['lon']),
|
||||
direction: j['dir'],
|
||||
profile: CameraProfile.alpr(), // only built‑in for now
|
||||
attempts: j['attempts'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,30 @@ class SettingsScreen extends StatelessWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Logged in to OSM (OAuth – coming soon)'),
|
||||
value: appState.isLoggedIn,
|
||||
onChanged: null, // disabled for now
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
appState.isLoggedIn ? Icons.person : Icons.login,
|
||||
color: appState.isLoggedIn ? Colors.green : null,
|
||||
),
|
||||
title: Text(appState.isLoggedIn
|
||||
? 'Logged in as ${appState.username}'
|
||||
: 'Log in to OpenStreetMap'),
|
||||
onTap: () async {
|
||||
if (appState.isLoggedIn) {
|
||||
await appState.logout();
|
||||
} else {
|
||||
await appState.login();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (appState.isLoggedIn)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_upload),
|
||||
title: const Text('Test upload'),
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Upload will run soon...')),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const Text('Camera Profiles',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
@@ -30,6 +49,11 @@ class SettingsScreen extends StatelessWidget {
|
||||
onChanged: (v) => appState.toggleProfile(p, v),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sync),
|
||||
title: Text('Pending uploads: ${appState.pendingCount}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:oauth2_client/oauth2_client.dart';
|
||||
import 'package:oauth2_client/oauth2_helper.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Handles OAuth 2 PKCE login to OpenStreetMap and exposes
|
||||
/// the stored access token & display name.
|
||||
///
|
||||
/// ─ Requirements ─
|
||||
/// • Register an OAuth app at
|
||||
/// https://www.openstreetmap.org/oauth2/applications
|
||||
/// ‑ Redirect URI: flockmap://auth
|
||||
/// • Put that client ID below (replace 'flockmap').
|
||||
class AuthService {
|
||||
static const _clientId = 'flockmap'; // ← replace with your ID
|
||||
static const _redirect = 'flockmap://auth';
|
||||
|
||||
late final OAuth2Helper _helper;
|
||||
|
||||
String? _displayName; // cached after login
|
||||
String? get displayName => _displayName;
|
||||
|
||||
AuthService() {
|
||||
final client = OAuth2Client(
|
||||
authorizeUrl: 'https://www.openstreetmap.org/oauth2/authorize',
|
||||
tokenUrl: 'https://www.openstreetmap.org/oauth2/token',
|
||||
redirectUri: _redirect,
|
||||
customUriScheme: 'flockmap', // matches redirect scheme
|
||||
);
|
||||
|
||||
_helper = OAuth2Helper(
|
||||
client,
|
||||
clientId: _clientId,
|
||||
scopes: ['write_api'],
|
||||
enablePKCE: true, // PKCE flow
|
||||
// No custom token store needed: oauth2_client will
|
||||
// auto‑use flutter_secure_storage when present.
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Public helpers ───────── */
|
||||
|
||||
/// Returns `true` if a non‑expired token is stored.
|
||||
Future<bool> isLoggedIn() async =>
|
||||
(await _helper.getTokenFromStorage())?.isExpired() == false;
|
||||
|
||||
/// Launches browser login if necessary; caches display name.
|
||||
Future<String?> login() async {
|
||||
final token = await _helper.getToken();
|
||||
if (token?.accessToken == null) return null;
|
||||
_displayName = await _fetchUsername(token!.accessToken!);
|
||||
return _displayName;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _helper.removeAllTokens();
|
||||
_displayName = null;
|
||||
}
|
||||
|
||||
/// Safely fetch current access token (or null).
|
||||
Future<String?> getAccessToken() async =>
|
||||
(await _helper.getTokenFromStorage())?.accessToken;
|
||||
|
||||
/* ───────── Internal ───────── */
|
||||
|
||||
Future<String?> _fetchUsername(String accessToken) async {
|
||||
final resp = await http.get(
|
||||
Uri.parse('https://api.openstreetmap.org/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
if (resp.statusCode != 200) return null;
|
||||
return jsonDecode(resp.body)['user']?['display_name'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/pending_upload.dart';
|
||||
|
||||
class Uploader {
|
||||
Uploader(this.accessToken, this.onSuccess);
|
||||
|
||||
final String accessToken;
|
||||
final void Function() onSuccess;
|
||||
|
||||
Future<bool> upload(PendingUpload p) async {
|
||||
try {
|
||||
// 1. open changeset
|
||||
final csXml = '''
|
||||
<osm>
|
||||
<changeset>
|
||||
<tag k="created_by" v="FlockMap 0.5"/>
|
||||
<tag k="comment" v="Add surveillance camera"/>
|
||||
</changeset>
|
||||
</osm>''';
|
||||
final csResp = await _post('/api/0.6/changeset/create', csXml);
|
||||
if (csResp.statusCode != 200) return false;
|
||||
final csId = csResp.body;
|
||||
|
||||
// 2. create node
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$csId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
<tag k="man_made" v="surveillance"/>
|
||||
<tag k="surveillance:type" v="ALPR"/>
|
||||
<tag k="camera:type" v="fixed"/>
|
||||
<tag k="direction" v="${p.direction.round()}"/>
|
||||
</node>
|
||||
</osm>''';
|
||||
final nodeResp = await _put('/api/0.6/node/create', nodeXml);
|
||||
if (nodeResp.statusCode != 200) return false;
|
||||
|
||||
// 3. close changeset
|
||||
await _put('/api/0.6/changeset/$csId/close', '');
|
||||
|
||||
onSuccess();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.Response> _post(String path, String body) => http.post(
|
||||
Uri.https('api.openstreetmap.org', path),
|
||||
headers: _headers,
|
||||
body: body,
|
||||
);
|
||||
|
||||
Future<http.Response> _put(String path, String body) => http.put(
|
||||
Uri.https('api.openstreetmap.org', path),
|
||||
headers: _headers,
|
||||
body: body,
|
||||
);
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'text/xml',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user