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:
Robin Kutesa
2026-05-19 00:47:28 +03:00
parent 6ae567d008
commit fae13eba9f
2 changed files with 82 additions and 8 deletions

View File

@@ -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
}

View File

@@ -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")