pretty broken, icons missing, login not working. browser opens though and cams still appear on map

This commit is contained in:
stopflock
2025-07-19 14:11:02 -05:00
parent d3d1e4a7b2
commit 56518bab28
11 changed files with 689 additions and 154 deletions
+106 -30
View File
@@ -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();
}
// ---------- Addcamera 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;
}
+18 -2
View File
@@ -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 builtin for now
attempts: j['attempts'] ?? 0,
);
}
+28 -4
View File
@@ -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
? 'Loggedin as ${appState.username}'
: 'Login 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}'),
),
],
),
);
+76
View File
@@ -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 OAuth2 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
// autouse flutter_secure_storage when present.
);
}
/* ───────── Public helpers ───────── */
/// Returns `true` if a nonexpired 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'];
}
}
+66
View File
@@ -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',
};
}