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,
|
||||
});
|
||||
|
||||
<<<<<<< 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
|
||||
State<MainScaffold> createState() => _MainScaffoldState();
|
||||
}
|
||||
@@ -364,218 +344,35 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
List<GalleryPhoto> _galleryPhotos = const [];
|
||||
bool _galleryLoading = false;
|
||||
String? _galleryError;
|
||||
|
||||
// Agent state — refreshed from /agent/hints, used for upload and fullscreen
|
||||
bool _agentConnected = false;
|
||||
List<Uri> _agentEndpoints = const [];
|
||||
String _agentToken = '';
|
||||
|
||||
Timer? _statusTimer;
|
||||
<<<<<<< HEAD
|
||||
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
||||
final GoogleSignIn _googleSignIn = GoogleSignIn(
|
||||
scopes: ['email'],
|
||||
serverClientId: googleServerClientId == '' ? null : googleServerClientId,
|
||||
);
|
||||
late final TextEditingController _tokenController;
|
||||
String _accessToken = '';
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
<<<<<<< HEAD
|
||||
_tokenController = TextEditingController();
|
||||
_loadBrokerSession();
|
||||
_loadDirectUploadConfig();
|
||||
=======
|
||||
_accessToken = widget.initialAccessToken;
|
||||
_refreshToken = widget.initialRefreshToken;
|
||||
_pairingTokenController = TextEditingController();
|
||||
_loadPairingToken();
|
||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
||||
_loadGallery();
|
||||
_statusTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _refreshAgentStatus(),
|
||||
(_) => _refreshAgentHints(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_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();
|
||||
}
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────
|
||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
||||
|
||||
Future<String?> _getValidAccessToken() async {
|
||||
if (_accessToken.isNotEmpty) return _accessToken;
|
||||
@@ -602,114 +399,9 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
}
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
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();
|
||||
}
|
||||
// ── Agent hints (single call, caches connected + endpoints + token) ──────
|
||||
|
||||
Future<void> _loadDirectUploadConfig() 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 {
|
||||
Future<void> _refreshAgentHints() async {
|
||||
try {
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) return;
|
||||
@@ -719,24 +411,37 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (res.statusCode != 200) {
|
||||
setState(() => _agentConnected = false);
|
||||
setState(() {
|
||||
_agentConnected = false;
|
||||
_agentEndpoints = const [];
|
||||
_agentToken = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final body = jsonDecode(res.body);
|
||||
setState(() => _agentConnected = _extractAgentConnected(body));
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
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 (_) {
|
||||
if (mounted) setState(() => _agentConnected = false);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_agentConnected = false;
|
||||
_agentEndpoints = const [];
|
||||
_agentToken = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Gallery ──────────────────────────────────────────────────────────────
|
||||
@@ -747,7 +452,7 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
_galleryError = null;
|
||||
});
|
||||
try {
|
||||
await _refreshAgentStatus();
|
||||
await _refreshAgentHints();
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) {
|
||||
setState(() {
|
||||
@@ -838,7 +543,6 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
|
||||
// ── Upload ───────────────────────────────────────────────────────────────
|
||||
|
||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
||||
Future<void> _pickPhotos() async {
|
||||
final picked = await _picker.pickMultiImage();
|
||||
if (picked.isEmpty) return;
|
||||
@@ -890,50 +594,19 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _uploadWithFallback(File file) async {
|
||||
final hinted = await _tryBrokerHintedDirectUpload(file);
|
||||
if (hinted != null && hinted.statusCode == 200) {
|
||||
await _indexDirectUploadToBroker(file);
|
||||
return hinted;
|
||||
// Try direct LAN upload using agent token from broker hints
|
||||
if (_agentToken.isNotEmpty && _agentEndpoints.isNotEmpty) {
|
||||
for (final endpoint in _agentEndpoints) {
|
||||
final res = await _tryDirectUploadToEndpoint(file, endpoint, _agentToken);
|
||||
if (res != null && res.statusCode == 200) {
|
||||
await _indexDirectUploadToBroker(file);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
File file,
|
||||
Uri endpoint,
|
||||
@@ -956,7 +629,8 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
if (token == null) throw const HttpException('no auth session');
|
||||
final req = http.MultipartRequest(
|
||||
'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.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||
@@ -1007,141 +681,7 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
error is HttpException ||
|
||||
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) {
|
||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
final withScheme =
|
||||
@@ -1152,26 +692,22 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
}
|
||||
|
||||
Future<void> _openFullPhoto(GalleryPhoto photo) async {
|
||||
final hints =
|
||||
_pairingToken.isEmpty ? const <Uri>[] : await _fetchAgentHints();
|
||||
String? directUrl;
|
||||
if (photo.filename != null && hints.isNotEmpty) {
|
||||
directUrl = hints.first
|
||||
if (photo.filename != null &&
|
||||
_agentToken.isNotEmpty &&
|
||||
_agentEndpoints.isNotEmpty) {
|
||||
directUrl = _agentEndpoints.first
|
||||
.resolve('/api/photo/${Uri.encodeComponent(photo.filename!)}')
|
||||
.toString();
|
||||
}
|
||||
if (!mounted) return;
|
||||
<<<<<<< HEAD
|
||||
|
||||
=======
|
||||
>>>>>>> 5eb79f1 (Polish Android app: auth gate, 3-tab layout, real Google Sign-In)
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => FullPhotoPage(
|
||||
filename: photo.filename,
|
||||
brokerImageUrl: photo.brokerFullUrl,
|
||||
directImageUrl: directUrl,
|
||||
directToken: _pairingToken,
|
||||
directToken: _agentToken,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1371,8 +907,7 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
children: [
|
||||
Text(
|
||||
_agentConnected ? 'Agent connected' : 'Agent offline',
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
_agentConnected
|
||||
@@ -1385,7 +920,7 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _refreshAgentStatus,
|
||||
onPressed: _refreshAgentHints,
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh status',
|
||||
),
|
||||
@@ -1394,45 +929,6 @@ class _MainScaffoldState extends State<MainScaffold> {
|
||||
),
|
||||
),
|
||||
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
|
||||
Card(
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
@@ -1728,9 +1071,7 @@ class _AgentStatusBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = connected
|
||||
? const Color(0xFF065F46)
|
||||
: const Color(0xFF7F1D1D);
|
||||
final bg = connected ? const Color(0xFF065F46) : const Color(0xFF7F1D1D);
|
||||
final icon = connected ? Icons.cloud_done : Icons.cloud_off;
|
||||
final label = connected ? 'Connected' : 'Offline';
|
||||
return Container(
|
||||
|
||||
Reference in New Issue
Block a user