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:
2026-06-05 17:56:13 +08:00
parent a5a946b20f
commit fa1cb9ef48
2 changed files with 55 additions and 714 deletions

View File

@@ -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(