24 Commits
Author SHA1 Message Date
MarcoE c57df2cbb7 Finish v1.2.5(11) 2026-11-04 15:53:55 +01:00
MarcoE e5cf4215f6 -> v1.2.5 (11) 2026-11-04 15:53:51 +01:00
MarcoEandClaude Opus 5.5 c4c624667d Permette di eliminare le foto di una scheda anche dopo il salvataggio
Finora la foto si poteva togliere solo prima di salvare la scheda.

- La X compare su tutte le foto (non in sola lettura, non mentre la foto e'
  ancora in elaborazione). L'eliminazione avviene al salvataggio: uscendo
  senza salvare le foto restano.
- Una foto gia' inviata si toglie prima dal server (nuovo endpoint
  steup/removeAttachment), poi dal dispositivo e da Scheda.ImageNames. Senza
  rete la X avvisa e non la toglie; se il server non conferma al salvataggio
  la foto resta ovunque e compare un avviso.
- Una foto salvata ma non ancora inviata si elimina dal dispositivo (con la
  rete si prova anche a toglierla dal server, senza bloccare).
- Togliere una foto salvata non la cancellava mai: il flag ToRemove finiva
  su un elemento appena rimosso dall'elenco. Ora le foto tolte stanno in una
  lista a parte fino al salvataggio; ToRemove e' stato eliminato.
- FileManager.GetInspectionFiles: le foto in /toUpload si filtravano per
  nome, ma ImageNames contiene il percorso completo, quindi quelle salvate
  senza rete non ricomparivano mai riaprendo la scheda. Ora si accetta sia il
  percorso sia il nome, e il DTO porta anche i byte del file per l'invio.

Richiede i Servizi con steup/removeAttachment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:39:31 +01:00
MarcoE df827216c3 Finish v1.2.4(10) 2026-10-29 15:48:01 +01:00
MarcoE b2db0f2479 Finish v1.2.4(10) 2026-10-29 15:48:01 +01:00
MarcoE 817cd606b6 -> 1.2.4 (10) 2026-10-29 15:48:01 +01:00
MarcoEandClaude Opus 5.5 495f335b72 Evita articoli persi o doppi con griglia e scanner insieme
Nella scheda, scegliendo alcuni articoli dalla griglia e sparandone altri,
non tutti i barcode arrivavano al salvataggio.

- La selezione dalla griglia modificava Scheda.Articoli da un thread del
  pool, mentre le scansioni lo fanno sul thread della UI: le due modifiche
  potevano sovrapporsi e far perdere articoli. Ora passa da InvokeAsync.
- La griglia non scartava i barcode gia' sparati, e lo scanner controllava
  solo il codice letto, non quello restituito dal server (che puo' essere
  il barcode principale, lo stesso della griglia). Un doppione rompe il
  @key della lista e il salvataggio locale (indice unico SchedaId+Barcode).
  Ora entrambi i percorsi aggiungono solo barcode non ancora presenti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:40:20 +01:00
MarcoEandClaude Opus 5.5 ed7c28feac Aggiorna IntegryApiClient alla 2.6.0
Con la 2.0.1 il login poteva fallire con WrongAccessTokenJsonException:
il token del server non contiene piu' la proprieta' 'web' che la vecchia
versione considerava obbligatoria.

- IntegryApiClient.Core, .MAUI e .Blazor: 2.0.1 -> 2.6.0.
- La 2.6.0 richiede dipendenze piu' recenti, che il restore segnalava come
  downgrade (NU1605): System.IdentityModel.Tokens.Jwt 8.17.0 -> 8.18.0,
  Microsoft.Extensions.Logging e .Logging.Debug 10.0.7 -> 10.0.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:40:20 +01:00
MarcoE 42e2403362 Finish v1.2.3(9) 2026-10-21 08:51:50 +02:00
MarcoE f59c8d3aaf Finish v1.2.3(9) 2026-10-21 08:51:50 +02:00
MarcoE 249b301920 -> 1.2.3 (9) 2026-10-21 08:51:43 +02:00
MarcoEandClaude Opus 5.5 885b3cc6b6 Avvisa quando manca il permesso della fotocamera
Con il permesso negato "Aggiungi una foto" non faceva nulla: il servizio
restituiva null come per un annullamento. Dopo un rifiuto definitivo
Android non ripropone la richiesta, quindi il rilevatore restava bloccato
senza capire perche'.

- AttachedService: la richiesta dei permessi diventa RequestCameraPermissions,
  usata anche da SelectImageFromCamera; OpenAppSettings apre le impostazioni
  dell'app.
- ModalFormScheda e ModalAddAttached: chiedono i permessi prima di scattare
  e, se negati, mostrano un avviso con l'azione "Impostazioni".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 08:49:34 +02:00
MarcoEandClaude Opus 5.5 7e1492d118 Avvisa quando la fotocamera non si apre e passa alla galleria
Se lo scatto falliva, AttachedService restituiva null come per un
annullamento: il rilevatore toccava "Aggiungi una foto" e non succedeva
nulla.

- AttachedService: espone IsCameraAvailable (IsCaptureSupported) e, dopo
  aver registrato l'errore e averlo inviato a Sentry, lo rilancia invece di
  restituire null.
- ModalFormScheda: dalla scheda il pulsante apre solo la fotocamera, quindi
  senza fotocamera non c'era alternativa. Ora avvisa e apre la galleria;
  se lo scatto fallisce mostra un errore.
- ModalAddAttached: stessa gestione dell'errore di scatto.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 08:45:57 +02:00
MarcoEandClaude Opus 5.5 fd0c218ab5 Dichiara la fotocamera di sistema nelle queries del manifest
Da Android 11 un'app vede le altre app installate solo se le dichiara in
<queries>. Senza l'intent IMAGE_CAPTURE, MediaPicker.CapturePhotoAsync non
trovava la fotocamera di sistema e falliva con "Either there was no camera
on the device or 'android.media.action.IMAGE_CAPTURE' was not added to the
<queries> element" (segnalato su Sentry).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 08:45:57 +02:00
MarcoE deda374b5b Finish v1.2.2 2026-09-29 10:34:45 +02:00
MarcoE e1ca03f646 Finish v1.2.2 2026-09-29 10:34:45 +02:00
MarcoE 92271e20fb -> v1.2.2 (8) 2026-09-29 10:34:40 +02:00
MarcoEandClaude Opus 5.5 e524c12a96 Riporta a bozza le schede non inviate senza reparto o motivo
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:33:01 +02:00
MarcoEandClaude Opus 5.5 de889ef8c7 Mostra nell'ispezione le bozze senza reparto
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:27:22 +02:00
MarcoE 0e00fb273a Finish v1.2.1 2026-09-28 12:11:37 +02:00
MarcoE f1a17a64db Finish v1.2.1 2026-09-28 12:11:36 +02:00
MarcoE c3772733ea -> v1.2.1 (7) 2026-09-28 12:11:30 +02:00
MarcoEandClaude Opus 5.5 9799f6b0c7 Invia i log a parti e chiedi se includere le foto
Le foto venivano zippate tutte in un unico allegato: oltre i 100 MB i servizi
rifiutavano l'invio (MaxUploadSizeExceededException) e, costruendo tutto in RAM
sul thread della UI, il dispositivo si bloccava.

