Complete Phase 4b direct fullscreen fetch and ship app/agent updates

This commit is contained in:
2026-05-18 14:31:20 +08:00
parent 46772a1448
commit 71872210d2
158 changed files with 1155 additions and 167 deletions

744
memoric-app/lib/main.dart Normal file
View 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,
),
};
}
}