Complete Phase 1 and Hour 4 gallery/status polish

This commit is contained in:
2026-05-14 17:54:01 +08:00
parent 728a6749a8
commit 6af248861b
14 changed files with 516 additions and 214 deletions

View File

@@ -1,7 +1,10 @@
import 'dart:convert';
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
const brokerUrl = 'https://memory.clouderized.com';
@@ -29,6 +32,13 @@ class PhotoItem {
PhotoItem(this.file, {this.status = UploadStatus.pending});
}
class GalleryPhoto {
final String thumbnailUrl;
final String fullUrl;
const GalleryPhoto({required this.thumbnailUrl, required this.fullUrl});
}
class UploadPage extends StatefulWidget {
const UploadPage({super.key});
@@ -39,6 +49,27 @@ class UploadPage extends StatefulWidget {
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;
@override
void initState() {
super.initState();
_loadGallery();
_statusTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _refreshAgentStatus(),
);
}
@override
void dispose() {
_statusTimer?.cancel();
super.dispose();
}
Future<void> _pickPhotos() async {
final picked = await _picker.pickMultiImage();
@@ -60,43 +91,342 @@ class _UploadPageState extends State<UploadPage> {
'POST',
Uri.parse('$brokerUrl/upload/$filename'),
);
req.files.add(await http.MultipartFile.fromPath('file', item.file.path));
req.files.add(
await http.MultipartFile.fromPath('file', item.file.path),
);
final res = await req.send();
setState(() {
item.status = res.statusCode == 200 ? UploadStatus.done : UploadStatus.failed;
item.status = res.statusCode == 200
? UploadStatus.done
: UploadStatus.failed;
});
} catch (_) {
setState(() => item.status = UploadStatus.failed);
}
}
await _loadGallery();
}
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 explicitFull = item['photo_url'] ?? item['full_url'] ?? item['url'];
String full;
if (explicitFull is String && explicitFull.trim().isNotEmpty) {
full = _resolveUrl(explicitFull);
} else if (item['filename'] is String &&
(item['filename'] as String).isNotEmpty) {
final encoded = Uri.encodeComponent(item['filename'] as String);
full = '$brokerUrl/photo/$encoded';
} else if (item['id'] != null) {
full = '$brokerUrl/photo/${item['id']}';
} else {
full = thumb;
}
return GalleryPhoto(thumbnailUrl: thumb, fullUrl: full);
}
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 messenger = ScaffoldMessenger.of(context);
await _refreshAgentStatus();
if (!mounted || !_agentConnected) {
messenger.showSnackBar(
const SnackBar(
content: Text('Agent is disconnected. Full photo is unavailable.'),
),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => FullPhotoPage(imageUrl: photo.fullUrl),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Memory by Clouderized'),
backgroundColor: const Color(0xFF0F766E),
foregroundColor: Colors.white,
),
body: _photos.isEmpty
? const Center(child: Text('No photos selected yet.'))
: 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]),
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),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _pickPhotos,
backgroundColor: const Color(0xFF0F766E),
],
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() {
if (_photos.isEmpty) {
return const Center(child: Text('No photos selected yet.'));
}
return 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]),
);
}
}
class FullPhotoPage extends StatelessWidget {
final String imageUrl;
const FullPhotoPage({super.key, required this.imageUrl});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
icon: const Icon(Icons.add_photo_alternate),
label: const Text('Pick Photos'),
title: const Text('Photo'),
),
body: InteractiveViewer(
minScale: 0.8,
maxScale: 4,
child: Center(
child: Image.network(
imageUrl,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => 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,
),
),
],
),
);
}
@@ -112,25 +442,33 @@ class _PhotoTile extends StatelessWidget {
fit: StackFit.expand,
children: [
Image.file(item.file, fit: BoxFit.cover),
Positioned(
bottom: 4,
right: 4,
child: _statusBadge(),
),
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.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),
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,
),
};
}
}