import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; class OsmUserProfile { final int id; final String displayName; final String? avatarUrl; final int changesetCount; const OsmUserProfile({ required this.id, required this.displayName, this.avatarUrl, this.changesetCount = 0, }); Map toJson() => { 'id': id, 'displayName': displayName, 'avatarUrl': avatarUrl, 'changesetCount': changesetCount, }; factory OsmUserProfile.fromJson(Map json) => OsmUserProfile( id: json['id'] as int? ?? 0, displayName: json['displayName'] as String? ?? 'OSM User', avatarUrl: json['avatarUrl'] as String?, changesetCount: json['changesetCount'] as int? ?? 0, ); } class OsmAuthState { final bool isAuthenticated; final bool isLoading; final String? accessToken; final OsmUserProfile? user; final String? errorMessage; const OsmAuthState({ this.isAuthenticated = false, this.isLoading = false, this.accessToken, this.user, this.errorMessage, }); OsmAuthState copyWith({ bool? isAuthenticated, bool? isLoading, String? accessToken, OsmUserProfile? user, String? errorMessage, }) { return OsmAuthState( isAuthenticated: isAuthenticated ?? this.isAuthenticated, isLoading: isLoading ?? this.isLoading, accessToken: accessToken ?? this.accessToken, user: user ?? this.user, errorMessage: errorMessage, ); } } class OsmOAuthService extends StateNotifier { static const String clientId = 'nkd1ogsHpcMJD1Hin9UT4bCjK8T33BjUrEwF7WAYNio'; static const String redirectUri = 'https://earnable.store/oauth/callback'; static const String appSchemeRedirect = 'motorsignal://oauth/callback'; static const String authEndpoint = 'https://www.openstreetmap.org/oauth2/authorize'; static const String tokenEndpoint = 'https://www.openstreetmap.org/oauth2/token'; static const String userDetailsEndpoint = 'https://api.openstreetmap.org/api/0.6/user/details.json'; static const String gpxUploadEndpoint = 'https://api.openstreetmap.org/api/0.6/gpx/create'; static const String _prefTokenKey = 'osm_access_token'; static const String _prefUserKey = 'osm_user_profile'; static const String _prefVerifierKey = 'osm_pkce_code_verifier'; OsmOAuthService() : super(const OsmAuthState(isLoading: true)) { _loadStoredSession(); } Future _loadStoredSession() async { try { final prefs = await SharedPreferences.getInstance(); final token = prefs.getString(_prefTokenKey); final userJsonStr = prefs.getString(_prefUserKey); if (token != null && token.isNotEmpty) { OsmUserProfile? user; if (userJsonStr != null) { try { user = OsmUserProfile.fromJson(jsonDecode(userJsonStr) as Map); } catch (_) {} } state = OsmAuthState( isAuthenticated: true, isLoading: false, accessToken: token, user: user, ); // Refresh profile in background fetchUserProfile(); return; } } catch (_) {} state = const OsmAuthState(isAuthenticated: false, isLoading: false); } String _generateRandomString(int length) { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; final rand = Random.secure(); return List.generate(length, (_) => chars[rand.nextInt(chars.length)]).join(); } String _generateCodeChallenge(String verifier) { final bytes = utf8.encode(verifier); final digest = sha256.convert(bytes); return base64Url.encode(digest.bytes).replaceAll('=', ''); } /// Launch OpenStreetMap in external browser for login & approval Future startLoginFlow() async { state = state.copyWith(isLoading: true, errorMessage: null); try { final codeVerifier = _generateRandomString(64); final codeChallenge = _generateCodeChallenge(codeVerifier); final stateParam = _generateRandomString(16); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefVerifierKey, codeVerifier); final queryParams = { 'response_type': 'code', 'client_id': clientId, 'redirect_uri': redirectUri, 'scope': 'read_prefs openid write_gpx read_gpx', 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', 'state': stateParam, }; final authUri = Uri.parse(authEndpoint).replace(queryParameters: queryParams); final launched = await launchUrl( authUri, mode: LaunchMode.externalApplication, ); state = state.copyWith(isLoading: false); return launched; } catch (e) { state = state.copyWith(isLoading: false, errorMessage: 'Failed to launch login: $e'); return false; } } /// Complete OAuth2 token exchange with auth code from redirect callback Future handleAuthCallback(Uri uri) async { final code = uri.queryParameters['code']; if (code == null || code.isEmpty) { state = state.copyWith(errorMessage: 'No authorization code received.'); return false; } state = state.copyWith(isLoading: true, errorMessage: null); try { final prefs = await SharedPreferences.getInstance(); final codeVerifier = prefs.getString(_prefVerifierKey) ?? ''; debugPrint('[OsmOAuth] Exchanging token with code: $code, redirect: $redirectUri'); final response = await http.post( Uri.parse(tokenEndpoint), headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: { 'grant_type': 'authorization_code', 'client_id': clientId, 'code': code, 'redirect_uri': redirectUri, 'code_verifier': codeVerifier, }, ); debugPrint('[OsmOAuth] Token response (${response.statusCode}): ${response.body}'); if (response.statusCode == 200) { final json = jsonDecode(response.body) as Map; final accessToken = json['access_token'] as String; await prefs.setString(_prefTokenKey, accessToken); await prefs.remove(_prefVerifierKey); state = OsmAuthState( isAuthenticated: true, isLoading: false, accessToken: accessToken, ); await fetchUserProfile(); return true; } else { state = state.copyWith( isLoading: false, errorMessage: 'Token exchange failed (${response.statusCode}): ${response.body}', ); return false; } } catch (e) { debugPrint('[OsmOAuth] Exception during auth callback: $e'); state = state.copyWith(isLoading: false, errorMessage: 'Authentication error: $e'); return false; } } /// Fetch OpenStreetMap user profile details Future fetchUserProfile() async { final token = state.accessToken; if (token == null || token.isEmpty) return; try { final response = await http.get( Uri.parse(userDetailsEndpoint), headers: { 'Authorization': 'Bearer $token', 'Accept': 'application/json', }, ); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; final userObj = data['user'] as Map?; if (userObj != null) { final profile = OsmUserProfile( id: userObj['id'] as int? ?? 0, displayName: userObj['display_name'] as String? ?? 'OSM User', avatarUrl: (userObj['img'] as Map?)?['href'] as String?, changesetCount: (userObj['changesets'] as Map?)?['count'] as int? ?? 0, ); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefUserKey, jsonEncode(profile.toJson())); state = state.copyWith(user: profile); } } } catch (_) {} } /// Upload recorded Ride GPX track directly to OpenStreetMap Future uploadGpxTrack({ required String gpxXmlContent, required String filename, required String description, List tags = const ['motorcycle', 'motorsignal'], String visibility = 'identifiable', // 'private', 'public', 'trackable', 'identifiable' }) async { final token = state.accessToken; if (token == null || token.isEmpty) return false; try { final request = http.MultipartRequest('POST', Uri.parse(gpxUploadEndpoint)) ..headers['Authorization'] = 'Bearer $token' ..fields['description'] = description ..fields['tags'] = tags.join(',') ..fields['visibility'] = visibility ..files.add( http.MultipartFile.fromString( 'file', gpxXmlContent, filename: filename, ), ); final streamedResponse = await request.send(); return streamedResponse.statusCode == 200 || streamedResponse.statusCode == 201; } catch (_) { return false; } } /// Disconnect / Sign out from OpenStreetMap Future logout() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_prefTokenKey); await prefs.remove(_prefUserKey); await prefs.remove(_prefVerifierKey); state = const OsmAuthState(isAuthenticated: false, isLoading: false); } } final osmOAuthServiceProvider = StateNotifierProvider((ref) { return OsmOAuthService(); });