Complete Phase 1 and Hour 4 gallery/status polish
This commit is contained in:
165
ALTERNATIVE.md
165
ALTERNATIVE.md
@@ -1,165 +0,0 @@
|
|||||||
# Alternative Architecture: Direct Node Data Plane
|
|
||||||
|
|
||||||
This document preserves the original Clouderized Memories architecture principle:
|
|
||||||
|
|
||||||
> The cloud should coordinate the experience, but customer media should not routinely flow through Clouderized application servers.
|
|
||||||
|
|
||||||
The current PoC uses a broker-mediated relay because it is simpler to prove end to end. This alternative remains important because it is the stronger long-term privacy and bandwidth architecture.
|
|
||||||
|
|
||||||
## Core Principle
|
|
||||||
|
|
||||||
Separate the control plane from the data plane.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Control plane:
|
|
||||||
client <-> Clouderized cloud
|
|
||||||
|
|
||||||
Data plane:
|
|
||||||
client <-> customer storage node
|
|
||||||
```
|
|
||||||
|
|
||||||
Clouderized cloud should handle identity, metadata, authorization, device registration, and node discovery. It should not be the normal path for original photo/video bytes.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Control Plane
|
|
||||||
|
|
||||||
Cloud service responsible for:
|
|
||||||
|
|
||||||
- user accounts and sessions
|
|
||||||
- device registration
|
|
||||||
- storage-node enrollment
|
|
||||||
- asset manifests
|
|
||||||
- backup state
|
|
||||||
- signed upload and fetch authorization
|
|
||||||
- node online/offline status
|
|
||||||
- billing and entitlement state
|
|
||||||
|
|
||||||
### Storage Agent
|
|
||||||
|
|
||||||
Lightweight service running on customer-owned hardware.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
- maintain a secure outbound control connection
|
|
||||||
- expose a stable reachable media endpoint
|
|
||||||
- receive authorized uploads
|
|
||||||
- store encrypted media blobs on local storage
|
|
||||||
- serve authorized encrypted blobs to clients
|
|
||||||
- report capacity, health, and transfer outcomes
|
|
||||||
- maintain local retry/checkpoint state
|
|
||||||
|
|
||||||
### Clients
|
|
||||||
|
|
||||||
Mobile and web clients are responsible for:
|
|
||||||
|
|
||||||
- authenticating with the control plane
|
|
||||||
- requesting upload/download grants
|
|
||||||
- uploading media directly to the storage node when possible
|
|
||||||
- fetching thumbnails/media directly from the storage node when possible
|
|
||||||
- decrypting and rendering media locally
|
|
||||||
|
|
||||||
## Preferred Data Path
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client <-> Customer Storage Agent
|
|
||||||
```
|
|
||||||
|
|
||||||
The cloud authorizes the operation, but the actual media transfer happens directly between the client and the node.
|
|
||||||
|
|
||||||
## Fallback Data Path
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client <-> Encrypted Relay <-> Customer Storage Agent
|
|
||||||
```
|
|
||||||
|
|
||||||
Relay fallback may be necessary for difficult networks, but the relay should only forward encrypted bytes. It should not process or persist original media.
|
|
||||||
|
|
||||||
## Addressing Model
|
|
||||||
|
|
||||||
Every storage node needs a stable logical address, independent of the customer's residential IP.
|
|
||||||
|
|
||||||
Candidate model:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<node-id>.edge.clouderized.com
|
|
||||||
```
|
|
||||||
|
|
||||||
The hostname identifies the node. It should not imply a fixed public IP or manual router configuration.
|
|
||||||
|
|
||||||
Possible approaches:
|
|
||||||
|
|
||||||
- managed reverse tunnel fabric
|
|
||||||
- direct peer connectivity with cloud signaling
|
|
||||||
- hybrid transport with direct path first and relay fallback
|
|
||||||
- existing tunnel substrate for early prototypes
|
|
||||||
|
|
||||||
Cloudflare-style HTTP tunnels are not sufficient as the only bulk media path because request-size limits can break large photo/video uploads.
|
|
||||||
|
|
||||||
## Upload Flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
1. Client asks the control plane for an upload session.
|
|
||||||
2. Control plane verifies user, entitlement, node status, and policy.
|
|
||||||
3. Control plane returns:
|
|
||||||
- target node identity
|
|
||||||
- short-lived upload token
|
|
||||||
- expected metadata envelope
|
|
||||||
4. Client streams encrypted payload directly to the storage node.
|
|
||||||
5. Node validates the token and writes the blob locally.
|
|
||||||
6. Node returns checksum/status.
|
|
||||||
7. Control plane marks the asset committed.
|
|
||||||
```
|
|
||||||
|
|
||||||
## View Flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
1. Client asks control plane for timeline metadata.
|
|
||||||
2. Control plane returns asset summaries and short-lived fetch grants.
|
|
||||||
3. Client fetches thumbnail/media bytes from the storage node.
|
|
||||||
4. Client decrypts and renders locally.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Thumbnail Strategy
|
|
||||||
|
|
||||||
Preferred early strategy:
|
|
||||||
|
|
||||||
- mobile client generates thumbnails during upload
|
|
||||||
- storage node stores both original and thumbnail blobs
|
|
||||||
- browser fetches thumbnails from the node and decrypts them locally
|
|
||||||
|
|
||||||
Later options:
|
|
||||||
|
|
||||||
- storage agent generates thumbnails for imports
|
|
||||||
- encrypted relay/cache for derived thumbnails if economics justify it
|
|
||||||
|
|
||||||
## Why This Matters
|
|
||||||
|
|
||||||
Compared with the current broker-relay PoC, this alternative has stronger:
|
|
||||||
|
|
||||||
- privacy story
|
|
||||||
- bandwidth economics
|
|
||||||
- long-term scalability
|
|
||||||
- differentiation from normal cloud photo services
|
|
||||||
|
|
||||||
The tradeoff is higher engineering complexity around:
|
|
||||||
|
|
||||||
- node reachability
|
|
||||||
- NAT traversal
|
|
||||||
- resumable transfers
|
|
||||||
- offline behavior
|
|
||||||
- large video transfer reliability
|
|
||||||
- cross-device key management
|
|
||||||
|
|
||||||
## Relationship To Current PoC
|
|
||||||
|
|
||||||
The current PoC is still valuable. It proves:
|
|
||||||
|
|
||||||
- outbound node connection
|
|
||||||
- broker-controlled routing
|
|
||||||
- upload into customer storage
|
|
||||||
- reverse fetch from customer storage
|
|
||||||
- Android-to-node workflow through a managed service
|
|
||||||
|
|
||||||
This alternative should guide the production architecture once the PoC graduates beyond proving the basic product loop.
|
|
||||||
|
|
||||||
61
PLAN.md
61
PLAN.md
@@ -10,7 +10,7 @@ that works end-to-end, not a half-finished layer.
|
|||||||
| Phase | Description | Status |
|
| Phase | Description | Status |
|
||||||
|-------|-------------|--------|
|
|-------|-------------|--------|
|
||||||
| 0 | POC — tunnel, upload relay, reverse fetch | ✅ Done |
|
| 0 | POC — tunnel, upload relay, reverse fetch | ✅ Done |
|
||||||
| 1 | Android app — pick and upload photos | 🔄 In progress |
|
| 1 | Android app — pick and upload photos | ✅ Done |
|
||||||
| 2 | Agent — production-ready (reconnect, dedup) | ⬜ Pending |
|
| 2 | Agent — production-ready (reconnect, dedup) | ⬜ Pending |
|
||||||
| 3 | Broker — thumbnails + SQLite metadata | ⬜ Pending |
|
| 3 | Broker — thumbnails + SQLite metadata | ⬜ Pending |
|
||||||
| 4 | Web gallery — browse and view photos | ⬜ Pending |
|
| 4 | Web gallery — browse and view photos | ⬜ Pending |
|
||||||
@@ -39,7 +39,7 @@ that works end-to-end, not a half-finished layer.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1 — Android Upload 🔄
|
## Phase 1 — Android Upload ✅
|
||||||
|
|
||||||
**Goal:** Real phone → broker → agent disk, on a real device.
|
**Goal:** Real phone → broker → agent disk, on a real device.
|
||||||
|
|
||||||
@@ -53,24 +53,29 @@ that works end-to-end, not a half-finished layer.
|
|||||||
- Android permissions set (READ_MEDIA_IMAGES, INTERNET)
|
- Android permissions set (READ_MEDIA_IMAGES, INTERNET)
|
||||||
- Debug APK built and tested — file uploads confirmed working
|
- Debug APK built and tested — file uploads confirmed working
|
||||||
|
|
||||||
**Remaining:**
|
**Completed:**
|
||||||
- [ ] Rename app display name from `memory_by_clouderized` → `MemoriC`
|
- ✅ Rename app display name from `memory_by_clouderized` → `MemoriC`
|
||||||
- [ ] Test on real device end-to-end (phone → broker → agent disk verified ✅)
|
- ✅ Test on real device end-to-end (phone → broker → agent disk verified)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — Production Agent
|
## Phase 2 — Production Agent
|
||||||
|
|
||||||
**Goal:** Agent that survives in the real world.
|
**Goal:** Agent that survives in the real world with foundation for future direct client-node transfers.
|
||||||
|
|
||||||
**Deliverable:** Go binary that reconnects automatically, deduplicates uploads,
|
**Deliverable:** Go binary that reconnects automatically, deduplicates uploads,
|
||||||
and organises photos by date on disk.
|
organises photos by date on disk, and maintains internal database for robustness.
|
||||||
|
|
||||||
**Tasks:**
|
**Tasks:**
|
||||||
|
- [x] Linux user service (`memoric-agent`) via `systemctl --user` with `Restart=always` and storage path set to `~/memoric/photos`
|
||||||
- [ ] Auto-reconnect with exponential backoff on disconnect
|
- [ ] Auto-reconnect with exponential backoff on disconnect
|
||||||
- [ ] SHA-256 dedup — skip files already stored, return 200 without relaying
|
- [ ] SHA-256 dedup — skip files already stored, return 200 without relaying
|
||||||
- [ ] Organised storage: `photos/YYYY/MM/filename` instead of flat directory
|
- [ ] Organised storage: `photos/YYYY/MM/filename` instead of flat directory
|
||||||
- [ ] Disk-full detection — warn broker when < 10GB free
|
- [ ] Disk-full detection — warn broker when < 10GB free
|
||||||
|
- [ ] Internal SQLite database to track stored files (path-independent)
|
||||||
|
- [ ] Content-addressable storage design (files stored by hash)
|
||||||
|
- [ ] Encryption at rest for stored photos
|
||||||
|
- [ ] Health reporting and capacity monitoring
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -158,3 +163,45 @@ new photos on WiFi, with a persistent notification showing sync status.
|
|||||||
- [ ] Agent pairing UX: QR code scan or manual token entry?
|
- [ ] Agent pairing UX: QR code scan or manual token entry?
|
||||||
- [ ] What happens if home agent disk is full — reject upload or warn?
|
- [ ] What happens if home agent disk is full — reject upload or warn?
|
||||||
- [ ] Pricing finalised: Free / Solo S$4 / Family S$8?
|
- [ ] Pricing finalised: Free / Solo S$4 / Family S$8?
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- [ ] Evaluate direct client-node data plane for improved privacy/bandwidth
|
||||||
|
- Hybrid approach: direct transfer when possible, encrypted relay fallback
|
||||||
|
- Requires solving NAT traversal, connection reliability, and data integrity
|
||||||
|
- Build on Phase 2 agent improvements (internal database, encryption, health reporting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Tracking (Hour Blocks)
|
||||||
|
|
||||||
|
This section consolidates the prior `DEVELOPMENT_PLAN.md` progress notes.
|
||||||
|
|
||||||
|
### Hour 1: App Display Name Update ✅ COMPLETED
|
||||||
|
- ✅ Updated app name in `pubspec.yaml`: `name: memoric`
|
||||||
|
- ✅ Updated iOS display name in `ios/Runner/Info.plist`: `CFBundleDisplayName = MemoriC`
|
||||||
|
- ✅ Updated iOS bundle name in `ios/Runner/Info.plist`: `CFBundleName = MemoriC`
|
||||||
|
- ✅ Created Android app name in `android/app/src/main/res/values/strings.xml`
|
||||||
|
- ✅ Verified debug APK build
|
||||||
|
|
||||||
|
### Hour 2: Real Device Testing Preparation
|
||||||
|
- Prepare device debugging + APK install workflow
|
||||||
|
|
||||||
|
### Hour 3: End-to-End Test Execution ✅ COMPLETED
|
||||||
|
- ✅ Agent connection and broker accessibility verified
|
||||||
|
- ✅ Real upload confirmed from app (`1000199397.jpg`)
|
||||||
|
- ✅ Reverse fetch confirmed (`/photo/1000199397.jpg`)
|
||||||
|
- ✅ Thumbnail serving confirmed (`/photo/thumb_1000199397`)
|
||||||
|
- ✅ Persistence path aligned to `/app/data/thumbnails` on broker
|
||||||
|
|
||||||
|
### Hour 4: Issue Resolution & Polish ✅ COMPLETED
|
||||||
|
- ✅ Added persistent photo index API (`GET /photos`)
|
||||||
|
- ✅ Added SQLite metadata persistence at `/app/data/memory.db`
|
||||||
|
- ✅ Switched new thumbnails to WebP (with legacy compatibility)
|
||||||
|
- ✅ Added Linux user service (`systemctl --user`) for agent
|
||||||
|
- ✅ Configured agent storage path to `~/memoric/photos`
|
||||||
|
- ✅ Wire Flutter app gallery UI to load from `GET /photos`
|
||||||
|
- ✅ Full-screen gallery tab with thumbnail grid and tap-to-open full photo
|
||||||
|
- ✅ Agent connected/disconnected badge with live broker health checks
|
||||||
|
- ✅ Final validation pass and close Hour 4
|
||||||
|
- ⏭️ Deferred: upload retry/error UX polish (not needed for now)
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -18,3 +18,32 @@ Built on the Clouderized platform (dogfooding).
|
|||||||
| [SPEC.md](SPEC.md) | Full product spec — architecture, data flow, tiers |
|
| [SPEC.md](SPEC.md) | Full product spec — architecture, data flow, tiers |
|
||||||
| [PLAN.md](PLAN.md) | Build plan — phases and milestones |
|
| [PLAN.md](PLAN.md) | Build plan — phases and milestones |
|
||||||
| [poc/](poc/) | Proof-of-concept code (tunnel broker, Go agent, Flutter app) |
|
| [poc/](poc/) | Proof-of-concept code (tunnel broker, Go agent, Flutter app) |
|
||||||
|
|
||||||
|
## Run Agent As User Service (Linux)
|
||||||
|
|
||||||
|
From repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd poc/agent
|
||||||
|
./install-user-service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then manage with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user start memoric-agent
|
||||||
|
systemctl --user stop memoric-agent
|
||||||
|
systemctl --user status memoric-agent
|
||||||
|
journalctl --user -u memoric-agent -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Default photo storage path:
|
||||||
|
- `~/memoric/photos`
|
||||||
|
|
||||||
|
To use a different path, edit:
|
||||||
|
- `~/.config/systemd/user/memoric-agent.service`
|
||||||
|
and change `Environment=STORAGE_DIR=...`, then run:
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user restart memoric-agent
|
||||||
|
```
|
||||||
|
|||||||
2
poc/agent/agent.log
Normal file
2
poc/agent/agent.log
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
2026/05/14 15:57:34 connected to broker at wss://memory.clouderized.com/agent
|
||||||
|
2026/05/14 15:57:48 served 1000208662.jpg (2713195 bytes)
|
||||||
27
poc/agent/install-user-service.sh
Executable file
27
poc/agent/install-user-service.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_DIR="${HOME}/.local/share/memoric-agent"
|
||||||
|
SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
|
||||||
|
|
||||||
|
mkdir -p "${APP_DIR}"
|
||||||
|
mkdir -p "${SYSTEMD_USER_DIR}"
|
||||||
|
|
||||||
|
echo "[1/4] Building agent binary..."
|
||||||
|
cd "${SCRIPT_DIR}"
|
||||||
|
go build -o "${APP_DIR}/memoric-agent" .
|
||||||
|
|
||||||
|
echo "[2/4] Installing systemd unit..."
|
||||||
|
cp "${SCRIPT_DIR}/systemd/memoric-agent.service" "${SYSTEMD_USER_DIR}/memoric-agent.service"
|
||||||
|
|
||||||
|
echo "[3/4] Reloading user daemon..."
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
echo "[4/4] Enabling and starting memoric-agent..."
|
||||||
|
systemctl --user enable --now memoric-agent
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "MemoriC agent service installed."
|
||||||
|
echo "Check status with: systemctl --user status memoric-agent"
|
||||||
|
echo "View logs with: journalctl --user -u memoric-agent -f"
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const brokerURL = "wss://memory.clouderized.com/agent"
|
const brokerURL = "wss://memory.clouderized.com/agent"
|
||||||
const storageDir = "./photos"
|
const defaultStorageDir = "./photos"
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -22,9 +22,15 @@ type Message struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
storageDir := os.Getenv("STORAGE_DIR")
|
||||||
|
if storageDir == "" {
|
||||||
|
storageDir = defaultStorageDir
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
log.Println("photo storage directory:", storageDir)
|
||||||
|
|
||||||
conn, _, err := websocket.DefaultDialer.Dial(brokerURL, nil)
|
conn, _, err := websocket.DefaultDialer.Dial(brokerURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
BIN
poc/agent/photos/1000199397.jpg
Normal file
BIN
poc/agent/photos/1000199397.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
BIN
poc/agent/photos/1000208662.jpg
Normal file
BIN
poc/agent/photos/1000208662.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
15
poc/agent/systemd/memoric-agent.service
Normal file
15
poc/agent/systemd/memoric-agent.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=MemoriC Agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/.local/share/memoric-agent
|
||||||
|
Environment=STORAGE_DIR=%h/memoric/photos
|
||||||
|
ExecStart=%h/.local/share/memoric-agent/memoric-agent
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
3
poc/android/android/app/src/main/res/values/strings.xml
Normal file
3
poc/android/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">MemoriC</string>
|
||||||
|
</resources>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Memory By Clouderized</string>
|
<string>MemoriC</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>memory_by_clouderized</string>
|
<string>MemoriC</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
const brokerUrl = 'https://memory.clouderized.com';
|
const brokerUrl = 'https://memory.clouderized.com';
|
||||||
@@ -29,6 +32,13 @@ class PhotoItem {
|
|||||||
PhotoItem(this.file, {this.status = UploadStatus.pending});
|
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 {
|
class UploadPage extends StatefulWidget {
|
||||||
const UploadPage({super.key});
|
const UploadPage({super.key});
|
||||||
|
|
||||||
@@ -39,6 +49,27 @@ class UploadPage extends StatefulWidget {
|
|||||||
class _UploadPageState extends State<UploadPage> {
|
class _UploadPageState extends State<UploadPage> {
|
||||||
final _picker = ImagePicker();
|
final _picker = ImagePicker();
|
||||||
final List<PhotoItem> _photos = [];
|
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 {
|
Future<void> _pickPhotos() async {
|
||||||
final picked = await _picker.pickMultiImage();
|
final picked = await _picker.pickMultiImage();
|
||||||
@@ -60,43 +91,342 @@ class _UploadPageState extends State<UploadPage> {
|
|||||||
'POST',
|
'POST',
|
||||||
Uri.parse('$brokerUrl/upload/$filename'),
|
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();
|
final res = await req.send();
|
||||||
setState(() {
|
setState(() {
|
||||||
item.status = res.statusCode == 200 ? UploadStatus.done : UploadStatus.failed;
|
item.status = res.statusCode == 200
|
||||||
|
? UploadStatus.done
|
||||||
|
: UploadStatus.failed;
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setState(() => item.status = UploadStatus.failed);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return DefaultTabController(
|
||||||
appBar: AppBar(
|
length: 2,
|
||||||
title: const Text('Memory by Clouderized'),
|
child: Scaffold(
|
||||||
backgroundColor: const Color(0xFF0F766E),
|
appBar: AppBar(
|
||||||
foregroundColor: Colors.white,
|
title: const Text('Memory by Clouderized'),
|
||||||
),
|
backgroundColor: const Color(0xFF0F766E),
|
||||||
body: _photos.isEmpty
|
foregroundColor: Colors.white,
|
||||||
? const Center(child: Text('No photos selected yet.'))
|
actions: [
|
||||||
: GridView.builder(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
child: _AgentStatusBadge(connected: _agentConnected),
|
||||||
crossAxisCount: 3,
|
|
||||||
crossAxisSpacing: 4,
|
|
||||||
mainAxisSpacing: 4,
|
|
||||||
),
|
|
||||||
itemCount: _photos.length,
|
|
||||||
itemBuilder: (_, i) => _PhotoTile(item: _photos[i]),
|
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
],
|
||||||
onPressed: _pickPhotos,
|
bottom: const TabBar(
|
||||||
backgroundColor: const Color(0xFF0F766E),
|
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,
|
foregroundColor: Colors.white,
|
||||||
icon: const Icon(Icons.add_photo_alternate),
|
title: const Text('Photo'),
|
||||||
label: const Text('Pick Photos'),
|
),
|
||||||
|
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,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Image.file(item.file, fit: BoxFit.cover),
|
Image.file(item.file, fit: BoxFit.cover),
|
||||||
Positioned(
|
Positioned(bottom: 4, right: 4, child: _statusBadge()),
|
||||||
bottom: 4,
|
|
||||||
right: 4,
|
|
||||||
child: _statusBadge(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _statusBadge() {
|
Widget _statusBadge() {
|
||||||
return switch (item.status) {
|
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(
|
UploadStatus.uploading => const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
),
|
),
|
||||||
UploadStatus.done => const Icon(Icons.check_circle, color: Colors.greenAccent, size: 20),
|
UploadStatus.done => const Icon(
|
||||||
UploadStatus.failed => const Icon(Icons.error, color: Colors.redAccent, size: 20),
|
Icons.check_circle,
|
||||||
|
color: Colors.greenAccent,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
UploadStatus.failed => const Icon(
|
||||||
|
Icons.error,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: memory_by_clouderized
|
name: memoric
|
||||||
description: "A new Flutter project."
|
description: "A new Flutter project."
|
||||||
# The following line prevents the package from being accidentally published to
|
# The following line prevents the package from being accidentally published to
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
|
|||||||
Submodule poc/memory updated: 7b5550e551...4a30916321
Reference in New Issue
Block a user