Gallery: show all NAS files; Settings: fix WiFi label + cellular row
Gallery NAS was only showing Kisani-manifest-tracked files (2 of 7432). Now fetches the full NAS directory listing on load and adds Phase 3 items for files not in the manifest (uploaded by other tools) — all 7432 files appear in the gallery with thumbnails loaded lazily on scroll. Settings network section: "No Wi-Fi" → "Wi-Fi" when connected but SSID is nil (no location permission). Added cellular status row below Wi-Fi row. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -75,19 +75,24 @@ final class GalleryViewModel: ObservableObject {
|
||||
} else if let cached = await NASManifestCache.shared.manifest, !cached.entries.isEmpty {
|
||||
manifestEntries = cached.entries
|
||||
} else if let conn = connection {
|
||||
// Both caches are cold — download the manifest directly so the gallery
|
||||
// isn't blank while BackupStatusService reconcile is still pending.
|
||||
manifestEntries = await Self.fetchManifestDirect(conn: conn)
|
||||
} else {
|
||||
manifestEntries = []
|
||||
}
|
||||
|
||||
// Fetch the full NAS directory so all files (not just Kisani-tracked ones) appear
|
||||
// in the gallery. This is a single listing call, not per-file.
|
||||
var nasDirectory: [NASItem] = []
|
||||
if let conn = connection {
|
||||
nasDirectory = await Self.fetchNASDirectory(conn: conn)
|
||||
}
|
||||
|
||||
let phoneAssets = await Task.detached(priority: .userInitiated) {
|
||||
Self.fetchPhoneAssets()
|
||||
}.value
|
||||
|
||||
let items = await Task.detached(priority: .userInitiated) {
|
||||
Self.buildItems(phone: phoneAssets, manifest: manifestEntries)
|
||||
let items = await Task.detached(priority: .userInitiated) { [nasDirectory] in
|
||||
Self.buildItems(phone: phoneAssets, manifest: manifestEntries, nasDirectory: nasDirectory)
|
||||
}.value
|
||||
|
||||
allItems = items
|
||||
@@ -95,6 +100,23 @@ final class GalleryViewModel: ObservableObject {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
/// Lists all media files in the NAS backup directory.
|
||||
/// Returns an empty array (silently) if the NAS is unreachable.
|
||||
private nonisolated static func fetchNASDirectory(conn: NASConnection) async -> [NASItem] {
|
||||
do {
|
||||
let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
try await transfer.connect(to: conn.host, port: conn.port,
|
||||
username: conn.username, password: conn.password)
|
||||
let items = try await transfer.listDirectory(at: conn.remotePath)
|
||||
transfer.disconnect()
|
||||
return items.filter {
|
||||
!$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads the NAS manifest directly when local caches are empty.
|
||||
/// Caches the result so subsequent opens are instant.
|
||||
private nonisolated static func fetchManifestDirect(conn: NASConnection) async -> [ManifestEntry] {
|
||||
@@ -148,20 +170,24 @@ final class GalleryViewModel: ObservableObject {
|
||||
|
||||
private nonisolated static func buildItems(
|
||||
phone: [(id: String, filename: String, date: Date?, duration: TimeInterval?, isVideo: Bool)],
|
||||
manifest: [ManifestEntry]
|
||||
manifest: [ManifestEntry],
|
||||
nasDirectory: [NASItem] = []
|
||||
) -> [GalleryItem] {
|
||||
var byID = [String: ManifestEntry](minimumCapacity: manifest.count)
|
||||
var byName = [String: ManifestEntry](minimumCapacity: manifest.count)
|
||||
var manifestFilenames = Set<String>(minimumCapacity: manifest.count)
|
||||
for entry in manifest {
|
||||
if !entry.localIdentifier.isEmpty { byID[entry.localIdentifier] = entry }
|
||||
let key = entry.filename.lowercased()
|
||||
if byName[key] == nil { byName[key] = entry }
|
||||
manifestFilenames.insert(key)
|
||||
}
|
||||
|
||||
var result: [GalleryItem] = []
|
||||
result.reserveCapacity(phone.count + manifest.count / 4)
|
||||
result.reserveCapacity(phone.count + manifest.count + nasDirectory.count / 4)
|
||||
var coveredFilenames = Set<String>()
|
||||
|
||||
// Phase 1: Phone assets — matched to manifest = synced, otherwise phone-only
|
||||
for (localID, filename, date, duration, isVideo) in phone {
|
||||
let key = filename.lowercased()
|
||||
if let entry = byID[localID] ?? byName[key] {
|
||||
@@ -188,7 +214,7 @@ final class GalleryViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// NAS-only: in manifest but not matched to a phone asset
|
||||
// Phase 2: Manifest-only NAS items (Kisani-tracked, no longer on this device)
|
||||
for entry in manifest {
|
||||
guard !coveredFilenames.contains(entry.filename.lowercased()) else { continue }
|
||||
let ext = (entry.filename as NSString).pathExtension.lowercased()
|
||||
@@ -205,6 +231,25 @@ final class GalleryViewModel: ObservableObject {
|
||||
))
|
||||
}
|
||||
|
||||
// Phase 3: NAS directory items not tracked by the manifest (uploaded by other tools)
|
||||
for item in nasDirectory {
|
||||
let lower = item.name.lowercased()
|
||||
guard !manifestFilenames.contains(lower) else { continue }
|
||||
let ext = (item.name as NSString).pathExtension.lowercased()
|
||||
let mediaType: GalleryItem.MediaType =
|
||||
videoExts.contains(ext) ? .video : imageExts.contains(ext) ? .photo : .unknown
|
||||
guard mediaType != .unknown else { continue }
|
||||
result.append(GalleryItem(
|
||||
id: "nas_dir_\(item.path)",
|
||||
filename: item.name,
|
||||
creationDate: item.modifiedDate,
|
||||
duration: nil,
|
||||
fileSize: item.size > 0 ? item.size : nil,
|
||||
mediaType: mediaType,
|
||||
source: .nas(path: item.path)
|
||||
))
|
||||
}
|
||||
|
||||
result.sort { ($0.creationDate ?? .distantPast) > ($1.creationDate ?? .distantPast) }
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(lanMonitor.currentSSID ?? (lanMonitor.isOnCellular ? "Cellular" : "No Wi-Fi"))
|
||||
Text(lanMonitor.currentSSID ?? (lanMonitor.isOnCellular ? "Cellular" : (lanMonitor.isOnNetwork ? "Wi-Fi" : "No Wi-Fi")))
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(lanMonitor.isOnNetwork ? "Connected" : "Not connected")
|
||||
@@ -238,6 +238,35 @@ struct SettingsView: View {
|
||||
|
||||
hairline
|
||||
|
||||
// Cellular row
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(lanMonitor.isOnCellular ? Color(red: 1.0, green: 0.58, blue: 0.0) : AppTheme.inkSecondary)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Cellular")
|
||||
.font(AppTheme.body())
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Text(lanMonitor.isOnCellular ? "Active" : "Not in use")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(lanMonitor.isOnCellular ? Color(red: 1.0, green: 0.58, blue: 0.0) : AppTheme.inkQuaternary)
|
||||
.frame(width: 5, height: 5)
|
||||
Text(lanMonitor.isOnCellular ? "Active" : "Off")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(lanMonitor.isOnCellular ? Color(red: 1.0, green: 0.58, blue: 0.0) : AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppTheme.cardPad)
|
||||
.padding(.vertical, 13)
|
||||
|
||||
hairline
|
||||
|
||||
// NAS reachability row
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "externaldrive.connected.to.line.below")
|
||||
|
||||
Reference in New Issue
Block a user