Auto-pairing: eliminate manual LAN pairing token from app
App fetches agent token automatically from /agent/hints after sign-in. Settings tab no longer shows a pairing token input field. Also resolves leftover merge conflict markers in main.dart. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -334,26 +334,6 @@ class MainScaffold extends StatefulWidget {
|
|||||||
required this.onSignedOut,
|
required this.onSignedOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
static const defaults = DirectUploadConfig(
|
|
||||||
enabled: false,
|
|
||||||
endpoint: '',
|
|
||||||
token: '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthResult {
|
|
||||||
final bool ok;
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
const AuthResult(this.ok, this.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
class UploadPage extends StatefulWidget {
|
|
||||||
const UploadPage({super.key});
|
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
@override
|
@override
|
||||||
State<MainScaffold> createState() => _MainScaffoldState();
|
State<MainScaffold> createState() => _MainScaffoldState();
|
||||||
}
|
}
|
||||||
@@ -364,218 +344,35 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
List<GalleryPhoto> _galleryPhotos = const [];
|
List<GalleryPhoto> _galleryPhotos = const [];
|
||||||
bool _galleryLoading = false;
|
bool _galleryLoading = false;
|
||||||
String? _galleryError;
|
String? _galleryError;
|
||||||
|
|
||||||
|
// Agent state — refreshed from /agent/hints, used for upload and fullscreen
|
||||||
bool _agentConnected = false;
|
bool _agentConnected = false;
|
||||||
|
List<Uri> _agentEndpoints = const [];
|
||||||
|
String _agentToken = '';
|
||||||
|
|
||||||
Timer? _statusTimer;
|
Timer? _statusTimer;
|
||||||
<<<<<<< HEAD
|
|
||||||
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
|
||||||
final GoogleSignIn _googleSignIn = GoogleSignIn(
|
|
||||||
scopes: ['email'],
|
|
||||||
serverClientId: googleServerClientId == '' ? null : googleServerClientId,
|
|
||||||
);
|
|
||||||
late final TextEditingController _tokenController;
|
|
||||||
String _accessToken = '';
|
String _accessToken = '';
|
||||||
String _refreshToken = '';
|
String _refreshToken = '';
|
||||||
String _userEmail = '';
|
|
||||||
String _userDisplayName = '';
|
|
||||||
String _userPictureUrl = '';
|
|
||||||
String _agentLanEndpoint = '';
|
|
||||||
bool _editingPairing = false;
|
|
||||||
=======
|
|
||||||
String _accessToken = '';
|
|
||||||
String _refreshToken = '';
|
|
||||||
String _pairingToken = '';
|
|
||||||
late final TextEditingController _pairingTokenController;
|
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
<<<<<<< HEAD
|
|
||||||
_tokenController = TextEditingController();
|
|
||||||
_loadBrokerSession();
|
|
||||||
_loadDirectUploadConfig();
|
|
||||||
=======
|
|
||||||
_accessToken = widget.initialAccessToken;
|
_accessToken = widget.initialAccessToken;
|
||||||
_refreshToken = widget.initialRefreshToken;
|
_refreshToken = widget.initialRefreshToken;
|
||||||
_pairingTokenController = TextEditingController();
|
|
||||||
_loadPairingToken();
|
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
_loadGallery();
|
_loadGallery();
|
||||||
_statusTimer = Timer.periodic(
|
_statusTimer = Timer.periodic(
|
||||||
const Duration(seconds: 30),
|
const Duration(seconds: 30),
|
||||||
(_) => _refreshAgentStatus(),
|
(_) => _refreshAgentHints(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_statusTimer?.cancel();
|
_statusTimer?.cancel();
|
||||||
<<<<<<< HEAD
|
|
||||||
_tokenController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadBrokerSession() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_accessToken = prefs.getString('broker_access_token') ?? '';
|
|
||||||
_refreshToken = prefs.getString('broker_refresh_token') ?? '';
|
|
||||||
_userEmail = prefs.getString('broker_user_email') ?? '';
|
|
||||||
_userDisplayName = prefs.getString('broker_user_display_name') ?? '';
|
|
||||||
_userPictureUrl = prefs.getString('broker_user_picture_url') ?? '';
|
|
||||||
});
|
|
||||||
if (_accessToken.isNotEmpty || _refreshToken.isNotEmpty) {
|
|
||||||
unawaited(_fetchCurrentUser());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _saveBrokerSession({
|
|
||||||
required String accessToken,
|
|
||||||
required String refreshToken,
|
|
||||||
}) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString('broker_access_token', accessToken);
|
|
||||||
await prefs.setString('broker_refresh_token', refreshToken);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_accessToken = accessToken;
|
|
||||||
_refreshToken = refreshToken;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _saveUserProfile(Map<String, dynamic> user) async {
|
|
||||||
final email = (user['email'] ?? '').toString();
|
|
||||||
final displayName = (user['display_name'] ?? '').toString();
|
|
||||||
final pictureUrl = (user['picture_url'] ?? '').toString();
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString('broker_user_email', email);
|
|
||||||
await prefs.setString('broker_user_display_name', displayName);
|
|
||||||
await prefs.setString('broker_user_picture_url', pictureUrl);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_userEmail = email;
|
|
||||||
_userDisplayName = displayName;
|
|
||||||
_userPictureUrl = pictureUrl;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchCurrentUser() async {
|
|
||||||
try {
|
|
||||||
final token = await _getValidAccessToken();
|
|
||||||
if (token == null) return;
|
|
||||||
final res = await http.get(
|
|
||||||
Uri.parse('$brokerUrl/me'),
|
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
|
||||||
);
|
|
||||||
if (res.statusCode != 200) return;
|
|
||||||
final body = jsonDecode(res.body);
|
|
||||||
if (body is Map<String, dynamic>) {
|
|
||||||
await _saveUserProfile(body);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Keep cached profile if refresh fails.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearBrokerSession() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.remove('broker_access_token');
|
|
||||||
await prefs.remove('broker_refresh_token');
|
|
||||||
await prefs.remove('broker_user_email');
|
|
||||||
await prefs.remove('broker_user_display_name');
|
|
||||||
await prefs.remove('broker_user_picture_url');
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_accessToken = '';
|
|
||||||
_refreshToken = '';
|
|
||||||
_userEmail = '';
|
|
||||||
_userDisplayName = '';
|
|
||||||
_userPictureUrl = '';
|
|
||||||
_agentConnected = false;
|
|
||||||
_agentLanEndpoint = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AuthResult> _exchangeGoogleIdToken(String idToken) async {
|
|
||||||
if (idToken.isEmpty) {
|
|
||||||
return const AuthResult(false, 'Google did not return an ID token.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final res = await http.post(
|
|
||||||
Uri.parse('$brokerUrl/auth/google'),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: jsonEncode({'id_token': idToken}),
|
|
||||||
);
|
|
||||||
if (res.statusCode != 200) {
|
|
||||||
var detail = res.body.trim();
|
|
||||||
try {
|
|
||||||
final body = jsonDecode(res.body);
|
|
||||||
if (body is Map && body['error'] != null) {
|
|
||||||
detail = body['error'].toString();
|
|
||||||
} else if (body is Map && body['detail'] != null) {
|
|
||||||
detail = body['detail'].toString();
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Keep raw response body.
|
|
||||||
}
|
|
||||||
return AuthResult(
|
|
||||||
false,
|
|
||||||
'Broker rejected Google sign-in (${res.statusCode}): $detail',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final body = jsonDecode(res.body);
|
|
||||||
final accessToken = (body['access_token'] ?? '').toString();
|
|
||||||
final refreshToken = (body['refresh_token'] ?? '').toString();
|
|
||||||
if (accessToken.isEmpty || refreshToken.isEmpty) {
|
|
||||||
return const AuthResult(
|
|
||||||
false,
|
|
||||||
'Broker response did not include a session.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await _saveBrokerSession(
|
|
||||||
accessToken: accessToken,
|
|
||||||
refreshToken: refreshToken,
|
|
||||||
);
|
|
||||||
final user = body['user'];
|
|
||||||
if (user is Map<String, dynamic>) {
|
|
||||||
await _saveUserProfile(user);
|
|
||||||
} else {
|
|
||||||
await _fetchCurrentUser();
|
|
||||||
}
|
|
||||||
return const AuthResult(true, 'Broker sign-in successful.');
|
|
||||||
} catch (e) {
|
|
||||||
return AuthResult(false, 'Sign-in request failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AuthResult> _signInWithGoogle() async {
|
|
||||||
try {
|
|
||||||
final account = await _googleSignIn.signIn();
|
|
||||||
if (account == null) {
|
|
||||||
return const AuthResult(false, 'Google sign-in was cancelled.');
|
|
||||||
}
|
|
||||||
final auth = await account.authentication;
|
|
||||||
final idToken = auth.idToken;
|
|
||||||
if (idToken == null || idToken.isEmpty) {
|
|
||||||
return const AuthResult(
|
|
||||||
false,
|
|
||||||
'Google did not return an ID token. Check Android OAuth client config.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _exchangeGoogleIdToken(idToken);
|
|
||||||
} catch (e) {
|
|
||||||
return AuthResult(false, 'Google sign-in failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
=======
|
|
||||||
_pairingTokenController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session ──────────────────────────────────────────────────────────────
|
// ── Session ──────────────────────────────────────────────────────────────
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
|
|
||||||
Future<String?> _getValidAccessToken() async {
|
Future<String?> _getValidAccessToken() async {
|
||||||
if (_accessToken.isNotEmpty) return _accessToken;
|
if (_accessToken.isNotEmpty) return _accessToken;
|
||||||
@@ -602,114 +399,9 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
// ── Agent hints (single call, caches connected + endpoints + token) ──────
|
||||||
Future<void> _signOut() async {
|
|
||||||
final refreshToken = _refreshToken;
|
|
||||||
try {
|
|
||||||
if (refreshToken.isNotEmpty) {
|
|
||||||
await http.post(
|
|
||||||
Uri.parse('$brokerUrl/auth/logout'),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: jsonEncode({'refresh_token': refreshToken}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Local sign-out should still complete.
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await _googleSignIn.signOut();
|
|
||||||
} catch (_) {
|
|
||||||
// Google session may already be absent.
|
|
||||||
}
|
|
||||||
await _clearBrokerSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadDirectUploadConfig() async {
|
Future<void> _refreshAgentHints() async {
|
||||||
=======
|
|
||||||
// ── Pairing token ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Future<void> _loadPairingToken() async {
|
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final token = prefs.getString('direct_upload_token') ?? '';
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _pairingToken = token);
|
|
||||||
_pairingTokenController.text = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
Future<void> _saveDirectUploadConfig() async {
|
|
||||||
final token = _tokenController.text.trim();
|
|
||||||
if (token.isEmpty) return;
|
|
||||||
final registered = await _registerPairingTokenWithBroker(token);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
registered
|
|
||||||
? 'Pairing token registered with broker.'
|
|
||||||
: 'Could not register pairing token with broker.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
=======
|
|
||||||
Future<void> _savePairingToken() async {
|
|
||||||
final token = _pairingTokenController.text.trim();
|
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString('direct_upload_token', token);
|
|
||||||
if (!mounted) return;
|
|
||||||
<<<<<<< HEAD
|
|
||||||
setState(() {
|
|
||||||
_directConfig = DirectUploadConfig(
|
|
||||||
enabled: true,
|
|
||||||
endpoint: '',
|
|
||||||
token: token,
|
|
||||||
);
|
|
||||||
_editingPairing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _hasBrokerSession =>
|
|
||||||
_accessToken.isNotEmpty || _refreshToken.isNotEmpty;
|
|
||||||
|
|
||||||
bool get _hasSavedPairingToken => _directConfig.token.trim().isNotEmpty;
|
|
||||||
|
|
||||||
bool get _pairingInputChanged =>
|
|
||||||
_tokenController.text.trim() != _directConfig.token.trim();
|
|
||||||
|
|
||||||
Future<bool> _registerPairingTokenWithBroker(String pairingToken) async {
|
|
||||||
try {
|
|
||||||
final accessToken = await _getValidAccessToken();
|
|
||||||
if (accessToken == null) return false;
|
|
||||||
final res = await http.post(
|
|
||||||
Uri.parse('$brokerUrl/agents/pairing-token'),
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer $accessToken',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: jsonEncode({
|
|
||||||
'pairing_token': pairingToken,
|
|
||||||
'name': 'Primary Agent',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return res.statusCode == 200;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
=======
|
|
||||||
setState(() => _pairingToken = token);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Pairing token saved.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Agent status ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Future<void> _refreshAgentStatus() async {
|
|
||||||
try {
|
try {
|
||||||
final token = await _getValidAccessToken();
|
final token = await _getValidAccessToken();
|
||||||
if (token == null) return;
|
if (token == null) return;
|
||||||
@@ -719,24 +411,37 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (res.statusCode != 200) {
|
if (res.statusCode != 200) {
|
||||||
setState(() => _agentConnected = false);
|
setState(() {
|
||||||
|
_agentConnected = false;
|
||||||
|
_agentEndpoints = const [];
|
||||||
|
_agentToken = '';
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final body = jsonDecode(res.body);
|
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
setState(() => _agentConnected = _extractAgentConnected(body));
|
final connected = body['agent_connected'] == true;
|
||||||
|
final rawEndpoints = body['lan_endpoints'];
|
||||||
|
final endpoints = rawEndpoints is List
|
||||||
|
? rawEndpoints
|
||||||
|
.map((e) => _normalizeEndpoint(e.toString()))
|
||||||
|
.whereType<Uri>()
|
||||||
|
.toList(growable: false)
|
||||||
|
: const <Uri>[];
|
||||||
|
final agentToken = (body['agent_token'] ?? '').toString();
|
||||||
|
setState(() {
|
||||||
|
_agentConnected = connected;
|
||||||
|
_agentEndpoints = endpoints;
|
||||||
|
_agentToken = agentToken;
|
||||||
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) setState(() => _agentConnected = false);
|
if (mounted) {
|
||||||
}
|
setState(() {
|
||||||
}
|
_agentConnected = false;
|
||||||
|
_agentEndpoints = const [];
|
||||||
bool _extractAgentConnected(dynamic body) {
|
_agentToken = '';
|
||||||
if (body is Map<String, dynamic>) {
|
});
|
||||||
for (final key in const ['agent_connected', 'connected', 'is_connected']) {
|
|
||||||
final value = body[key];
|
|
||||||
if (value is bool) return value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gallery ──────────────────────────────────────────────────────────────
|
// ── Gallery ──────────────────────────────────────────────────────────────
|
||||||
@@ -747,7 +452,7 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
_galleryError = null;
|
_galleryError = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await _refreshAgentStatus();
|
await _refreshAgentHints();
|
||||||
final token = await _getValidAccessToken();
|
final token = await _getValidAccessToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -838,7 +543,6 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
|
|
||||||
// ── Upload ───────────────────────────────────────────────────────────────
|
// ── Upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
Future<void> _pickPhotos() async {
|
Future<void> _pickPhotos() async {
|
||||||
final picked = await _picker.pickMultiImage();
|
final picked = await _picker.pickMultiImage();
|
||||||
if (picked.isEmpty) return;
|
if (picked.isEmpty) return;
|
||||||
@@ -890,50 +594,19 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<http.StreamedResponse> _uploadWithFallback(File file) async {
|
Future<http.StreamedResponse> _uploadWithFallback(File file) async {
|
||||||
final hinted = await _tryBrokerHintedDirectUpload(file);
|
// Try direct LAN upload using agent token from broker hints
|
||||||
if (hinted != null && hinted.statusCode == 200) {
|
if (_agentToken.isNotEmpty && _agentEndpoints.isNotEmpty) {
|
||||||
await _indexDirectUploadToBroker(file);
|
for (final endpoint in _agentEndpoints) {
|
||||||
return hinted;
|
final res = await _tryDirectUploadToEndpoint(file, endpoint, _agentToken);
|
||||||
|
if (res != null && res.statusCode == 200) {
|
||||||
|
await _indexDirectUploadToBroker(file);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return _uploadViaBroker(file);
|
return _uploadViaBroker(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<http.StreamedResponse?> _tryBrokerHintedDirectUpload(File file) async {
|
|
||||||
if (_pairingToken.isEmpty) return null;
|
|
||||||
final hints = await _fetchAgentHints();
|
|
||||||
if (hints.isEmpty) return null;
|
|
||||||
for (final endpoint in hints) {
|
|
||||||
final res =
|
|
||||||
await _tryDirectUploadToEndpoint(file, endpoint, _pairingToken);
|
|
||||||
if (res != null && res.statusCode == 200) return res;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Uri>> _fetchAgentHints() async {
|
|
||||||
try {
|
|
||||||
final token = await _getValidAccessToken();
|
|
||||||
if (token == null) return const [];
|
|
||||||
final res = await http.get(
|
|
||||||
Uri.parse('$_brokerUrl/agent/hints'),
|
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
|
||||||
);
|
|
||||||
if (res.statusCode != 200) return const [];
|
|
||||||
final body = jsonDecode(res.body);
|
|
||||||
if (body is! Map<String, dynamic>) return const [];
|
|
||||||
final endpoints = body['lan_endpoints'];
|
|
||||||
if (endpoints is! List) return const [];
|
|
||||||
final out = <Uri>[];
|
|
||||||
for (final raw in endpoints) {
|
|
||||||
final uri = _normalizeEndpoint(raw.toString());
|
|
||||||
if (uri != null) out.add(uri);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
} catch (_) {
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<http.StreamedResponse?> _tryDirectUploadToEndpoint(
|
Future<http.StreamedResponse?> _tryDirectUploadToEndpoint(
|
||||||
File file,
|
File file,
|
||||||
Uri endpoint,
|
Uri endpoint,
|
||||||
@@ -956,7 +629,8 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
if (token == null) throw const HttpException('no auth session');
|
if (token == null) throw const HttpException('no auth session');
|
||||||
final req = http.MultipartRequest(
|
final req = http.MultipartRequest(
|
||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$_brokerUrl/upload/${Uri.encodeComponent(p.basename(file.path))}'),
|
Uri.parse(
|
||||||
|
'$_brokerUrl/upload/${Uri.encodeComponent(p.basename(file.path))}'),
|
||||||
);
|
);
|
||||||
req.headers['Authorization'] = 'Bearer $token';
|
req.headers['Authorization'] = 'Bearer $token';
|
||||||
req.files.add(await http.MultipartFile.fromPath('file', file.path));
|
req.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||||
@@ -1007,141 +681,7 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
error is HttpException ||
|
error is HttpException ||
|
||||||
error is http.ClientException;
|
error is http.ClientException;
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
final body = jsonDecode(res.body);
|
|
||||||
final items = _extractPhotoItems(body);
|
|
||||||
final photos = items
|
|
||||||
.map((x) => _toGalleryPhoto(x, token))
|
|
||||||
.whereType<GalleryPhoto>()
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_galleryPhotos = photos;
|
|
||||||
_galleryLoading = false;
|
|
||||||
});
|
|
||||||
} catch (_) {
|
|
||||||
setState(() {
|
|
||||||
_galleryLoading = false;
|
|
||||||
_galleryError = 'Failed to load gallery.';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _extractAgentConnected(dynamic body) {
|
|
||||||
if (body is Map<String, dynamic>) {
|
|
||||||
for (final key in const [
|
|
||||||
'agent_connected',
|
|
||||||
'connected',
|
|
||||||
'is_connected',
|
|
||||||
]) {
|
|
||||||
final value = body[key];
|
|
||||||
if (value is bool) return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshAgentStatus() async {
|
|
||||||
try {
|
|
||||||
final token = await _getValidAccessToken();
|
|
||||||
if (token == null) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_agentConnected = false;
|
|
||||||
_agentLanEndpoint = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final res = await http.get(
|
|
||||||
Uri.parse('$brokerUrl/agent/hints'),
|
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
|
||||||
);
|
|
||||||
if (res.statusCode != 200) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_agentConnected = false;
|
|
||||||
_agentLanEndpoint = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final body = jsonDecode(res.body);
|
|
||||||
final connected = _extractAgentConnected(body);
|
|
||||||
var endpoint = '';
|
|
||||||
if (body is Map<String, dynamic> && body['lan_endpoints'] is List) {
|
|
||||||
for (final raw in body['lan_endpoints'] as List) {
|
|
||||||
final value = raw.toString().trim();
|
|
||||||
if (value.isNotEmpty) {
|
|
||||||
endpoint = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_agentConnected = connected;
|
|
||||||
_agentLanEndpoint = endpoint;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_agentConnected = false;
|
|
||||||
_agentLanEndpoint = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Map<String, dynamic>> _extractPhotoItems(dynamic body) {
|
|
||||||
final raw = body is List
|
|
||||||
? body
|
|
||||||
: body is Map<String, dynamic> && body['photos'] is List
|
|
||||||
? body['photos'] as List
|
|
||||||
: const [];
|
|
||||||
return raw
|
|
||||||
.whereType<Map>()
|
|
||||||
.map((x) => Map<String, dynamic>.from(x))
|
|
||||||
.toList(growable: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item, String accessToken) {
|
|
||||||
final thumbRaw = item['thumbnail_url'];
|
|
||||||
if (thumbRaw is! String) return null;
|
|
||||||
|
|
||||||
final thumb = _resolveUrl(thumbRaw, accessToken);
|
|
||||||
if (thumb.isEmpty) return null;
|
|
||||||
|
|
||||||
final rawFilename = item['filename'];
|
|
||||||
final filename = rawFilename is String && rawFilename.trim().isNotEmpty
|
|
||||||
? rawFilename.trim()
|
|
||||||
: null;
|
|
||||||
final explicitFull = item['photo_url'] ?? item['full_url'] ?? item['url'];
|
|
||||||
String full;
|
|
||||||
|
|
||||||
if (explicitFull is String && explicitFull.trim().isNotEmpty) {
|
|
||||||
full = _resolveUrl(explicitFull, accessToken);
|
|
||||||
} else if (filename != null) {
|
|
||||||
final encoded = Uri.encodeComponent(filename);
|
|
||||||
full = '$brokerUrl/photo/$encoded';
|
|
||||||
} else if (item['id'] != null) {
|
|
||||||
full = '$brokerUrl/photo/${item['id']}';
|
|
||||||
} else {
|
|
||||||
full = thumb;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GalleryPhoto(
|
|
||||||
thumbnailUrl: thumb,
|
|
||||||
brokerFullUrl: full,
|
|
||||||
filename: filename,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _resolveUrl(String value, String accessToken) {
|
|
||||||
=======
|
|
||||||
Uri? _normalizeEndpoint(String value) {
|
Uri? _normalizeEndpoint(String value) {
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
if (trimmed.isEmpty) return null;
|
if (trimmed.isEmpty) return null;
|
||||||
final withScheme =
|
final withScheme =
|
||||||
@@ -1152,26 +692,22 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openFullPhoto(GalleryPhoto photo) async {
|
Future<void> _openFullPhoto(GalleryPhoto photo) async {
|
||||||
final hints =
|
|
||||||
_pairingToken.isEmpty ? const <Uri>[] : await _fetchAgentHints();
|
|
||||||
String? directUrl;
|
String? directUrl;
|
||||||
if (photo.filename != null && hints.isNotEmpty) {
|
if (photo.filename != null &&
|
||||||
directUrl = hints.first
|
_agentToken.isNotEmpty &&
|
||||||
|
_agentEndpoints.isNotEmpty) {
|
||||||
|
directUrl = _agentEndpoints.first
|
||||||
.resolve('/api/photo/${Uri.encodeComponent(photo.filename!)}')
|
.resolve('/api/photo/${Uri.encodeComponent(photo.filename!)}')
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => FullPhotoPage(
|
builder: (_) => FullPhotoPage(
|
||||||
filename: photo.filename,
|
filename: photo.filename,
|
||||||
brokerImageUrl: photo.brokerFullUrl,
|
brokerImageUrl: photo.brokerFullUrl,
|
||||||
directImageUrl: directUrl,
|
directImageUrl: directUrl,
|
||||||
directToken: _pairingToken,
|
directToken: _agentToken,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1371,8 +907,7 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_agentConnected ? 'Agent connected' : 'Agent offline',
|
_agentConnected ? 'Agent connected' : 'Agent offline',
|
||||||
style:
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
const TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_agentConnected
|
_agentConnected
|
||||||
@@ -1385,7 +920,7 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _refreshAgentStatus,
|
onPressed: _refreshAgentHints,
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
tooltip: 'Refresh status',
|
tooltip: 'Refresh status',
|
||||||
),
|
),
|
||||||
@@ -1394,45 +929,6 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// LAN pairing token
|
|
||||||
Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'LAN Pairing Token',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w600, fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Enter the token from your agent to enable direct uploads on the same Wi-Fi network.',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: _pairingTokenController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Pairing token',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: _savePairingToken,
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// Sign out
|
// Sign out
|
||||||
Card(
|
Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
@@ -1467,159 +963,6 @@ class _MainScaffoldState extends State<MainScaffold> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
Widget _buildDirectUploadSettings() {
|
|
||||||
final signedIn = _hasBrokerSession;
|
|
||||||
final paired = _hasSavedPairingToken;
|
|
||||||
final canSavePairing =
|
|
||||||
signedIn &&
|
|
||||||
_tokenController.text.trim().isNotEmpty &&
|
|
||||||
(!paired || _editingPairing) &&
|
|
||||||
_pairingInputChanged;
|
|
||||||
final accountLabel = _userDisplayName.trim().isNotEmpty
|
|
||||||
? _userDisplayName.trim()
|
|
||||||
: _userEmail.trim().isNotEmpty
|
|
||||||
? _userEmail.trim()
|
|
||||||
: 'Signed in with Google';
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (signedIn && _userPictureUrl.isNotEmpty)
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 12,
|
|
||||||
backgroundImage: NetworkImage(_userPictureUrl),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Icon(
|
|
||||||
signedIn ? Icons.verified_user : Icons.account_circle,
|
|
||||||
size: 18,
|
|
||||||
color: signedIn ? const Color(0xFF0F766E) : Colors.black54,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
signedIn ? accountLabel : 'Not signed in',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
if (signedIn &&
|
|
||||||
_userEmail.isNotEmpty &&
|
|
||||||
_userEmail != accountLabel)
|
|
||||||
Text(
|
|
||||||
_userEmail,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (signedIn)
|
|
||||||
TextButton(onPressed: _signOut, child: const Text('Sign Out'))
|
|
||||||
else
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final result = await _signInWithGoogle();
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(result.message)));
|
|
||||||
if (result.ok) {
|
|
||||||
await _loadGallery();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Sign In'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
paired ? Icons.lan : Icons.link_off,
|
|
||||||
size: 18,
|
|
||||||
color: paired ? const Color(0xFF0F766E) : Colors.black54,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
paired
|
|
||||||
? _agentConnected
|
|
||||||
? 'Agent paired and connected'
|
|
||||||
: 'Agent paired, waiting for connection'
|
|
||||||
: 'Agent not paired',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
if (_agentLanEndpoint.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
_agentLanEndpoint,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (!signedIn || !paired) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
!signedIn
|
|
||||||
? 'Sign in before pairing this phone with your agent.'
|
|
||||||
: 'Enter the pairing token from your agent.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const Divider(height: 20),
|
|
||||||
TextField(
|
|
||||||
controller: _tokenController,
|
|
||||||
enabled: signedIn && (!paired || _editingPairing),
|
|
||||||
onChanged: (_) => setState(() {}),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Pairing token',
|
|
||||||
helperText: paired
|
|
||||||
? 'Saved. Change the token only when re-pairing.'
|
|
||||||
: 'Required once for direct LAN upload.',
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
if (paired && !_editingPairing)
|
|
||||||
TextButton(
|
|
||||||
onPressed: signedIn
|
|
||||||
? () => setState(() => _editingPairing = true)
|
|
||||||
: null,
|
|
||||||
child: const Text('Change'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: canSavePairing ? _saveDirectUploadConfig : null,
|
|
||||||
child: Text(
|
|
||||||
paired ? 'LAN Settings Saved' : 'Save LAN Settings',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
=======
|
|
||||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Full-photo page ──────────────────────────────────────────────────────────
|
// ─── Full-photo page ──────────────────────────────────────────────────────────
|
||||||
@@ -1728,9 +1071,7 @@ class _AgentStatusBadge extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bg = connected
|
final bg = connected ? const Color(0xFF065F46) : const Color(0xFF7F1D1D);
|
||||||
? const Color(0xFF065F46)
|
|
||||||
: const Color(0xFF7F1D1D);
|
|
||||||
final icon = connected ? Icons.cloud_done : Icons.cloud_off;
|
final icon = connected ? Icons.cloud_done : Icons.cloud_off;
|
||||||
final label = connected ? 'Connected' : 'Offline';
|
final label = connected ? 'Connected' : 'Offline';
|
||||||
return Container(
|
return Container(
|
||||||
|
|||||||
2
memory
2
memory
Submodule memory updated: 489949461d...6e2bba9e27
Reference in New Issue
Block a user