Complete Phase 4b direct fullscreen fetch and ship app/agent updates
This commit is contained in:
744
memoric-app/lib/main.dart
Normal file
744
memoric-app/lib/main.dart
Normal file
@@ -0,0 +1,744 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const brokerUrl = 'https://memory.clouderized.com';
|
||||
|
||||
void main() => runApp(const MemoryApp());
|
||||
|
||||
class MemoryApp extends StatelessWidget {
|
||||
const MemoryApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Memory by Clouderized',
|
||||
theme: ThemeData(colorSchemeSeed: const Color(0xFF0F766E)),
|
||||
home: const UploadPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum UploadStatus { pending, uploading, done, failed }
|
||||
|
||||
class PhotoItem {
|
||||
final File file;
|
||||
UploadStatus status;
|
||||
PhotoItem(this.file, {this.status = UploadStatus.pending});
|
||||
}
|
||||
|
||||
class GalleryPhoto {
|
||||
final String thumbnailUrl;
|
||||
final String brokerFullUrl;
|
||||
final String? filename;
|
||||
|
||||
const GalleryPhoto({
|
||||
required this.thumbnailUrl,
|
||||
required this.brokerFullUrl,
|
||||
required this.filename,
|
||||
});
|
||||
}
|
||||
|
||||
class DirectUploadConfig {
|
||||
final bool enabled;
|
||||
final String endpoint;
|
||||
final String token;
|
||||
|
||||
const DirectUploadConfig({
|
||||
required this.enabled,
|
||||
required this.endpoint,
|
||||
required this.token,
|
||||
});
|
||||
|
||||
static const defaults = DirectUploadConfig(
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
token: '',
|
||||
);
|
||||
}
|
||||
|
||||
class UploadPage extends StatefulWidget {
|
||||
const UploadPage({super.key});
|
||||
|
||||
@override
|
||||
State<UploadPage> createState() => _UploadPageState();
|
||||
}
|
||||
|
||||
class _UploadPageState extends State<UploadPage> {
|
||||
final _picker = ImagePicker();
|
||||
final List<PhotoItem> _photos = [];
|
||||
List<GalleryPhoto> _galleryPhotos = const [];
|
||||
bool _galleryLoading = false;
|
||||
String? _galleryError;
|
||||
bool _agentConnected = false;
|
||||
Timer? _statusTimer;
|
||||
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
||||
late final TextEditingController _tokenController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tokenController = TextEditingController();
|
||||
_loadDirectUploadConfig();
|
||||
_loadGallery();
|
||||
_statusTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _refreshAgentStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_statusTimer?.cancel();
|
||||
_tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadDirectUploadConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cfg = DirectUploadConfig(
|
||||
enabled: prefs.getBool('direct_upload_enabled') ?? false,
|
||||
endpoint: prefs.getString('direct_upload_endpoint') ?? '',
|
||||
token: prefs.getString('direct_upload_token') ?? '',
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_directConfig = cfg;
|
||||
_tokenController.text = cfg.token;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveDirectUploadConfig() async {
|
||||
final token = _tokenController.text.trim();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('direct_upload_enabled', true);
|
||||
await prefs.remove('direct_upload_endpoint');
|
||||
await prefs.setString('direct_upload_token', token);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_directConfig = DirectUploadConfig(
|
||||
enabled: true,
|
||||
endpoint: '',
|
||||
token: token,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickPhotos() async {
|
||||
final picked = await _picker.pickMultiImage();
|
||||
if (picked.isEmpty) return;
|
||||
setState(() {
|
||||
for (final x in picked) {
|
||||
_photos.add(PhotoItem(File(x.path)));
|
||||
}
|
||||
});
|
||||
_uploadAll();
|
||||
}
|
||||
|
||||
Future<void> _uploadAll() async {
|
||||
for (final item in _photos.where((i) => i.status == UploadStatus.pending)) {
|
||||
setState(() => item.status = UploadStatus.uploading);
|
||||
try {
|
||||
final res = await _uploadWithFallback(item.file);
|
||||
setState(() {
|
||||
item.status = res.statusCode == 200
|
||||
? UploadStatus.done
|
||||
: UploadStatus.failed;
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() => item.status = UploadStatus.failed);
|
||||
}
|
||||
}
|
||||
await _loadGallery();
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _uploadWithFallback(File file) async {
|
||||
final hinted = await _tryBrokerHintedDirectUpload(file);
|
||||
if (hinted != null && hinted.statusCode == 200) {
|
||||
await _indexDirectUploadToBroker(file);
|
||||
return hinted;
|
||||
}
|
||||
|
||||
return _uploadViaBroker(file);
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse?> _tryBrokerHintedDirectUpload(File file) async {
|
||||
final token = _directConfig.token.trim();
|
||||
if (token.isEmpty) return null;
|
||||
final hints = await _fetchAgentHints();
|
||||
if (hints.isEmpty) return null;
|
||||
for (final endpoint in hints) {
|
||||
final res = await _tryDirectUploadToEndpoint(file, endpoint, token);
|
||||
if (res != null && res.statusCode == 200) return res;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<Uri>> _fetchAgentHints() async {
|
||||
try {
|
||||
final res = await http.get(Uri.parse('$brokerUrl/agent/hints'));
|
||||
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 = _normalizeDirectEndpoint(raw.toString());
|
||||
if (uri != null) out.add(uri);
|
||||
}
|
||||
return out;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse?> _tryDirectUploadToEndpoint(
|
||||
File file,
|
||||
Uri endpoint,
|
||||
String token,
|
||||
) async {
|
||||
try {
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
endpoint.resolve('/api/upload'),
|
||||
);
|
||||
req.headers['Authorization'] = 'Bearer $token';
|
||||
req.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||
req.fields['filename'] = p.basename(file.path);
|
||||
return await req.send();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _uploadViaBroker(File file) async {
|
||||
final filename = p.basename(file.path);
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$brokerUrl/upload/$filename'),
|
||||
);
|
||||
req.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||
return req.send();
|
||||
}
|
||||
|
||||
Uri? _normalizeDirectEndpoint(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
final withScheme = trimmed.contains('://') ? trimmed : 'http://$trimmed';
|
||||
final uri = Uri.tryParse(withScheme);
|
||||
if (uri == null || uri.host.isEmpty) return null;
|
||||
return uri;
|
||||
}
|
||||
|
||||
Future<void> _indexDirectUploadToBroker(File file) async {
|
||||
try {
|
||||
final thumbBytes = await _buildPhoneThumbnail(file);
|
||||
if (thumbBytes == null) return;
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$brokerUrl/upload-index'),
|
||||
);
|
||||
req.fields['filename'] = p.basename(file.path);
|
||||
req.fields['size_bytes'] = (await file.length()).toString();
|
||||
req.fields['content_type'] = 'image/jpeg';
|
||||
req.files.add(
|
||||
http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
thumbBytes,
|
||||
filename: 'thumb_${p.basename(file.path)}.jpg',
|
||||
),
|
||||
);
|
||||
await req.send();
|
||||
} catch (_) {
|
||||
// Keep upload success semantics: indexing failure should not fail direct upload.
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>?> _buildPhoneThumbnail(File file) async {
|
||||
try {
|
||||
final raw = await file.readAsBytes();
|
||||
final decoded = img.decodeImage(raw);
|
||||
if (decoded == null) return null;
|
||||
final thumb = img.copyResize(
|
||||
decoded,
|
||||
width: decoded.width >= decoded.height ? 400 : null,
|
||||
height: decoded.height > decoded.width ? 400 : null,
|
||||
interpolation: img.Interpolation.average,
|
||||
);
|
||||
return img.encodeJpg(thumb, quality: 80);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadGallery() async {
|
||||
setState(() {
|
||||
_galleryLoading = true;
|
||||
_galleryError = null;
|
||||
});
|
||||
try {
|
||||
await _refreshAgentStatus();
|
||||
final res = await http.get(Uri.parse('$brokerUrl/photos'));
|
||||
if (res.statusCode != 200) {
|
||||
setState(() {
|
||||
_galleryLoading = false;
|
||||
_galleryError = 'Failed to load gallery (${res.statusCode}).';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final body = jsonDecode(res.body);
|
||||
final items = _extractPhotoItems(body);
|
||||
final photos = items
|
||||
.map(_toGalleryPhoto)
|
||||
.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 res = await http.get(Uri.parse('$brokerUrl/'));
|
||||
if (res.statusCode != 200) {
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final body = jsonDecode(res.body);
|
||||
final connected = _extractAgentConnected(body);
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = connected);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
final thumbRaw = item['thumbnail_url'];
|
||||
if (thumbRaw is! String) return null;
|
||||
|
||||
final thumb = _resolveUrl(thumbRaw);
|
||||
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);
|
||||
} 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) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return '';
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri != null && uri.hasScheme && uri.hasAuthority) return trimmed;
|
||||
return Uri.parse(brokerUrl).resolve(trimmed).toString();
|
||||
}
|
||||
|
||||
Future<void> _openFullPhoto(GalleryPhoto photo) async {
|
||||
final token = _directConfig.token.trim();
|
||||
final hints = token.isEmpty ? const <Uri>[] : await _fetchAgentHints();
|
||||
String? directUrl;
|
||||
if (photo.filename != null && hints.isNotEmpty) {
|
||||
final encoded = Uri.encodeComponent(photo.filename!);
|
||||
directUrl = hints.first.resolve('/api/photo/$encoded').toString();
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => FullPhotoPage(
|
||||
brokerImageUrl: photo.brokerFullUrl,
|
||||
directImageUrl: directUrl,
|
||||
directToken: token,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Memory by Clouderized'),
|
||||
backgroundColor: const Color(0xFF0F766E),
|
||||
foregroundColor: Colors.white,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _AgentStatusBadge(connected: _agentConnected),
|
||||
),
|
||||
],
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(Icons.photo_library), text: 'Gallery'),
|
||||
Tab(icon: Icon(Icons.upload), text: 'Uploads'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(children: [_buildGalleryTab(), _buildUploadsTab()]),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _pickPhotos,
|
||||
backgroundColor: const Color(0xFF0F766E),
|
||||
foregroundColor: Colors.white,
|
||||
icon: const Icon(Icons.add_photo_alternate),
|
||||
label: const Text('Pick Photos'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGalleryTab() {
|
||||
if (_galleryLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_galleryError != null) {
|
||||
return Center(child: Text(_galleryError!));
|
||||
}
|
||||
if (_galleryPhotos.isEmpty) {
|
||||
return const Center(child: Text('No photos in gallery yet.'));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadGallery,
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
),
|
||||
itemCount: _galleryPhotos.length,
|
||||
itemBuilder: (_, i) {
|
||||
final photo = _galleryPhotos[i];
|
||||
return GestureDetector(
|
||||
onTap: () => _openFullPhoto(photo),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.network(
|
||||
photo.thumbnailUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const ColoredBox(
|
||||
color: Color(0xFFE5E7EB),
|
||||
child: Center(child: Icon(Icons.broken_image)),
|
||||
),
|
||||
loadingBuilder: (_, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const ColoredBox(
|
||||
color: Color(0xFFF3F4F6),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!_agentConnected)
|
||||
const ColoredBox(color: Color(0x77000000)),
|
||||
if (!_agentConnected)
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.cloud_off, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadsTab() {
|
||||
final hasPhotos = _photos.isNotEmpty;
|
||||
return Column(
|
||||
children: [
|
||||
_buildDirectUploadSettings(),
|
||||
Expanded(
|
||||
child: hasPhotos
|
||||
? GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
),
|
||||
itemCount: _photos.length,
|
||||
itemBuilder: (_, i) => _PhotoTile(item: _photos[i]),
|
||||
)
|
||||
: const Center(child: Text('No photos selected yet.')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectUploadSettings() {
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Direct upload is automatic using broker-provided agent LAN hints. '
|
||||
'If direct fails, upload falls back to broker.',
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _tokenController,
|
||||
decoration: const InputDecoration(labelText: 'Pairing token'),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed: _saveDirectUploadConfig,
|
||||
child: const Text('Save LAN Settings'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FullPhotoPage extends StatefulWidget {
|
||||
final String brokerImageUrl;
|
||||
final String? directImageUrl;
|
||||
final String directToken;
|
||||
|
||||
const FullPhotoPage({
|
||||
super.key,
|
||||
required this.brokerImageUrl,
|
||||
required this.directImageUrl,
|
||||
required this.directToken,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FullPhotoPage> createState() => _FullPhotoPageState();
|
||||
}
|
||||
|
||||
class _FullPhotoPageState extends State<FullPhotoPage> {
|
||||
late String _activeUrl;
|
||||
late Map<String, String>? _activeHeaders;
|
||||
late String _path;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final direct = widget.directImageUrl;
|
||||
if (direct != null && widget.directToken.trim().isNotEmpty) {
|
||||
_activeUrl = direct;
|
||||
_activeHeaders = {'Authorization': 'Bearer ${widget.directToken}'};
|
||||
_path = 'direct';
|
||||
return;
|
||||
}
|
||||
_activeUrl = widget.brokerImageUrl;
|
||||
_activeHeaders = null;
|
||||
_path = 'broker_fallback';
|
||||
}
|
||||
|
||||
void _fallbackToBroker() {
|
||||
if (_path == 'broker_fallback') return;
|
||||
setState(() {
|
||||
_activeUrl = widget.brokerImageUrl;
|
||||
_activeHeaders = null;
|
||||
_path = 'broker_fallback';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text('Photo ($_path)'),
|
||||
),
|
||||
body: InteractiveViewer(
|
||||
minScale: 0.8,
|
||||
maxScale: 4,
|
||||
child: Center(
|
||||
child: Image.network(
|
||||
_activeUrl,
|
||||
headers: _activeHeaders,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) {
|
||||
if (_path == 'direct') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_fallbackToBroker();
|
||||
});
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Failed to load full photo.',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
);
|
||||
},
|
||||
loadingBuilder: (_, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgentStatusBadge extends StatelessWidget {
|
||||
final bool connected;
|
||||
|
||||
const _AgentStatusBadge({required this.connected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = connected ? const Color(0xFF065F46) : const Color(0xFF7F1D1D);
|
||||
final icon = connected ? Icons.cloud_done : Icons.cloud_off;
|
||||
final label = connected ? 'Connected' : 'Disconnected';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoTile extends StatelessWidget {
|
||||
final PhotoItem item;
|
||||
const _PhotoTile({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.file(item.file, fit: BoxFit.cover),
|
||||
Positioned(bottom: 4, right: 4, child: _statusBadge()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusBadge() {
|
||||
return switch (item.status) {
|
||||
UploadStatus.pending => const Icon(
|
||||
Icons.schedule,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
UploadStatus.uploading => const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
),
|
||||
UploadStatus.done => const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.greenAccent,
|
||||
size: 20,
|
||||
),
|
||||
UploadStatus.failed => const Icon(
|
||||
Icons.error,
|
||||
color: Colors.redAccent,
|
||||
size: 20,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user