- Invio diviso in più email da massimo ~40 MB ciascuna
- Zip scritti su disco una parte alla volta e caricati in streaming
- Preparazione in background, avanzamento "parte X di N"
- Richiesta se includere le foto, con il loro peso (default: solo i log)
- Database inviato insieme al file -wal
- Avviso subito se si è offline

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:10:19 +02:00
MarcoE a4e5709be6 Finish v1.2.0 2026-09-18 11:51:17 +02:00
23 changed files with 679 additions and 222 deletions

No files matched your search

@@ -155,14 +155,29 @@ public class IspezioniService(AppDbContext db) : IIspezioniService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public Task<List<Scheda>> GetAllSchedeOfIspezioneAsync(string codMdep, DateTime data, string rilevatore) => public async Task<List<Scheda>> GetAllSchedeOfIspezioneAsync(string codMdep, DateTime data, string rilevatore)
db.Schede {
// Le versioni precedenti dell'app hanno lasciato schede non bozza senza
// reparto o motivo: risultano inviabili ma il server le rifiuta, e
// bloccano la conclusione dell'ispezione. Tornano bozze, cosi' il
// rilevatore le completa. Quelle gia' inviate non si toccano.
await db.Schede
.Where(x => x.CodMdep == codMdep &&
x.Data == data &&
x.Rilevatore == rilevatore &&
!x.Bozza &&
x.ActivityId == null &&
(string.IsNullOrEmpty(x.CodJfas) || string.IsNullOrEmpty(x.ActivityTypeId)))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Bozza, true));
return await db.Schede
.AsNoTracking() .AsNoTracking()
.Include(s => s.Articoli) .Include(s => s.Articoli)
.Where(x => x.CodMdep == codMdep && .Where(x => x.CodMdep == codMdep &&
x.Data == data && x.Data == data &&
x.Rilevatore == rilevatore) x.Rilevatore == rilevatore)
.ToListAsync(); .ToListAsync();
}
public Task<Scheda?> GetSchedaAsync(int schedaId) => public Task<Scheda?> GetSchedaAsync(int schedaId) =>
db.Schede db.Schede
+15 -3
View File
@@ -8,12 +8,21 @@ namespace SteUp.Maui.Core.Services;
public class AttachedService(ILogger<FileManager> logger) : IAttachedService public class AttachedService(ILogger<FileManager> logger) : IAttachedService
{ {
public async Task<AttachedDto?> SelectImageFromCamera() public bool IsCameraAvailable => MediaPicker.Default.IsCaptureSupported;
public async Task<bool> RequestCameraPermissions()
{ {
var cameraPerm = await Permissions.RequestAsync<Permissions.Camera>(); var cameraPerm = await Permissions.RequestAsync<Permissions.Camera>();
var storagePerm = await Permissions.RequestAsync<Permissions.StorageWrite>(); var storagePerm = await Permissions.RequestAsync<Permissions.StorageWrite>();
if (cameraPerm != PermissionStatus.Granted || storagePerm != PermissionStatus.Granted) return cameraPerm == PermissionStatus.Granted && storagePerm == PermissionStatus.Granted;
}
public void OpenAppSettings() => AppInfo.Current.ShowSettingsUI();
public async Task<AttachedDto?> SelectImageFromCamera()
{
if (!await RequestCameraPermissions())
return null; return null;
FileResult? result; FileResult? result;
@@ -28,7 +37,10 @@ public class AttachedService(ILogger<FileManager> logger) : IAttachedService
logger.LogError(ex, ex.Message); logger.LogError(ex, ex.Message);
Console.WriteLine($"Errore cattura foto: {ex.Message}"); Console.WriteLine($"Errore cattura foto: {ex.Message}");
SentrySdk.CaptureException(ex); SentrySdk.CaptureException(ex);
return null;
// Rilancia: restituire null qui era indistinguibile da un annullamento,
// e il rilevatore toccava "Aggiungi una foto" senza vedere nulla.
throw;
} }
return result is null ? null : await UtilityFile.ConvertToDto(result, AttachedDto.TypeAttached.Image); return result is null ? null : await UtilityFile.ConvertToDto(result, AttachedDto.TypeAttached.Image);
+48 -136
View File
@@ -1,5 +1,4 @@
using System.IO.Compression; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using SteUp.Data.LocalDb; using SteUp.Data.LocalDb;
using SteUp.Shared.Core.Dto; using SteUp.Shared.Core.Dto;
using SteUp.Shared.Core.Entities; using SteUp.Shared.Core.Entities;
@@ -17,15 +16,15 @@ public class FileManager(
private async Task<AttachedDto> ConvertToDto(FileInfo file, AttachedDto.TypeAttached type, bool isFromToUpload) private async Task<AttachedDto> ConvertToDto(FileInfo file, AttachedDto.TypeAttached type, bool isFromToUpload)
{ {
var (origUrl, thumbUrl) = await SaveAndCreateThumbAsync( var bytes = await File.ReadAllBytesAsync(file.FullName);
await File.ReadAllBytesAsync(file.FullName), var (origUrl, thumbUrl) = await SaveAndCreateThumbAsync(bytes, file.Name);
file.Name
);
return new AttachedDto return new AttachedDto
{ {
Name = file.Name, Name = file.Name,
Path = file.FullName, Path = file.FullName,
// Servono per inviare le foto rimaste in /toUpload (salvate senza rete).
FileBytes = bytes,
TempPath = origUrl, TempPath = origUrl,
ThumbPath = thumbUrl, ThumbPath = thumbUrl,
Type = type, Type = type,
@@ -102,7 +101,9 @@ public class FileManager(
foreach (var file in toUploadFiles) foreach (var file in toUploadFiles)
{ {
if (!fileNameFilter.Contains(file.Name)) // Scheda.ImageNames contiene il percorso completo restituito da SaveInspectionFile:
// confrontando solo il nome le foto salvate e non ancora inviate non comparivano mai.
if (!fileNameFilter.Contains(file.FullName) && !fileNameFilter.Contains(file.Name))
continue; continue;
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -343,146 +344,57 @@ public class FileManager(
private static string GetInspectionKey(Ispezione ispezione) => private static string GetInspectionKey(Ispezione ispezione) =>
$"{ispezione.CodMdep}_{ispezione.Data:ddMMyyyy}_{ispezione.Rilevatore.ToLower()}"; $"{ispezione.CodMdep}_{ispezione.Data:ddMMyyyy}_{ispezione.Rilevatore.ToLower()}";
public List<SendEmailDto.AttachmentsDto> GetFileForExport() public Task<long> GetLogExportImagesSizeAsync(CancellationToken ct = default) =>
Task.Run(() => RetrieveAttachedImages().Sum(x => x.File.Length), ct);
public Task<ILogExport> PrepareLogExportAsync(bool includeImages, CancellationToken ct = default)
{ {
var attachments = new List<SendEmailDto.AttachmentsDto>(); return Task.Run<ILogExport>(() =>
// 1) log file singolo (se ti serve ancora)
var logFile = RetrieveLogFile();
if (!logFile.IsNullOrEmpty())
{ {
attachments.Add(new SendEmailDto.AttachmentsDto var workDir = Path.Combine(FileSystem.CacheDirectory, "log_export");
{
FileName = $"logs_{DateTime.Today:yyyyMMdd}.zip",
FileContent = CreateZipBytes(logFile!)
});
}
// 2) database zip // Residui di un invio interrotto (app chiusa a metà): si riparte da zero.
var dbZip = CreateDatabaseZipAttachment(); if (Directory.Exists(workDir))
if (dbZip != null) attachments.Add(dbZip); Directory.Delete(workDir, true);
Directory.CreateDirectory(workDir);
// 3) Img zip var logFiles = RetrieveLogFiles()
var attachedInfo = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "attached")); .Select(f => new LogExport.ZipItem(f, f.Name))
if (!attachedInfo.Exists) return attachments;
var attachedFiles = attachedInfo
.EnumerateFiles("*", SearchOption.AllDirectories)
.ToList(); .ToList();
if (attachedFiles.Count > 0) // Col journal WAL le modifiche recenti stanno ancora nel file -wal: senza, il database
{ // inviato può non avere le ultime ispezioni.
attachments.Add(new SendEmailDto.AttachmentsDto var dbPath = dbPathProvider.GetDbPath();
{ var dbFiles = new[] { dbPath, $"{dbPath}-wal" }
FileName = $"immagini_allegate_{DateTime.Today:yyyyMMdd}.zip", .Select(p => new FileInfo(p))
FileContent = CreateZipBytes(attachedInfo.FullName, attachedFiles) .Where(f => f.Exists)
}); .Select(f => new LogExport.ZipItem(f, f.Name))
.ToList();
var images = includeImages ? RetrieveAttachedImages() : [];
return new LogExport(workDir, logFiles, dbFiles, images, logger);
}, ct);
} }
return attachments; private static List<FileInfo> RetrieveLogFiles()
{
var directory = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "logs"));
return directory.Exists ? directory.GetFiles().ToList() : [];
} }
private static List<FileInfo>? RetrieveLogFile() private static List<LogExport.ZipItem> RetrieveAttachedImages()
{ {
var appDataPath = FileSystem.AppDataDirectory; var root = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "attached"));
var targetDirectory = Path.Combine(appDataPath, "logs"); if (!root.Exists) return [];
var directory = new DirectoryInfo(targetDirectory); // Ordine per percorso: le foto della stessa ispezione finiscono nella stessa parte.
// Il percorso relativo mantiene le cartelle dentro lo zip (separatore "/" per compatibilità).
List<FileInfo>? files = null; return root
.EnumerateFiles("*", SearchOption.AllDirectories)
if (directory.Exists) .OrderBy(f => f.FullName, StringComparer.Ordinal)
files = directory.GetFiles().ToList(); .Select(f => new LogExport.ZipItem(f,
Path.GetRelativePath(root.FullName, f.FullName).Replace('\\', '/')))
return files; .ToList();
}
private SendEmailDto.AttachmentsDto? CreateDatabaseZipAttachment()
{
var files = new[]
{
new FileInfo(dbPathProvider.GetDbPath())
};
// Filtra solo quelli esistenti
var existingFiles = files.Where(f => f.Exists).ToList();
if (existingFiles.Count == 0)
return null;
using var memoryStream = new MemoryStream();
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
{
foreach (var file in existingFiles)
{
var entry = archive.CreateEntry(file.Name, CompressionLevel.Optimal);
using var entryStream = entry.Open();
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
fileStream.CopyTo(entryStream);
}
}
return new SendEmailDto.AttachmentsDto
{
FileName = $"database_{DateTime.Now:yyyyMMdd_HHmm}.zip",
FileContent = memoryStream.ToArray()
};
}
private static byte[] CreateZipBytes(IEnumerable<FileInfo> files)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var file in files)
{
if (!file.Exists)
continue;
// Nome dentro lo zip (evita path e collisioni minime)
var entryName = file.Name;
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
fileStream.CopyTo(entryStream);
}
}
return ms.ToArray();
}
private static byte[] CreateZipBytes(string rootDir, IEnumerable<FileInfo> files)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var file in files)
{
if (!file.Exists)
continue;
// Path relativo rispetto a rootDir -> mantiene le directory nello zip
var relativePath = Path.GetRelativePath(rootDir, file.FullName);
// Zip usa "/" come separatore: normalizziamo per compatibilità
var entryName = relativePath.Replace('\\', '/');
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
using var entryStream = entry.Open();
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
fileStream.CopyTo(entryStream);
}
}
return ms.ToArray();
} }
} }
+171
View File
@@ -0,0 +1,171 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using SteUp.Shared.Core.Dto;
using SteUp.Shared.Core.Interface.System;
namespace SteUp.Maui.Core.Services;
/// <summary>
/// Divide l'invio dei log in parti sotto <see cref="MaxPartBytes"/>: la prima porta log, database
/// e le prime immagini, le successive solo immagini. Gli zip vengono scritti su disco (mai in RAM)
/// una parte alla volta, così lo spazio occupato resta quello di una sola parte.
/// </summary>
internal sealed class LogExport : ILogExport
{
// Ben sotto il max-file-size dei servizi (100 MB) e i limiti tipici dei server di posta.
// Una parte piccola regge anche l'upload da rete mobile entro il timeout HTTP del client.
internal const long MaxPartBytes = 40L * 1024 * 1024;
private const int BufferSize = 81920;
internal readonly record struct ZipItem(FileInfo File, string EntryName);
private readonly string _workDir;
private readonly List<ZipItem> _logFiles;
private readonly List<ZipItem> _dbFiles;
private readonly List<List<ZipItem>> _imageParts;
private readonly ILogger _logger;
private readonly List<string> _currentPartFiles = [];
public int PartCount => _imageParts.Count;
internal LogExport(string workDir, List<ZipItem> logFiles, List<ZipItem> dbFiles, List<ZipItem> images,
ILogger logger)
{
_workDir = workDir;
_logFiles = logFiles;
_dbFiles = dbFiles;
_logger = logger;
_imageParts = SplitImages(images, logFiles.Concat(dbFiles).Sum(x => x.File.Length));
}
/// <summary>
/// Riempie le parti in ordine (le foto di un'ispezione restano vicine). La prima parte parte
/// già occupata da log e database: uso le dimensioni non compresse, una stima per eccesso.
/// Un file più grande di una parte finisce da solo in una parte sua.
/// </summary>
private static List<List<ZipItem>> SplitImages(List<ZipItem> images, long firstPartReserved)
{
var parts = new List<List<ZipItem>> { new() };
var currentSize = firstPartReserved;
foreach (var image in images)
{
var length = image.File.Length;
if (currentSize > 0 && currentSize + length > MaxPartBytes)
{
parts.Add([]);
currentSize = 0;
}
parts[^1].Add(image);
currentSize += length;
}
return parts;
}
public Task<List<SendEmailDto.AttachmentsDto>> BuildPartAsync(int index, CancellationToken ct = default)
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, PartCount);
// Task.Run: la compressione non deve bloccare il thread della UI.
return Task.Run(() =>
{
DeleteCurrentPartFiles();
var attachments = new List<SendEmailDto.AttachmentsDto>();
var date = DateTime.Today.ToString("yyyyMMdd");
if (index == 0)
{
if (_logFiles.Count > 0)
attachments.Add(WriteZip($"logs_{date}.zip", _logFiles, CompressionLevel.Optimal, ct));
if (_dbFiles.Count > 0)
attachments.Add(WriteZip($"database_{DateTime.Now:yyyyMMdd_HHmm}.zip", _dbFiles,
CompressionLevel.Optimal, ct));
}
var images = _imageParts[index];
if (images.Count > 0)
{
var suffix = PartCount > 1 ? $"_parte{index + 1}di{PartCount}" : string.Empty;
// JPEG già compressi: ricomprimerli costa CPU e non riduce quasi niente.
attachments.Add(WriteZip($"immagini_allegate_{date}{suffix}.zip", images,
CompressionLevel.NoCompression, ct));
}
return attachments;
}, ct);
}
private SendEmailDto.AttachmentsDto WriteZip(string fileName, List<ZipItem> items, CompressionLevel level,
CancellationToken ct)
{
var path = Path.Combine(_workDir, fileName);
_currentPartFiles.Add(path);
using (var zipStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize))
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create))
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested();
try
{
// FileShare.ReadWrite: log e database possono essere ancora aperti in scrittura.
using var source = item.File.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var entry = archive.CreateEntry(item.EntryName, level);
entry.LastWriteTime = item.File.LastWriteTime;
using var entryStream = entry.Open();
source.CopyTo(entryStream, BufferSize);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// Un file sparito o bloccato non deve far saltare tutto l'invio.
_logger.LogWarning(e, "Export log: file {File} saltato", item.File.FullName);
}
}
}
return new SendEmailDto.AttachmentsDto { FileName = fileName, FilePath = path };
}
private void DeleteCurrentPartFiles()
{
foreach (var path in _currentPartFiles)
{
try
{
File.Delete(path);
}
catch (Exception e)
{
_logger.LogWarning(e, "Export log: impossibile eliminare {File}", path);
}
}
_currentPartFiles.Clear();
}
public ValueTask DisposeAsync()
{
try
{
if (Directory.Exists(_workDir))
Directory.Delete(_workDir, true);
}
catch (Exception e)
{
_logger.LogWarning(e, "Export log: impossibile eliminare {Dir}", _workDir);
}
return ValueTask.CompletedTask;
}
}
@@ -25,4 +25,11 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Android 11+: rende visibile l'app fotocamera di sistema a MediaPicker -->
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
</manifest> </manifest>
+5 -5
View File
@@ -30,8 +30,8 @@
<ApplicationId>it.integry.SteUp</ApplicationId> <ApplicationId>it.integry.SteUp</ApplicationId>
<!-- Versions --> <!-- Versions -->
<ApplicationDisplayVersion>1.2.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.2.5</ApplicationDisplayVersion>
<ApplicationVersion>6</ApplicationVersion> <ApplicationVersion>11</ApplicationVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<!-- <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">--> <!-- <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">-->
@@ -116,12 +116,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Maui" Version="14.1.0" /> <PackageReference Include="CommunityToolkit.Maui" Version="14.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="IntegryApiClient.MAUI" Version="2.0.1" /> <PackageReference Include="IntegryApiClient.MAUI" Version="2.6.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.51" /> <PackageReference Include="Microsoft.Maui.Controls" Version="10.0.51" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.51" /> <PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.51" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.51" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.51" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.8" />
<PackageReference Include="Sentry.Maui" Version="6.4.1" /> <PackageReference Include="Sentry.Maui" Version="6.4.1" />
<PackageReference Include="SkiaSharp" Version="3.119.2" /> <PackageReference Include="SkiaSharp" Version="3.119.2" />
</ItemGroup> </ItemGroup>
@@ -157,6 +157,7 @@
@foreach (var group in SchedeGrouped) @foreach (var group in SchedeGrouped)
{ {
var reparto = group.Key; var reparto = group.Key;
var senzaReparto = ReferenceEquals(reparto, SenzaReparto);
var chiuso = IsCollapsed(reparto); var chiuso = IsCollapsed(reparto);
var daInviare = group.Value.CountDaInviare(); var daInviare = group.Value.CountDaInviare();
@@ -172,7 +173,9 @@
</button> </button>
<div class="reparto__actions"> <div class="reparto__actions">
@if (!IsCompleted) @* Il gruppo "Senza reparto" raccoglie solo bozze da
sistemare: non ha un "+" ne' un invio suo. *@
@if (!IsCompleted && !senzaReparto)
{ {
@* Da qui la scheda nasce gia' con il suo reparto: e' @* Da qui la scheda nasce gia' con il suo reparto: e'
l'unica differenza con il "+" della barra. *@ l'unica differenza con il "+" della barra. *@
@@ -326,13 +329,15 @@
// schermata, non del documento. // schermata, non del documento.
private readonly HashSet<string> _collapsed = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _collapsed = new(StringComparer.OrdinalIgnoreCase);
private bool IsCollapsed(JtbFasiDto reparto) => // Il gruppo "Senza reparto" non ha codice: la chiave vuota e' la sua.
reparto.CodJfas is not null && _collapsed.Contains(reparto.CodJfas); private static string CollapseKey(JtbFasiDto reparto) => reparto.CodJfas ?? string.Empty;
private bool IsCollapsed(JtbFasiDto reparto) => _collapsed.Contains(CollapseKey(reparto));
private void Toggle(JtbFasiDto reparto) private void Toggle(JtbFasiDto reparto)
{ {
if (reparto.CodJfas is null) return; var key = CollapseKey(reparto);
if (!_collapsed.Remove(reparto.CodJfas)) _collapsed.Add(reparto.CodJfas); if (!_collapsed.Remove(key)) _collapsed.Add(key);
} }
private static string Conteggi(List<Scheda> schede) private static string Conteggi(List<Scheda> schede)
@@ -542,15 +547,22 @@
StateHasChanged(); StateHasChanged();
}); });
// Una bozza si puo' salvare anche senza reparto: senza questo gruppo
// sparirebbe dall'elenco pur restando sul dispositivo e contata fra le bozze.
private static readonly JtbFasiDto SenzaReparto = new() { Descrizione = "Senza reparto" };
private void GroupSchede() private void GroupSchede()
{ {
SchedeGrouped = SchedeList var grouped = new Dictionary<JtbFasiDto, List<Scheda>>();
.Where(s => s.Reparto != null)
.GroupBy(s => s.CodJfas) // In testa: sono schede a meta' che chiedono di essere completate.
.ToDictionary( var senzaReparto = SchedeList.FindAll(s => s.Reparto == null);
g => g.First().Reparto!, if (senzaReparto.Count > 0) grouped[SenzaReparto] = senzaReparto;
g => g.ToList()
); foreach (var g in SchedeList.Where(s => s.Reparto != null).GroupBy(s => s.CodJfas))
grouped[g.First().Reparto!] = g.ToList();
SchedeGrouped = grouped;
} }
private void OnSchedaModified(Scheda obj) private void OnSchedaModified(Scheda obj)
+100 -14
View File
@@ -1,5 +1,6 @@
@page "/user" @page "/user"
@attribute [Authorize] @attribute [Authorize]
@using Microsoft.Extensions.Logging
@using SteUp.Shared.Components.Layout @using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.Layout.Overlay @using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Components.SingleElements @using SteUp.Shared.Components.SingleElements
@@ -14,6 +15,7 @@
@inject IGenericSystemService GenericSystemService @inject IGenericSystemService GenericSystemService
@inject IFileManager FileManager @inject IFileManager FileManager
@inject IIntegryApiService IntegryApiService @inject IIntegryApiService IntegryApiService
@inject ILogger<UserPage> Logger
<HeaderLayout Title="Profilo"/> <HeaderLayout Title="Profilo"/>
@@ -87,6 +89,9 @@
<ConfirmMessageBox @ref="_logoutBox" Title="Uscire dall'account?" NoText="Annulla" <ConfirmMessageBox @ref="_logoutBox" Title="Uscire dall'account?" NoText="Annulla"
YesText="Esci" YesColor="Color.Error" YesText="Esci" YesColor="Color.Error"
Message="Uscendo servirà di nuovo la connessione per rientrare. Le ispezioni salvate sul dispositivo restano al loro posto."/> Message="Uscendo servirà di nuovo la connessione per rientrare. Le ispezioni salvate sul dispositivo restano al loro posto."/>
<ConfirmMessageBox @ref="_imagesBox" Title="Inviare anche le foto?" NoText="Annulla"
YesText="Solo i log" AltText="Includi le foto"
Message="@_imagesBoxMessage"/>
@code { @code {
private bool IsLoggedIn { get; set; } private bool IsLoggedIn { get; set; }
@@ -95,6 +100,8 @@
private string? CodHash { get; set; } = ""; private string? CodHash { get; set; } = "";
private ConfirmMessageBox _logoutBox = null!; private ConfirmMessageBox _logoutBox = null!;
private ConfirmMessageBox _imagesBox = null!;
private string _imagesBoxMessage = string.Empty;
private Core.Helpers.StatusBadgeHelper.BadgeSpec ConnessioneBadge => NetworkService.ConnectionAvailable private Core.Helpers.StatusBadgeHelper.BadgeSpec ConnessioneBadge => NetworkService.ConnectionAvailable
? new Core.Helpers.StatusBadgeHelper.BadgeSpec("success", "ri-cloud-line", "Online") ? new Core.Helpers.StatusBadgeHelper.BadgeSpec("success", "ri-cloud-line", "Online")
@@ -109,33 +116,69 @@
private async Task ExportLog() private async Task ExportLog()
{ {
_progressMessage = "Invio dei log…"; if (VisibleOverlay) return;
if (!NetworkService.ConnectionAvailable)
{
Snackbar.Add("Sei offline: per inviare i log serve la connessione.", Severity.Warning);
return;
}
var includeImages = await AskIncludeImages();
if (includeImages is null) return;
_progressMessage = "Preparazione dei log…";
VisibleOverlay = true; VisibleOverlay = true;
StateHasChanged(); StateHasChanged();
var partCount = 0;
var sentParts = 0;
try try
{ {
var profiloAzienda = LocalStorage.GetString("codHash"); // Un'email per parte: ogni parte resta sotto i limiti di upload dei servizi.
await using var export = await FileManager.PrepareLogExportAsync(includeImages.Value);
partCount = export.PartCount;
var email = new SendEmailDto var profiloAzienda = LocalStorage.GetString("codHash");
var subject = $"SteUP - Log del {DateTime.Today:d} di {UserSession.User.Username}";
var body = $"Username: <b>{UserSession.User.Username}</b><br/>" +
$"Profilo azienda: <b>{profiloAzienda}</b><br/>" +
$"ProfileDb: <b>{UserSession.ProfileDb}</b><br/>" +
$"Versione app: <b>{GenericSystemService.GetCurrentAppVersion()}</b>";
for (var i = 0; i < partCount; i++)
{
var multiPart = partCount > 1;
_progressMessage = multiPart ? $"Invio dei log… parte {i + 1} di {partCount}" : "Invio dei log…";
StateHasChanged();
var attachments = await export.BuildPartAsync(i);
await IntegryApiService.SendEmail(new SendEmailDto
{ {
FromName = "Integry Log", FromName = "Integry Log",
To = "developer@integry.it", To = "developer@integry.it",
Subject = $"SteUP - Log del {DateTime.Today:d} di {UserSession.User.Username}", Subject = multiPart ? $"{subject} (parte {i + 1}/{partCount})" : subject,
IsHtml = true, IsHtml = true,
MsgText = $"Username: <b>{UserSession.User.Username}</b><br/>" + MsgText = multiPart ? $"{body}<br/>Parte: <b>{i + 1} di {partCount}</b>" : body,
$"Profilo azienda: <b>{profiloAzienda}</b><br/>" + Attachments = attachments
$"ProfileDb: <b>{UserSession.ProfileDb}</b><br/>" + });
$"Versione app: <b>{GenericSystemService.GetCurrentAppVersion()}</b>",
Attachments = FileManager.GetFileForExport()
};
await IntegryApiService.SendEmail(email); sentParts++;
Snackbar.Add("Log inviati allo sviluppo", Severity.Success);
} }
catch
Snackbar.Add(partCount > 1 ? $"Log inviati allo sviluppo in {partCount} parti" : "Log inviati allo sviluppo",
Severity.Success);
}
catch (Exception e)
{ {
Snackbar.Add("Invio dei log non riuscito. Controlla la connessione e riprova.", Severity.Error); Logger.LogError(e, "Invio dei log interrotto dopo {Sent} parti su {Total}", sentParts, partCount);
Snackbar.Add(sentParts > 0
? $"Invio interrotto: inviate {sentParts} parti su {partCount}. Controlla la connessione e riprova."
: "Invio dei log non riuscito. Controlla la connessione e riprova.",
Severity.Error);
} }
finally finally
{ {
@@ -145,6 +188,49 @@
} }
} }
/// <summary>
/// Chiede se allegare le foto, indicando quanto pesano. Null = invio annullato.
/// Se sul dispositivo non ci sono foto non chiede niente.
/// </summary>
private async Task<bool?> AskIncludeImages()
{
long imagesSize;
_progressMessage = "Controllo delle foto…";
VisibleOverlay = true;
StateHasChanged();
try
{
imagesSize = await FileManager.GetLogExportImagesSizeAsync();
}
catch (Exception e)
{
// Senza sapere quanto pesano, meglio un invio leggero che uno che rischia di fallire.
Logger.LogWarning(e, "Impossibile calcolare la dimensione delle foto da inviare");
imagesSize = 0;
}
finally
{
VisibleOverlay = false;
_progressMessage = null;
StateHasChanged();
}
if (imagesSize == 0) return false;
var sizeText = imagesSize < 1024 * 1024
? "meno di 1 MB"
: $"circa {imagesSize / (1024d * 1024):N0} MB";
_imagesBoxMessage = $"Le foto delle ispezioni occupano {sizeText}. Servono solo se il problema " +
"riguarda le foto: senza, l'invio è molto più veloce.";
StateHasChanged();
// "Solo i log" è la scelta consigliata (pulsante pieno, true); "Includi le foto" -> false.
var result = await _imagesBox.ShowAsync();
return result is null ? null : result == false;
}
private async Task UpdateDb() private async Task UpdateDb()
{ {
_progressMessage = "Sincronizzazione…"; _progressMessage = "Sincronizzazione…";
@@ -16,7 +16,7 @@
<div class="list-card" aria-label="@SemanticLabel"> <div class="list-card" aria-label="@SemanticLabel">
<div class="list-card__head"> <div class="list-card__head">
<div class="list-card__ident"> <div class="list-card__ident">
<div class="t-ident">@Scheda.DescrizioneReparto</div> <div class="t-ident">@(Scheda.DescrizioneReparto ?? "Senza reparto")</div>
<h3 class="list-card__title">@Titolo</h3> <h3 class="list-card__title">@Titolo</h3>
</div> </div>
</div> </div>
@@ -125,7 +125,7 @@
}; };
private string SemanticLabel => private string SemanticLabel =>
$"Scheda {Titolo}, reparto {Scheda.DescrizioneReparto}, {SyncState.ToBadge().Label}, {ArticoliLabel}, {FotoLabel}"; $"Scheda {Titolo}, reparto {Scheda.DescrizioneReparto ?? "non indicato"},{SyncState.ToBadge().Label}, {ArticoliLabel}, {FotoLabel}";
private async Task SetBusy(bool value) private async Task SetBusy(bool value)
{ {
@@ -2,8 +2,9 @@
che conseguenze ha: davanti a un'azione irreversibile la domanda deve che conseguenze ha: davanti a un'azione irreversibile la domanda deve
essere leggibile prima del testo, non dedotta da esso. essere leggibile prima del testo, non dedotta da esso.
YesText -> true, NoText -> false, chiusura senza scelta -> null. *@ YesText -> true, NoText o chiusura senza scelta -> null.
<MudMessageBox @ref="_confirmDelete" Title="@Title" CancelText="@NoText"> AltText (opzionale) aggiunge una seconda scelta valida -> false. *@
<MudMessageBox @ref="_confirmDelete" Title="@Title" CancelText="@NoText" NoText="@AltText">
<MessageContent> <MessageContent>
@Message @Message
</MessageContent> </MessageContent>
@@ -20,6 +21,7 @@
[Parameter] public string Message { get; set; } = string.Empty; [Parameter] public string Message { get; set; } = string.Empty;
[Parameter] public string YesText { get; set; } = string.Empty; [Parameter] public string YesText { get; set; } = string.Empty;
[Parameter] public string NoText { get; set; } = "Annulla"; [Parameter] public string NoText { get; set; } = "Annulla";
[Parameter] public string? AltText { get; set; }
[Parameter] public Color YesColor { get; set; } = Color.Primary; [Parameter] public Color YesColor { get; set; } = Color.Primary;
private MudMessageBox? _confirmDelete; private MudMessageBox? _confirmDelete;
@@ -40,7 +40,32 @@
private async Task OnCamera() private async Task OnCamera()
{ {
var selectImageFromCamera = await AttachedService.SelectImageFromCamera(); if (!await AttachedService.RequestCameraPermissions())
{
Snackbar.Clear();
Snackbar.Add("Per scattare le foto serve il permesso della fotocamera", Severity.Warning, config =>
{
config.Action = "Impostazioni";
config.OnClick = _ =>
{
AttachedService.OpenAppSettings();
return Task.CompletedTask;
};
});
return;
}
AttachedDto? selectImageFromCamera;
try
{
selectImageFromCamera = await AttachedService.SelectImageFromCamera();
}
catch (Exception)
{
Snackbar.Clear();
Snackbar.Add("Non è stato possibile scattare la foto, riprova", Severity.Error);
return;
}
if (selectImageFromCamera != null) if (selectImageFromCamera != null)
{ {
@@ -171,7 +171,10 @@
</div> </div>
} }
@if (item.p.ToUpload && !ReadOnly) @* Rimovibile anche se gia' salvata o inviata. Non mentre e'
in elaborazione: l'indice della foto serve ancora al
caricamento in background. *@
@if (!ReadOnly && !item.p.ThumbPath.IsNullOrEmpty())
{ {
<button type="button" class="attach-item__remove" <button type="button" class="attach-item__remove"
aria-label="@($"Rimuovi la foto {item.p.Name}")" aria-label="@($"Rimuovi la foto {item.p.Name}")"
@@ -336,6 +339,9 @@
// Gli allegati vivono in AttachedList finche' non si salva: li tracciamo con un flag. // Gli allegati vivono in AttachedList finche' non si salva: li tracciamo con un flag.
private bool _attachmentsDirty; private bool _attachmentsDirty;
private List<AttachedDto>? AttachedList { get; set; } private List<AttachedDto>? AttachedList { get; set; }
// Foto gia' salvate tolte dal rilevatore: si eliminano davvero solo al salvataggio,
// cosi' uscendo senza salvare restano dove sono.
private readonly List<AttachedDto> _removedAttachments = [];
private IDisposable? _backRegistration; private IDisposable? _backRegistration;
// Il tasto indietro puo' arrivare mentre il prompt di uscita e' gia' a video. // Il tasto indietro puo' arrivare mentre il prompt di uscita e' gia' a video.
@@ -505,10 +511,6 @@
} }
private async Task Update(SaveSchedaResponseDto? apiResponse, bool preserveActivityId = false) private async Task Update(SaveSchedaResponseDto? apiResponse, bool preserveActivityId = false)
{
if (!AttachedList.IsNullOrEmpty())
{
foreach (var attached in AttachedList!.Where(x => !x.SavedOnAppData))
{ {
var ispezione = new Ispezione var ispezione = new Ispezione
{ {
@@ -517,7 +519,13 @@
Rilevatore = UserSession.User.Username Rilevatore = UserSession.User.Username
}; };
if (!attached.ToRemove) // Prima di toccare l'ActivityId: le foto gia' inviate vanno tolte dall'attivita'
// su cui sono state caricate.
await RemoveDeletedAttachments(ispezione);
if (!AttachedList.IsNullOrEmpty())
{
foreach (var attached in AttachedList!.Where(x => !x.SavedOnAppData))
{ {
var fileNameAdded = await FileManager.SaveInspectionFile( var fileNameAdded = await FileManager.SaveInspectionFile(
ispezione, ispezione,
@@ -529,9 +537,6 @@
if (fileNameAdded != null) if (fileNameAdded != null)
Scheda.ImageNames.Add(fileNameAdded); Scheda.ImageNames.Add(fileNameAdded);
} }
else
_ = FileManager.RemoveInspectionFile(ispezione, attached.Name!);
}
} }
// Salvando una bozza non tocchiamo l'ActivityId: se la scheda era già sul server, // Salvando una bozza non tocchiamo l'ActivityId: se la scheda era già sul server,
@@ -549,6 +554,70 @@
} }
} }
/// <summary>
/// Elimina le foto salvate che il rilevatore ha tolto dalla scheda: dal server se
/// erano gia' state inviate, poi dal dispositivo e dall'elenco della scheda.
/// Se il server non conferma, la foto resta (sul server e sul dispositivo) invece
/// di sparire solo da qui.
/// </summary>
private async Task RemoveDeletedAttachments(Ispezione ispezione)
{
if (_removedAttachments.Count == 0) return;
var nonEliminate = 0;
foreach (var attached in _removedAttachments)
{
if (attached.Name.IsNullOrEmpty()) continue;
var giaInviata = !attached.ToUpload;
// Una foto ancora "da inviare" di norma non e' sul server, ma l'invio dalla
// card o dalla chiusura ispezione non la sposta fra le inviate: con la rete
// si prova comunque a toglierla, senza bloccare l'eliminazione se non c'e'.
if (Scheda.ActivityId.IsValorized() && (giaInviata || NetworkService.ConnectionAvailable))
{
try
{
await IntegrySteupService.RemoveFile(Scheda.ActivityId!, attached.Name!);
}
catch (Exception e)
{
if (giaInviata)
{
Logger.LogError(e, "Eliminazione della foto {FileName} dal server non riuscita", attached.Name);
nonEliminate++;
continue;
}
Logger.LogWarning(e, "Foto {FileName} non trovata sul server", attached.Name);
}
}
try
{
FileManager.RemoveInspectionFile(ispezione, attached.Name!);
}
catch (Exception e)
{
// Il file orfano non fa danni: conta che la foto esca dalla scheda.
Logger.LogError(e, "Eliminazione del file {FileName} non riuscita", attached.Name);
}
Scheda.ImageNames?.RemoveAll(x => System.IO.Path.GetFileName(x) == attached.Name);
}
_removedAttachments.Clear();
if (nonEliminate > 0)
{
Snackbar.Add(nonEliminate == 1
? "Una foto già inviata non è stata eliminata: riprova con la connessione"
: $"{nonEliminate} foto già inviate non sono state eliminate: riprova con la connessione",
Severity.Warning);
}
}
private async Task UploadFile(Scheda scheda) private async Task UploadFile(Scheda scheda)
{ {
if (AttachedList.IsNullOrEmpty()) return; if (AttachedList.IsNullOrEmpty()) return;
@@ -703,7 +772,8 @@
/// <summary> /// <summary>
/// Salva il lavoro in corso solo sul dispositivo, marcato come da completare. /// Salva il lavoro in corso solo sul dispositivo, marcato come da completare.
/// Niente chiamate al server e niente validazione: una bozza può essere incompleta. /// Niente salvataggio sul server e niente validazione: una bozza può essere incompleta.
/// Unica eccezione: le foto già inviate tolte dal rilevatore si eliminano anche dal server.
/// </summary> /// </summary>
private async Task SaveDraft() private async Task SaveDraft()
{ {
@@ -754,12 +824,60 @@
#region File #region File
// Dopo un rifiuto definitivo Android non ripropone la richiesta: il
// permesso si riattiva solo dalle impostazioni, quindi le apriamo noi.
private void ShowCameraPermissionDenied()
{
Snackbar.Clear();
Snackbar.Add("Per scattare le foto serve il permesso della fotocamera", Severity.Warning, config =>
{
config.Action = "Impostazioni";
config.OnClick = _ =>
{
AttachedService.OpenAppSettings();
return Task.CompletedTask;
};
});
}
private async Task OpenAddAttached() private async Task OpenAddAttached()
{ {
var selectImageFromCamera = await AttachedService.SelectImageFromCamera(); List<AttachedDto>? attachedList;
if (selectImageFromCamera == null) return;
var attachedList = new List<AttachedDto> { selectImageFromCamera }; if (AttachedService.IsCameraAvailable)
{
if (!await AttachedService.RequestCameraPermissions())
{
ShowCameraPermissionDenied();
return;
}
AttachedDto? selectImageFromCamera;
try
{
selectImageFromCamera = await AttachedService.SelectImageFromCamera();
}
catch (Exception)
{
// Gia' registrato e inviato a Sentry dal servizio
Snackbar.Clear();
Snackbar.Add("Non è stato possibile scattare la foto, riprova", Severity.Error);
return;
}
if (selectImageFromCamera == null) return;
attachedList = [selectImageFromCamera];
}
else
{
// Senza fotocamera "Aggiungi una foto" non avrebbe altra strada:
// si passa alla galleria invece di lasciare il rilevatore fermo.
Snackbar.Clear();
Snackbar.Add("Fotocamera non disponibile: scegli la foto dalla galleria", Severity.Warning);
attachedList = await AttachedService.SelectImageFromGallery();
if (attachedList is null || attachedList.Count == 0) return;
}
OnLoading = true; OnLoading = true;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -848,29 +966,32 @@
var modal = await WithChildDialog(() => Dialog.OpenSelectArt(articoli)); var modal = await WithChildDialog(() => Dialog.OpenSelectArt(articoli));
// Gli articoli si aggiungono sul thread della UI, come fanno le scansioni:
// toccare Scheda.Articoli da qui mentre arriva una lettura (o mentre la
// lista viene disegnata) puo' far perdere articoli.
await InvokeAsync(() => await InvokeAsync(() =>
{ {
OnLoading = false; OnLoading = false;
TextLoading = null; TextLoading = null;
StateHasChanged();
});
if (modal is { Canceled: false, Data: List<ArticoliInGrigliaDto> articoliSelezionati }) if (modal is { Canceled: false, Data: List<ArticoliInGrigliaDto> articoliSelezionati })
{ {
Scheda.Articoli.InsertRange(0, articoliSelezionati.ConvertAll(x => new SchedaArticolo // Quelli gia' sparati non si aggiungono di nuovo.
Scheda.Articoli.InsertRange(0, articoliSelezionati
.DistinctBy(x => x.Barcode)
.Where(x => !ContainsBarcode(x.Barcode))
.Select(x => new SchedaArticolo
{ {
Barcode = x.Barcode, Barcode = x.Barcode,
Descrizione = x.Descrizione Descrizione = x.Descrizione
}) })
); );
await InvokeAsync(() =>
{
RecalcDirty(); RecalcDirty();
}
StateHasChanged(); StateHasChanged();
}); });
}
}); });
} }
@@ -878,8 +999,22 @@
{ {
if (AttachedList is null || index < 0 || index >= AttachedList.Count) return; if (AttachedList is null || index < 0 || index >= AttachedList.Count) return;
if (AttachedList[index].SavedOnAppData) var attached = AttachedList[index];
AttachedList[index].ToRemove = true;
// Le foto appena scattate stanno solo in memoria: basta toglierle dall'elenco.
if (attached.SavedOnAppData)
{
// Una foto gia' inviata va tolta anche dal server: senza rete resterebbe
// nella scheda inviata pur sparendo dal dispositivo.
if (!attached.ToUpload && !NetworkService.ConnectionAvailable)
{
Snackbar.Clear();
Snackbar.Add("Per eliminare una foto già inviata serve la connessione", Severity.Warning);
return;
}
_removedAttachments.Add(attached);
}
AttachedList.RemoveAt(index); AttachedList.RemoveAt(index);
_attachmentsDirty = true; _attachmentsDirty = true;
@@ -936,7 +1071,7 @@
try try
{ {
if (value is null) return; if (value is null) return;
if (Scheda.Articoli.Any(x => x.Barcode.Equals(value))) return; if (ContainsBarcode(value)) return;
await InvokeAsync(() => await InvokeAsync(() =>
{ {
@@ -951,6 +1086,11 @@
OnLoading = false; OnLoading = false;
StateHasChanged(); StateHasChanged();
// Il controllo va ripetuto sul barcode restituito: il server puo' rispondere
// con un barcode diverso da quello letto (lo stesso gia' scelto dalla griglia),
// e due letture ravvicinate superano entrambe il controllo iniziale.
if (art != null && ContainsBarcode(art.Barcode)) return;
if (art != null) if (art != null)
{ {
Scheda.Articoli.Insert(0, new SchedaArticolo Scheda.Articoli.Insert(0, new SchedaArticolo
@@ -984,6 +1124,8 @@
private void OnErrorScan(string? value) => OnError(new Exception(value), value); private void OnErrorScan(string? value) => OnError(new Exception(value), value);
private bool ContainsBarcode(string barcode) => Scheda.Articoli.Any(x => x.Barcode.Equals(barcode));
#endregion #endregion
private void OnError(Exception? e, string? errorMessage) private void OnError(Exception? e, string? errorMessage)
-1
View File
@@ -15,7 +15,6 @@ public class AttachedDto
public string? ThumbPath { get; set; } public string? ThumbPath { get; set; }
public bool SavedOnAppData { get; set; } public bool SavedOnAppData { get; set; }
public bool ToRemove { get; set; }
public bool ToUpload { get; set; } public bool ToUpload { get; set; }
public Stream? FileContent => public Stream? FileContent =>
+5 -2
View File
@@ -22,12 +22,15 @@ public class SendEmailDto
[JsonPropertyName("html")] [JsonPropertyName("html")]
public bool IsHtml { get; set; } public bool IsHtml { get; set; }
[JsonPropertyName("attachments")] // Gli allegati viaggiano come parti multipart "allegati", non dentro il JSON della richiesta.
[JsonIgnore]
public List<AttachmentsDto>? Attachments { get; set; } public List<AttachmentsDto>? Attachments { get; set; }
public class AttachmentsDto public class AttachmentsDto
{ {
public string FileName { get; set; } = string.Empty; public string FileName { get; set; } = string.Empty;
public byte[] FileContent { get; set; } = [];
// Il file resta su disco e viene letto in streaming durante l'upload.
public string FilePath { get; set; } = string.Empty;
} }
} }
@@ -0,0 +1,39 @@
using System.Net;
using System.Net.Http.Headers;
namespace SteUp.Shared.Core.Helpers;
/// <summary>
/// Contenuto HTTP letto dal file durante l'invio, senza caricarlo in RAM. Il file viene riaperto
/// a ogni serializzazione, così regge anche un eventuale nuovo tentativo del client
/// (es. dopo il refresh del token).
/// </summary>
public sealed class FileHttpContent : HttpContent
{
private const int BufferSize = 81920;
private readonly string _path;
public FileHttpContent(string path, string mediaType)
{
_path = path;
Headers.ContentType = new MediaTypeHeaderValue(mediaType);
}
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
SerializeToStreamAsync(stream, context, CancellationToken.None);
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context,
CancellationToken cancellationToken)
{
await using var file = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.Read,
BufferSize, useAsync: true);
await file.CopyToAsync(stream, BufferSize, cancellationToken);
}
protected override bool TryComputeLength(out long length)
{
length = new FileInfo(_path).Length;
return true;
}
}
@@ -17,6 +17,7 @@ public interface IIntegrySteupService
Task<SaveSchedaResponseDto?> SaveMultipleSchede(List<SaveRequestDto> request); Task<SaveSchedaResponseDto?> SaveMultipleSchede(List<SaveRequestDto> request);
Task CompleteInspection(string activityId); Task CompleteInspection(string activityId);
Task UploadFile(string activityId, byte[] file, string fileName); Task UploadFile(string activityId, byte[] file, string fileName);
Task RemoveFile(string activityId, string fileName);
Task DeleteScheda(string activityId); Task DeleteScheda(string activityId);
} }
@@ -4,6 +4,14 @@ namespace SteUp.Shared.Core.Interface.System;
public interface IAttachedService public interface IAttachedService
{ {
bool IsCameraAvailable { get; }
/// <summary>Chiede i permessi per scattare; false se negati.</summary>
Task<bool> RequestCameraPermissions();
void OpenAppSettings();
/// <summary>Null se l'utente annulla; lancia se lo scatto fallisce.</summary>
Task<AttachedDto?> SelectImageFromCamera(); Task<AttachedDto?> SelectImageFromCamera();
Task<List<AttachedDto>?> SelectImageFromGallery(); Task<List<AttachedDto>?> SelectImageFromGallery();
} }
@@ -26,5 +26,6 @@ public interface IFileManager
Task<(string originalUrl, string thumbUrl)> SaveAndCreateThumbAsync(byte[] bytes, string fileName, Task<(string originalUrl, string thumbUrl)> SaveAndCreateThumbAsync(byte[] bytes, string fileName,
CancellationToken ct = default); CancellationToken ct = default);
List<SendEmailDto.AttachmentsDto> GetFileForExport(); Task<long> GetLogExportImagesSizeAsync(CancellationToken ct = default);
Task<ILogExport> PrepareLogExportAsync(bool includeImages, CancellationToken ct = default);
} }
@@ -0,0 +1,15 @@
using SteUp.Shared.Core.Dto;
namespace SteUp.Shared.Core.Interface.System;
/// <summary>
/// Invio dei log diviso in parti: ogni parte diventa un'email separata, sotto i limiti di upload
/// dei servizi. Gli zip di una parte vengono creati solo quando la si chiede e cancellati alla
/// parte successiva; il dispose elimina tutto quello che resta su disco.
/// </summary>
public interface ILogExport : IAsyncDisposable
{
int PartCount { get; }
Task<List<SendEmailDto.AttachmentsDto>> BuildPartAsync(int index, CancellationToken ct = default);
}
@@ -1,9 +1,9 @@
using System.Net.Http.Headers; using System.Text;
using System.Text;
using System.Text.Json; using System.Text.Json;
using IntegryApiClient.Core.Domain.RestClient.Contacts; using IntegryApiClient.Core.Domain.RestClient.Contacts;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SteUp.Shared.Core.Dto; using SteUp.Shared.Core.Dto;
using SteUp.Shared.Core.Helpers;
using SteUp.Shared.Core.Interface.IntegryApi; using SteUp.Shared.Core.Interface.IntegryApi;
namespace SteUp.Shared.Core.Services; namespace SteUp.Shared.Core.Services;
@@ -37,21 +37,18 @@ public class IntegryApiService(
public async Task SendEmail(SendEmailDto sendEmail) public async Task SendEmail(SendEmailDto sendEmail)
{ {
var content = new MultipartFormDataContent(); using var content = new MultipartFormDataContent();
try try
{ {
if (sendEmail.Attachments != null) foreach (var a in sendEmail.Attachments ?? [])
{ {
foreach (var a in sendEmail.Attachments) var mediaType = a.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
{ ? "application/zip"
var fileContent = new ByteArrayContent(a.FileContent); : "application/octet-stream";
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data"); content.Add(new FileHttpContent(a.FilePath, mediaType), "allegati", a.FileName);
content.Add(fileContent, "allegati", a.FileName);
}
} }
sendEmail.Attachments = null;
content.Add( content.Add(
new StringContent( new StringContent(
JsonSerializer.Serialize(sendEmail), JsonSerializer.Serialize(sendEmail),
@@ -69,9 +66,5 @@ public class IntegryApiService(
logger.LogError(e, e.Message); logger.LogError(e, e.Message);
throw; throw;
} }
finally
{
content.Dispose();
}
} }
} }
@@ -70,6 +70,20 @@ public class IntegrySteupService(IIntegryApiRestClient integryApiRestClient) : I
content.Dispose(); content.Dispose();
} }
/// <summary>
/// Toglie un allegato dalla scheda. Il server lo riconosce dal nome con cui
/// è stato caricato da <see cref="UploadFile"/>.
/// </summary>
public Task RemoveFile(string activityId, string fileName) =>
integryApiRestClient.AuthorizedGet<object>(
$"{BaseRequest}/removeAttachment",
new Dictionary<string, object?>
{
{ "activityId", activityId },
{ "fileName", fileName }
}
);
public Task DeleteScheda(string activityId) => public Task DeleteScheda(string activityId) =>
integryApiRestClient.AuthorizedGet<object>( integryApiRestClient.AuthorizedGet<object>(
$"{BaseRequest}/deleteScheda", $"{BaseRequest}/deleteScheda",
+2 -2
View File
@@ -11,13 +11,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="IntegryApiClient.Core" Version="2.0.1" /> <PackageReference Include="IntegryApiClient.Core" Version="2.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="MudBlazor" Version="9.0.0" /> <PackageReference Include="MudBlazor" Version="9.0.0" />
<PackageReference Include="CodeBeam.MudBlazor.Extensions" Version="9.0.1" /> <PackageReference Include="CodeBeam.MudBlazor.Extensions" Version="9.0.1" />
<PackageReference Include="Sentry" Version="6.4.1" /> <PackageReference Include="Sentry" Version="6.4.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" />
<PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.51" /> <PackageReference Include="Microsoft.Maui.Essentials" Version="10.0.51" />
</ItemGroup> </ItemGroup>
+1 -1
View File
@@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="IntegryApiClient.Blazor" Version="2.0.1" /> <PackageReference Include="IntegryApiClient.Blazor" Version="2.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.7" />