Compare commits

..
257 changed files with 2491 additions and 24043 deletions

View file

@ -15,7 +15,7 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=db POSTGRES_DB=db
POSTGRES_USER=pguser POSTGRES_USER=pguser
POSTGRES_PASSWORD=<your pg password> POSTGRES_PASSWORD=pgpassword
ANTHROPIC_API_KEY=none ANTHROPIC_API_KEY=none
ANTHROPIC_MAX_TOKENS=0 ANTHROPIC_MAX_TOKENS=0

View file

@ -49,29 +49,9 @@ jobs:
run: | run: |
echo "🏗️ Building the solution..." echo "🏗️ Building the solution..."
cd /src/_src && dotnet build --verbosity normal cd /src/_src && dotnet build --verbosity normal
- name: install dependencies
run: |
echo "📦 Installing dependencies..."
apt install -y texlive-binaries texlive-luatex texlive-pictures \
texlive-fonts-extra texlive-lang-french texlive-lang-english \
texlive-lang-portuguese texlive-lang-spanish \
texlive-latex-recommended texlive-latex-extra
- name: Test - name: Test
artifacts:
name: test-results
path: test-results.html
run: | run: |
echo "🚀 Lancement des tests..." echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \ cd /src/_src && dotnet test \
--report-xunit \ --verbosity normal \
--report-xunit-html \ --filter="Category!=Platform-Android"
--report-xunit-html-filename "test-results.html"
TEST_EXIT_CODE=$?
echo "Test exit code: $TEST_EXIT_CODE"
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
exit $TEST_EXIT_CODE
else
echo "✅ All tests passed successfully."
fi

5
.gitignore vendored
View file

@ -43,8 +43,3 @@ DataDir/
*.log *.log
# gitleaks scan output (local secret-scan report, not for the repo)
/gitleaks-report.json
# Exclude test reports (local test report, not for the repo)
/test-reports

18
.vscode/launch.json vendored
View file

@ -22,9 +22,11 @@
}, },
{ {
"name": "Android Debug", "name": "Android Debug",
"type": "maui", "type": "mono",
"request": "launch", "preLaunchTask": "run-debug-android",
"project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj" "request": "attach",
"address": "localhost",
"port": 55555
}, },
{ {
"name": "Android Attach - Debug", "name": "Android Attach - Debug",
@ -41,15 +43,9 @@
}, },
{ {
"name": "Yavsc Org", "name": "Yavsc Org",
"type": "coreclr", "type": "dotnet",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll", "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"cwd": "${workspaceFolder}/src/Yavsc.Org",
"preLaunchTask": "build-web-yavsc-org"
}, },
{ {
"name": "Yavsc Blogs", "name": "Yavsc Blogs",

View file

@ -45,10 +45,5 @@
] ]
} }
}, },
"dotnet.defaultSolution": "yavsc.sln", "dotnet.defaultSolution": "yavsc.sln"
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln",
"chat.tools.terminal.autoApprove": {
"dotnet build": true,
"dotnet test": true
}
} }

54
.vscode/tasks.json vendored
View file

@ -1,17 +1,27 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"isRoot": true, "isRoot": true,
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"code": 5,
"message": 6
}
}
],
"tasks": [ "tasks": [
{ {
"label": "build-debug-android", "label": "run-debug-android",
"command": "dotnet", "command": "dotnet",
"detail": "Build the PostIt Android project in debug mode",
"type": "shell", "type": "shell",
"runOptions": {
},
"problemMatcher": ["$msCompile"],
"options": { "options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
"env": { "env": {
@ -33,7 +43,6 @@
"label": "build", "label": "build",
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"problemMatcher": ["$msCompile"],
"args": ["build", "/property:GenerateFullPaths=true"], "args": ["build", "/property:GenerateFullPaths=true"],
"group": "build", "group": "build",
"isBuildCommand": true, "isBuildCommand": true,
@ -93,34 +102,6 @@
}, },
"isBackground": true "isBackground": true
}, },
{
"label": "build-web-yavsc-org",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Org"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "build-web-yavsc-blogs",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Blogs"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{ {
"label": "dotnet: build-postit-desktop", "label": "dotnet: build-postit-desktop",
"type": "process", "type": "process",
@ -128,7 +109,6 @@
"isTestCommand": false, "isTestCommand": false,
"isBackground": true, "isBackground": true,
"command": "dotnet", "command": "dotnet",
"problemMatcher": ["$msCompile"],
"args": ["build", "/property:GenerateFullPaths=true"], "args": ["build", "/property:GenerateFullPaths=true"],
"options": { "options": {
"cwd": "src/PostIt/PostIt.Desktop" "cwd": "src/PostIt/PostIt.Desktop"

View file

@ -1,251 +1,5 @@
# Changelog # Changelog
## [1.0.8-rc19] - Blogs UX
### Fixed
La saisie du billet est maintenant plutôt possible,
en petit écran.
## [1.0.8-rc18] - unstable
* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo
* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
(arbre de stockage indexé par le login, pas par le `sub`), boutons
d'action des listes réparés (binding `$parent`), revendication `name`
enfin présente dans les access tokens (branchement du `ProfileService`),
et négociation HTTP/3 côté client PostIt.
### Added
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
`DefaultRequestVersion = 3.0`, `DefaultVersionPolicy =
RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`,
avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/
desktop sans libmsquic).
* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via
`NavigationPage` + `Window.Show()` et vérifie que chaque bouton de
ligne (Télécharger / Supprimer) a sa `Command` liée, est armé
(`CanExecute`) et déclenche l'appel API au clic.
* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` :
mint un vrai JWT via le token endpoint IdentityServer (password grant,
`openid profile`) et asserte que l'access token porte `name`=login et
que `GetUserName()` résout vers ce login.
* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` :
découple `sub` et login, téléverse sous le login, et asserte que
`GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après
le `sub` n'existe pas).
### Changed
* [Yavsc.Org] Branchement du `ProfileService` custom
(`AddProfileService<ProfileService>()` après
`AddAspNetIdentity<ApplicationUser>()`). La classe était du code mort :
IdentityServer8 retombait sur son profile service par défaut, qui
n'émet que les claims listés dans les `UserClaims` des API resources.
`name` n'en faisait jamais partie, donc tout access token était sans
`name` et `GetUserName()` résolvait à `null` sur les hosts ressources
(Blogs/Api) — cause du 500 sur l'upload MyFiles et du
`User.Identity.Name` injoignable. Le service émet désormais
`name`=`user.UserName`, `email` et les rôles pour tout client dont
les `AllowedScopes` contiennent `profile`, indépendamment du caller.
* [Yavsc.Server] `ServiceExtensions` : `NameClaimType =
JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms
courts de claims tels qu'émis).
* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise
`GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour
la racine de stockage par utilisateur.
* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ;
1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client
de test ; réensemencement des identity resources openid/profile dans
`ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store
in-memory partagé et les laissait absentes → `invalid_scope`).
* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel
(défaut = subject) pour refléter la forme production où
`sub` (GUID) ≠ `name` (login).
* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour
sérialiser avec le build d'hôte qui mute les registres statiques
`BillingService`/`Config` (race parallèle pré-existante).
### Fixed
* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous
`{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de
stockage perso est indexé par le login partout ailleurs
(`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`,
le endpoint de téléchargement et les avatars utilisent
`user.UserName`). Une fois l'upload corrigé, les fichiers
atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait
sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt
« Mes fichiers » qui ne listait rien. `GetDir` résout désormais
l'utilisateur en base et liste sous `user.UserName`.
`UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds`
interpole toujours par `uid`.
* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au
clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach
sur les listes de pièces jointes de `BillingQueryDetailsPage` et
`EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd`
cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel,
pas un contrôle — donc résolvait à `null` et le `Button` restait
désactivé (grisé) et inerte au clic. Corrigé en
`$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe
déjà en vigueur dans `CirclesPage` / `EstimateListPage`).
* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient
pas sélectionnables/copiables : le texte était un `TextBlock` (non
sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`.
* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`)
renvoyait un 40x alors que le fichier était bien supprimé du disque.
`DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient
`FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length`
lève `FileNotFoundException` une fois le fichier disparu — le `catch`
du contrôleur transformait cette exception en 400, d'où le message
perçu « le fichier n'existe pas sur le serveur » tandis que la
suppression avait réussi. La taille est désormais capturée avant la
suppression.
* PostIt default settings
## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
production où `IsPublished` disparaissait du payload de liste du blog API.
### Added
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
le détail de demande et la page d'édition de devis.
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
(`EstimateApiController`) et les demandes front-office
(`FrontOfficeApiController`).
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
`UserFileAttachments`.
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
`README.nginx.md`.
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
`FileSystemApiTests` (pièces jointes et stockage perso).
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
### Changed
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
production sur le vhost Org. La preprod et la prod partageant la même
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
la config par nginx.
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
provient de la variable d'environnement
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
source.
### Fixed
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
de la liste du blog API. Le endpoint `GetBlogspot` retourne
`IEnumerable<IBlogPost>` sérialisé par System.Text.Json d'après le type
déclaré (l'interface), qui omet les propriétés du type concret —
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
`CommentsController` (Index/Details/…) atterrissaient sur la même route
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
`SwaggerGeneratorException` (conflit method/path) au chargement de
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
les actions scaffoldées.
## [1.0.8-rc16] - unstable
Flux de signature et de génération de devis : un devis peut désormais être
signé par les deux parties (fournisseur et client) puis téléchargé en PDF
ou TeX depuis PostIt, avec les signatures dessinées en vecteur.
### Added
* [PostIt] Pad de signature sur la page de validation d'un devis : on valide
ou on refuse en joignant une signature (côté fournisseur ou client), capturée
au format filaire PostIt (strokes) et envoyée sur l'acceptation.
* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` :
autorisation selon le rôle (fournisseur/client/admin), signature optionnelle
persistée sur l'estimate avec horodatage `ProviderValidationDate` /
`ClientValidationDate` selon le côté signataire.
* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted`
pour distinguer l'acceptation de chaque partie.
* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et
`estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est
rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`,
`BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs`
câble `AddControllersWithViews` + `SiteSettings`).
* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel
(`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client
côte à côte dans deux minipages ; plus de dépendance à un PNG de signature.
* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` /
`GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX »
dans PostIt sur la page de validation du devis et le détail de commande,
avec un helper `StorageProvider` d'enregistrement de fichier.
* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back),
de sorte qu'un devis validé quitte immédiatement la liste du signataire.
* [PostIt] Renommage des titres de liste : « Devis en attente de signature
fournisseur » / « Devis en attente de signature client ».
* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`.
* [CLI] Option `-c configuration-file.json` au lancement.
* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline
signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF
existe au chemin de sortie attendu et que seul le PDF persiste (aux/log
nettoyés) ; skip propre si `lualatex` est absent.
* [README] Dépendances système au runtime pour la génération PDF :
`texlive-binaries` (lualatex), `texlive-luatex` (luaotfload),
`texlive-pictures` (tikz), `texlive-fonts-extra` (bera).
### Changed
* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur
`ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste
« en attente ».
* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni
sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le
répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés
afin que seul le `.pdf` persiste.
### Fixed
* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument
fichier lit stdin en mode terminal et s'arrête après la première ligne d'un
document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier
d'entrée — stdin est alors traité comme un fichier régulier et le document
multi-lignes compile. C'était la cause racine de l'échec en production, pas
`bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets).
* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif
`Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de
`-output-directory` et de répertoire de travail ; résolu en chemin absolu.
* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model`
nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine`
résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine`
unique).
* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait
`null.cshtml` ; corrigé en `Layout = null` (null C#).
* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par
`lualatex`.
* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log`
qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message
d'erreur, et le `.log` est conservé en cas d'échec.
## [1.0.8-rc15] - unstable ## [1.0.8-rc15] - unstable
### Added ### Added
@ -615,10 +369,6 @@ the ACL now comes along with the article,
the same user-visible switch without a schema change. the same user-visible switch without a schema change.
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17
[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 [1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6

View file

@ -6,69 +6,44 @@
<ItemGroup> <ItemGroup>
<PackageVersion Include="DotMarkdown" Version="0.3.0" /> <PackageVersion Include="DotMarkdown" Version="0.3.0" />
<PackageVersion Include="Markdig" Version="1.3.2" /> <PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" /> <PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" /> <PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" /> <PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" /> <PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" /> <PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" /> <PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" /> <PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" />
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" />
<PackageVersion Include="MailKit" Version="4.18.0" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.4.1" />
<PackageVersion Include="MimeKit" Version="4.18.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="PayPalMerchantSDK" Version="2.16.250" />
<PackageVersion Include="pazof.rules" Version="1.1.3" />
<PackageVersion Include="RazorEngine.NetCore" Version="3.1.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -24,24 +24,41 @@
# ===================================================================== # =====================================================================
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2 ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1
FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env
WORKDIR /src WORKDIR /src
# (1) Manifest + fichiers de projet pour le cache de restore
COPY *.sln ./
COPY Directory.*.props ./
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# (2) Tout le code source
COPY . . COPY . .
# (4) Restore
RUN dotnet restore RUN dotnet restore
# Build des trois services web principaux # (5) Build des trois services web principaux
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
# Build APK Android (utilisé par le workflow docker-publish-android). # (6) Build APK Android (utilisé par le workflow docker-publish-android).
ARG ANDROID_TARGET_RID=android-arm64 ARG ANDROID_TARGET_RID=android-arm64
RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID} RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID}
# Publish des trois services web (les artifacts sont consommés par # (7) Publish des trois services web (les artifacts sont consommés par
# les stages publish-org / publish-api / publish-blogs ci-dessous). # les stages publish-org / publish-api / publish-blogs ci-dessous).
RUN mkdir -p /app/publish RUN mkdir -p /app/publish
RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org

View file

@ -13,37 +13,11 @@ all:
clean: clean:
dotnet clean -c $(CONFIG) dotnet clean -c $(CONFIG)
brutal_clean:
@find -name "obj" -exec rm -rf {} \;
@find -name "bin" -exec rm -rf {} \;
src/Yavsc/bin/output/wwwroot: src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
test: test:
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \ ASPNETCORE_ENVIRONMENT=Development dotnet test
--report-xunit-html \
--results-directory "test-reports"
test-postit:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "postit-test-results.html" \
--project src/PostIt/PostIt.Tests/PostIt.Tests.csproj
backend-tests: Blogs-backend-test Org-backend-test Api-backend-test
Api-backend-test:
Blogs-backend-test:
Org-backend-test:
%-backend-test:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "$*-backend-test-results.html" \
--project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj
watch: watch:
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj

View file

@ -61,40 +61,9 @@ make test
### les services et l'API ### les services et l'API
#### Dépendances système au runtime
La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`,
`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande
système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit
directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc
être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service
systemd `yavscApi`.
Sous Debian/Ubuntu :
```bash
sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra
```
- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic).
- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex,
notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur
`module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF.
(Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers
`texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.)
- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures
du client et du fournisseur en bas du devis (vecteur, pas de PNG).
- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis.
Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found`
(`Emergency stop`, aucun PDF).
Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF
generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet
de compiler le document hors-ligne.
### La Prod ### La Prod
`cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`. `cd srv/Yavsc` : `make pushInProd CONFIGURATION=Release`.
puis, pour une première installation puis, pour une première installation
`make install_service`. `make install_service`.

View file

@ -1,32 +1,23 @@
DESTDIR=
DOTNET_FRAMEWORK=net10.0
APP_PROJECT_NAMES=Org Blogs Api APP_PROJECT_NAMES=Org Blogs Api
SLNDIR=.. SLNDIR=..
APP_INSTALL_ENV=preprod
APP_INSTALL_PREFIX=preprod-
include .$(APP_INSTALL_ENV).env include $(SLNDIR)/.env
include .env
reinstall: copy-binaries generated/:
@for project in $(APP_PROJECT_NAMES); do \ @mkdir -p $@
echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done
@echo "💯 backend started"
reinstall-prod: generated/yavscOrg.service:
make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production generated/yavscBlogs.service:
generated/yavscApi.service:
generated/$(APP_INSTALL_PREFIX)yavscOrg.service: generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
generated/$(APP_INSTALL_PREFIX)yavscApi.service:
generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLNDIR)/.env
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \ DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$($*_PORT)" \ HTTP_PORT="$*_$(HTTP_PORT)" \
APPDIR="$(APPDIR)" \ BASEAPPDIR="$(BASEAPPDIR)" \
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ APP_SYSLOG_ID="yavsc$*" \
POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_HOST="$(POSTGRES_HOST)" \
POSTGRES_PORT="$(POSTGRES_PORT)" \ POSTGRES_PORT="$(POSTGRES_PORT)" \
POSTGRES_DB="$(POSTGRES_DB)" \ POSTGRES_DB="$(POSTGRES_DB)" \
@ -43,83 +34,11 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
envsubst > $@ envsubst > $@
@echo Created service file: $@ @echo Created service file: $@
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) --- copy-services: copy-service-Org copy-service-Blogs copy-service-Api
# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api} copy-service-Org: /etc/systemd/system/yavscOrg.service
# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr) copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
# copy-service-Api: /etc/systemd/system/yavscApi.service
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx
# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement
# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré).
NGINX_TEMPLATE := template.vhost
NGINX_OUT_DIR := generated/nginx
NGINX_HOSTS := Org Blogs Api
DOMAIN_ROOT ?= pschneider.fr
# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's
# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env).
BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http)
# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs
nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1)))
# domaine complet : <prefix><subdomain>.<DOMAIN_ROOT>
# prod Org -> yavsc.pschneider.fr
# preprod Org -> preprod-yavsc.pschneider.fr
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
@cat $(NGINX_TEMPLATE) \
| DOMAIN="$(call nginx_domain,$*)" \
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
HTTP_HOST="$(HTTP_HOST)" \
BACKEND_PORT="$($*_PORT)" \
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
@echo Created nginx vhost: $@
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
generate-nginx-prod:
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
$(NGINX_OUT_DIR):
@mkdir -p $@
# --- appsettings (générés depuis template.appsettings-*.json) ---
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
#
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
# contient un \). jq lit le secret depuis l'environnement (export) ->
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
export ASPNETCORE_Smtp__Password
APPSETTINGS_OUT_DIR := generated/appsettings
APPSETTINGS_HOSTS := org api blogs
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
@jq --arg domain "$(DOMAIN_ROOT)" \
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
template.appsettings-$*.json > $@
@echo Created appsettings: $@
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
$(APPSETTINGS_OUT_DIR):
@mkdir -p $@
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
sudo systemctl daemon-reload
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
install-services: install-service-Org install-service-Blogs install-service-Api
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
@ -133,36 +52,39 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \ rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \
sync ; \ sync ; \
ls $${BINDIR}/appsettings* 2>&1 ; \ ls $${BINDIR}/appsettings* 2>&1 ; \
sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \ sudo cp -a $${BINDIR}/* $(BASEAPPDIR) ; \
done done
@sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR) @sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR)
@sync
$(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service /etc/systemd/system/yavsc%.service: generated/yavsc%.service
sudo cp $^ $@ sudo cp $^ $@
sudo chown root:root $@ sudo chown root:root $@
build_publish_%: clean_publish_dir_% build_publish_%: clean_publish_dir_%
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj @ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
clean_publish_dir_%: clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
install: copy-binaries copy-services install: build_publish copy-binaries copy-services
@sudo systemctl daemon-reload @sudo systemctl daemon-reload
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do \ do \
echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ sudo systemctl enable yavsc$${project} ; \
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$${project} ; \ sudo systemctl start yavsc$${project} ; \
echo "🚀 start $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done done
reinstall: copy-binaries
@sync
@for project in $(APP_PROJECT_NAMES); do \
sudo systemctl start yavsc$${project} ; done
stop-services: stop-services:
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do \ do \
echo "🔥 Stopping $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ sudo systemctl stop yavsc$${project} ; \
sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done done
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
@ -171,7 +93,7 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_
showConfig: showConfig:
@echo CONFIGURATION: $(CONFIGURATION) @echo CONFIGURATION: $(CONFIGURATION)
@echo APPDIR: $(APPDIR) @echo BASEAPPDIR: $(BASEAPPDIR)
showApiLogs: showApiLogs:
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log @sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
@ -185,5 +107,3 @@ showBlogsLogs:
clean: clean:
@rm -rf generated @rm -rf generated
generated/:
@mkdir -p $@

View file

@ -1,134 +0,0 @@
# Vhosts nginx — génération par `make generate-nginx`
Les vhosts nginx sont **générés** à l'image des unités de service systemd
(`template.service` + `envsubst` → `generated/*.service`) : un template
unique `contrib/template.vhost` est substitué par `envsubst` vers
`contrib/generated/nginx/`, qui est **gitignoré**.
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
`template.appsettings-*.json`) est une capacité complémentaire — non
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
uniquement), mais disponible si Kestrel est un jour exposé en direct.
Directives générées (par vhost) :
```nginx
listen 443 ssl; listen [::]:443 ssl; http2 on;
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
ssl_protocols TLSv1.3;
```
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
confondus).
## Prérequis côté host
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
`listen ... quic` suffit à activer h3) :
```bash
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
```
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
backports ou `nginx-full`.
2. **UDP 443 ouvert** :
```bash
sudo ufw allow 443/udp
sudo ufw status | grep '443/udp' # → ALLOW
```
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
échoue sur le bind IPv6).
4. Certbot / certs Let's Encrypt déjà en place (chemins
`/etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem` référencés).
5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` —
sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux).
## Génération
```bash
cd contrib
# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
make generate-nginx
# Production -> generated/nginx/yavsc{Org,Blogs,Api}
make generate-nginx-prod
# ou manuellement :
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
```
Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env`
(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend
`https` en prod, `http` en preprod.
| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod |
|-------|--------------|-------------|-------------------------|------------------------|
| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` |
| Api | api | non | `https://localhost:87` | `http://localhost:93` |
| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` |
Domaines : `<prefix><subdomain>.pschneider.fr`
(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`).
## Déploiement sur le host
```bash
sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg
sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg
# (idem yavscApi, yavscBlogs)
sudo nginx -t && sudo systemctl reload nginx
```
## Vérification (sur le host)
```bash
# 1. Module h3 présent
nginx -V 2>&1 | grep -o 'http_v3_module'
# 2. UDP 443 ouvert
sudo ufw status | grep '443/udp'
# 3. En-tête Alt-Svc annoncé
curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443"
# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3)
curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3'
# 5. Fallback h2 et h1 toujours fonctionnels
curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
```
Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et
les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools →
Network → colonne « Protocole » → `h3` après mise à niveau.
## Comment ça marche (template + envsubst)
`contrib/template.vhost` contient les placeholders `$DOMAIN`,
`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle
`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue
via `envsubst` avec une **allowlist** :
```makefile
envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS'
```
Cette allowlist limite la substitution aux 5 variables citées, de sorte
que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`,
`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans
allowlist, `envsubst` détruirait ces variables nginx.
## Hors périmètre
- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal
systemd + nginx).
- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que
HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`.
- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2
côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP,
relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3).

View file

@ -1,33 +0,0 @@
{
"Site": {
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["api"],
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN"
],
"Bills": "bills",
"TempDir": "temp"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:92"
},
"Https": {
"Url": "https://localhost:93",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -1,40 +0,0 @@
{
"Site": {
"Title": "Yavsc Blogs",
"Description": "A collection of blogs about software development, technology, and programming.",
"Keywords": "software development, technology, programming, coding, blogs",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["blogs"],
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"Blog": "blog"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Authentication": "Debug"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:90"
},
"Https": {
"Url": "https://localhost:91",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -1,74 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"AllowedHosts": "*",
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": [
"blogs",
"api"
],
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
"ApiUrl": "https://preprod-api.$DOMAIN",
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN",
"https://preprod-api.$DOMAIN"
],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Admin": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"Blog": "blog",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"
},
"Smtp": {
"Host": "mercure.pschneider.fr",
"Port": 465,
"SenderName": "Paul Schneider",
"SenderEmail": "paul@pschneider.fr",
"UserName": "paul",
"Password": "$SMTP_PASSWORD"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:88"
},
"Https": {
"Url": "https://localhost:89",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -10,10 +10,10 @@ RestartSec=5s
Type=simple Type=simple
User=yavsc User=yavsc
Group=yavsc Group=yavsc
WorkingDirectory=$APPDIR WorkingDirectory=$DESTDIR
ExecStart=$APPDIR/Yavsc.$APP_NAME ExecStart=$DESTDIR/Yavsc.$APP_NAME
Restart=always Restart=always
Environment="HOME=$APPDIR" Environment="HOME=$DESTDIR"
Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY"
Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS" Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS"
Environment="ASPNETCORE_Environment=$ENV" Environment="ASPNETCORE_Environment=$ENV"
@ -29,6 +29,8 @@ Environment="ASPNETCORE_Smtp__Password=\"$ASPNETCORE_Smtp__Password\""
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=$APP_SYSLOG_ID SyslogIdentifier=$APP_SYSLOG_ID
[Install] [Install]

View file

@ -1,61 +0,0 @@
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
#
# Variables substituées par envsubst (allowlist) :
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
# même IP, donc seule la prod (Org) l'active.
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
# préservées (hors allowlist).
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
# ACME ; ne pas servir l'app en HTTP clair.)
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
return 301 https://$host$request_uri;
}
server {
# 1. TCP : HTTPS classique + HTTP/2
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
listen 443 quic$QUIC_OPTS;
listen [::]:443 quic$QUIC_OPTS;
http3 on;
server_name $DOMAIN;
# 3. Certificats Let's Encrypt (Certbot).
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
ssl_protocols TLSv1.3;
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location / {
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 100s;
proxy_buffering off;
}
include server-status.conf;
}

54
contrib/yavsc-org Normal file
View file

@ -0,0 +1,54 @@
server {
if ($host = yavsc.pschneider.fr) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name yavsc.pschneider.fr;
location / {
root /srv/www/yavsc/wwwroot;
dav_methods PUT;
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name yavsc.pschneider.fr;
ssl_certificate /etc/letsencrypt/live/yavsc.pschneider.fr-0001/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/yavsc.pschneider.fr-0001/privkey.pem; # managed by Certbot
# Configure the SignalR Endpoint
location / {
# App server url
proxy_pass http://localhost:3003;
proxy_cache off;
# WebSockets were implemented after http/1.0
proxy_http_version 1.1;
# Configuration for ServerSentEvents
proxy_buffering off;
# Configuration for LongPolling or if your KeepAliveInterval is longer than 60 seconds
proxy_read_timeout 100s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View file

@ -26,7 +26,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: web-runtime target: web-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -53,7 +53,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# # Indispensable pour que Yavsc.Org (IdentityServer8) démarre. # # Indispensable pour que Yavsc.Org (IdentityServer8) démarre.
# # Sans ce montage, 'docker compose up' échoue côté web avec # # Sans ce montage, 'docker compose up' échoue côté web avec
@ -71,7 +73,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: api-runtime target: api-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -88,7 +90,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro
@ -101,7 +105,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: blogs-runtime target: blogs-runtime
args: args:
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 BUILD_ENV_TAG: debian12-dotnet10-android36-v1
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -118,7 +122,9 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
network_mode: host networks:
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro

View file

@ -1,5 +0,0 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

View file

@ -4,31 +4,28 @@
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management --> <!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.2</AvaloniaVersionBase> <AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.2" /> <PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Mapsui" Version="5.1.0" /> <PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" /> <PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" /> <PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
<PackageVersion Include="Material.Avalonia" Version="3.20.0" /> <PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" /> <PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -2,7 +2,6 @@
using Android.App; using Android.App;
using Android.Content; using Android.Content;
using Android.Content.PM; using Android.Content.PM;
using Android.Views;
using AndroidX.Core.Provider; using AndroidX.Core.Provider;
using AndroidX.Emoji2.Text; using AndroidX.Emoji2.Text;
using Avalonia.Android; using Avalonia.Android;
@ -16,9 +15,7 @@ namespace PostIt.Android;
Theme = "@style/MyTheme.NoActionBar", Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon", Icon = "@drawable/icon",
MainLauncher = true, MainLauncher = true,
WindowSoftInputMode = SoftInput.AdjustResize, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)
]
public class MainActivity : AvaloniaMainActivity public class MainActivity : AvaloniaMainActivity
{ {
/// <summary> /// <summary>

View file

@ -11,8 +11,8 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-feat-sign-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">
@ -27,14 +27,4 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
extension's injected HotReload agent (references Microsoft.Maui.*).
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
this file's item groups, so the removal must run in a target. -->
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
<ItemGroup>
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
</ItemGroup>
</Target>
</Project> </Project>

View file

@ -6,8 +6,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-feat-sign-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Browser" /> <PackageReference Include="Avalonia.Browser" />

View file

@ -7,8 +7,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-feat-sign-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>

View file

@ -12,8 +12,6 @@ sealed class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
CLIOptionHandler.HandleArgs(args);
// Short-circuit 2nd-instance launches (OS handing us the // Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window. // postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes // If we let Avalonia initialise, the new MainWindow flashes

View file

@ -22,8 +22,7 @@ public class EstimateEditionPageViewModelTests
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null) private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{ {
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
} }
[Fact] [Fact]
@ -144,8 +143,10 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
var payload = Assert.IsType<EstimateDto>(postCall.Body); Assert.Equal(HttpMethod.Post, api.LastMethod);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(42, payload.CommandId); Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId); Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType); Assert.Equal("Brush", payload.CommandType);
@ -215,8 +216,7 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); var payload = Assert.IsType<EstimateDto>(api.LastBody);
var payload = Assert.IsType<EstimateDto>(postCall.Body);
Assert.Equal(2, payload.Bill.Count); Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost); Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent); Assert.True(vm.HasSent);
@ -230,20 +230,11 @@ public class EstimateEditionPageViewModelTests
public object? LastBody { get; private set; } public object? LastBody { get; private set; }
public Exception? Failure { get; init; } public Exception? Failure { get; init; }
/// <summary>Historique complet des appels (le plus récent en dernier).</summary> public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
{ {
LastMethod = method; LastMethod = method;
LastPath = path; LastPath = path;
LastBody = body; LastBody = body;
Calls.Add((method, path, body));
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (Failure is not null) if (Failure is not null)
{ {
@ -262,7 +253,9 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{ {
Record(method, path, body); LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -272,12 +265,6 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct); => CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -11,13 +11,12 @@ public class EstimateListPageViewModelTests
{ {
var api = new StubEstimateListApi(); var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync(); await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
Assert.Equal("Devis en attente de signature client", vm.Title); Assert.Equal("Mes devis à valider", vm.Title);
Assert.Equal(2, vm.Estimates.Count); Assert.Equal(2, vm.Estimates.Count);
// Most recent first // Most recent first
Assert.Equal(2, vm.Estimates[0].Id); Assert.Equal(2, vm.Estimates[0].Id);
@ -29,13 +28,12 @@ public class EstimateListPageViewModelTests
{ {
var api = new StubEstimateListApi(); var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync(); await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
Assert.Equal("Devis en attente de signature fournisseur", vm.Title); Assert.Equal("Mes devis en attente", vm.Title);
Assert.Equal(2, vm.Estimates.Count); Assert.Equal(2, vm.Estimates.Count);
} }
@ -44,8 +42,7 @@ public class EstimateListPageViewModelTests
{ {
var api = new StubEstimateListApi(); var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync(); await vm.InitializeAsync();
@ -66,8 +63,7 @@ public class EstimateListPageViewModelTests
{ {
var api = new StubEstimateListApi { Empty = true }; var api = new StubEstimateListApi { Empty = true };
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync(); await vm.InitializeAsync();

View file

@ -1,255 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using PostIt.Controls;
using PostIt.ViewModels;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.Tests;
/// <summary>
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
/// signature pads (provider + client) into the right
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
/// is not tested here; the VM is driven through the same
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
/// and the API client through a recording fake
/// <see cref="IYavscApiClient"/>. The test host's
/// <c>TestApp</c> makes <c>GoBackAsync</c> a no-op, so the success paths
/// run to completion without navigation side effects.
/// </summary>
public class EstimateValidationPageViewModelTests
{
private static EstimateDto SampleEstimate(long? commandId = 42) => new()
{
Id = 7,
CommandId = commandId,
CommandType = "Rdv",
Title = "Devis plomberie",
Description = "Réparation",
ClientId = "bob",
OwnerId = "alice",
};
private static void DrawOneStroke(SignaturePadControl pad)
{
pad.AppendPointForTest(100, 200);
pad.AppendPointForTest(300, 400);
pad.SealStrokeForTest();
}
[Fact]
public async Task Validate_calls_accept_with_strokes_and_command_id()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var clientPad = new SignaturePadControl();
vm.Attach(proPad, clientPad);
DrawOneStroke(proPad); // provider signs on their writable pad
await vm.ValidateAsync();
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Contains("front/query/accept", api.LastPath);
Assert.Contains("billingCode=Rdv", api.LastPath);
Assert.Contains("queryId=42", api.LastPath);
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
Assert.NotNull(body.Strokes);
Assert.NotEmpty(body.Strokes);
Assert.Equal(10_000, body.CoordinateMax);
// The provider perspective declares the Pro side, so the
// server stores the signature under the provider's author
// regardless of role inference.
Assert.Equal(SignatureType.Pro, body.SignatureType);
}
[Fact]
public async Task Validate_from_client_perspective_declares_client_side()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
var cliPad = new SignaturePadControl();
vm.Attach(new SignaturePadControl(), cliPad);
DrawOneStroke(cliPad); // client signs on their writable pad
await vm.ValidateAsync();
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
Assert.Equal(SignatureType.Client, body.SignatureType); // Client side
}
[Fact]
public async Task Reject_calls_reject_endpoint_with_no_body()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
await vm.RejectAsync();
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Contains("front/query/reject", api.LastPath);
Assert.Contains("queryId=42", api.LastPath);
Assert.Null(api.LastBody);
}
[Fact]
public async Task Validate_without_signature_warns_and_does_not_call()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads
await vm.ValidateAsync(); // no throw: returns before GoBack
Assert.Null(api.LastMethod);
Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_command_gates_on_signature_and_command()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
vm.Attach(proPad, new SignaturePadControl());
// No CommandId ⇒ cannot validate even after signing.
DrawOneStroke(proPad);
Assert.False(vm.ValidateCommand.CanExecute(null));
var vm2 = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad2 = new SignaturePadControl();
vm2.Attach(proPad2, new SignaturePadControl());
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
DrawOneStroke(proPad2);
Assert.True(vm2.ValidateCommand.CanExecute(null));
}
[Fact]
public void RoleLabel_reflects_perspective()
{
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var pro = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var cli = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase);
Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Only_the_author_pad_is_writable()
{
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var proVm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
proVm.Attach(proPad, cliPad);
Assert.False(proPad.IsReadOnly); // provider writes the Pro pad
Assert.True(cliPad.IsReadOnly);
var cliVm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
var proPad2 = new SignaturePadControl();
var cliPad2 = new SignaturePadControl();
cliVm.Attach(proPad2, cliPad2);
Assert.True(proPad2.IsReadOnly);
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
}
[Fact]
public void Load_existing_signatures_renders_both_sides_in_their_pads()
{
var proStrokes = new[] { 1, 1000, 1000 };
var cliStrokes = new[] { 1, 9000, 9000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes };
estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes };
// Client perspective: both pads show the stored signatures, but
// only the client pad is writable.
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
vm.Attach(proPad, cliPad);
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
Assert.True(vm.HasProSignature);
Assert.True(vm.HasClientSignature);
}
[Fact]
public void Load_existing_pro_signature_renders_in_pro_pad_only()
{
var strokes = new[] { 1, 5000, 5000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 };
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
vm.Attach(proPad, cliPad);
Assert.Equal(strokes, proPad.Snapshot().Strokes);
Assert.True(cliPad.Snapshot().IsEmpty);
Assert.True(vm.HasProSignature);
Assert.False(vm.HasClientSignature);
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? LastMethod { get; private set; }
public string? LastPath { get; private set; }
public object? LastBody { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,157 +0,0 @@
using System.Net.Http;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Verifies <see cref="FrontOfficeApiClient"/> issues the right
/// <c>api/v1/front/query/accept</c> / <c>.../reject</c> requests with
/// the PostIt signature payload, using a recording fake
/// <see cref="IYavscApiClient"/> (no network).
/// </summary>
public class FrontOfficeApiClientTests
{
private static QueryAcceptanceRequestDto SampleBody() => new()
{
Strokes = new[] { 2, 100, 200, 300, 400 },
CoordinateMax = 10_000,
CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc),
};
[Fact]
public async Task Accept_posts_to_accept_endpoint_with_signature_body()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var body = SampleBody();
await client.AcceptQueryAsync("Rdv", 42, body);
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42",
api.LastPath);
Assert.Same(body, api.LastBody);
}
[Fact]
public async Task Reject_posts_to_reject_endpoint_with_no_body()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.RejectQueryAsync("Rdv", 42);
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42",
api.LastPath);
Assert.Null(api.LastBody);
}
[Fact]
public async Task Accept_returns_deserialized_response()
{
var api = new RecordingApi
{
Response = new QueryAcceptanceResponseDto
{
QueryId = 42,
Status = "Accepted",
Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" },
},
};
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody());
Assert.Equal(42, response.QueryId);
Assert.Equal("Accepted", response.Status);
Assert.NotNull(response.Signature);
Assert.Equal("Pro", response.Signature!.Type);
}
[Fact]
public async Task Accept_rejects_invalid_arguments()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await Assert.ThrowsAsync<ArgumentException>(() => client.AcceptQueryAsync("", 42, SampleBody()));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.AcceptQueryAsync("Rdv", 0, SampleBody()));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.AcceptQueryAsync("Rdv", 42, null!));
await Assert.ThrowsAsync<ArgumentException>(() => client.RejectQueryAsync("", 42));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
}
[Fact]
public async Task GetEstimatePdf_gets_estimate_pdf_endpoint()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.GetEstimatePdfAsync(42);
Assert.Equal(HttpMethod.Get, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/42/estimate.pdf",
api.LastPath);
}
[Fact]
public async Task GetEstimateTex_gets_estimate_tex_endpoint()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.GetEstimateTexAsync(42);
Assert.Equal(HttpMethod.Get, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/42/estimate.tex",
api.LastPath);
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? LastMethod { get; private set; }
public string? LastPath { get; private set; }
public object? LastBody { get; private set; }
public QueryAcceptanceResponseDto? Response { get; set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
var response = Response ?? new QueryAcceptanceResponseDto();
return Task.FromResult((T)(object)response);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = null;
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,208 +0,0 @@
using System.Net.Http;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless UI coverage for the "Mes fichiers" page
/// (<see cref="MyFilesPage"/>). The user reported that once a file
/// is listed, "l'item est inerte au click, et ses boutons restent
/// grisés, désactivés" — neither the row nor its action buttons
/// (Télécharger / Supprimer) respond to a click.
///
/// <para>These tests mount a real <see cref="MyFilesPage"/> under
/// the Avalonia headless platform with a stub
/// <see cref="IYavscApiClient"/> that lists one file, then walk the
/// realised visual tree to find the row's action buttons and
/// assert the two things that would silently break and reproduce
/// the "grayed / inert" symptom:</para>
/// <list type="number">
/// <item><description>The <c>Command</c> binding
/// (<c>$parent[vm:MyFilesViewModel].DownloadFileCommand</c>)
/// resolved to a non-null command — a null command leaves the
/// button disabled (grayed) and click-silent.</description></item>
/// <item><description>The command is <b>armed</b>
/// (<c>CanExecute</c> true) for the listed file — an
/// <c>AsyncRelayCommand</c> with
/// <c>AllowConcurrentExecutions=false</c> (the
/// <see cref="CommunityToolkit.Mvvm.Input.RelayCommandAttribute"/>
/// default) disables itself while running; a button stuck
/// "running" is exactly the grayed-and-inert report.</description></item>
/// <item><description>Executing the command actually invokes the
/// file download / delete on the API client — the row
/// "activates" on click instead of being inert.</description></item>
/// </list>
/// <para>The OS save picker is absent under headless, so
/// <c>FileSaveHelpers.SaveAsync</c> throws or cancels and the
/// download command's <c>catch</c> sets an error status — but the
/// API download is awaited <em>before</em> the picker, so the call
/// is recorded regardless. We do not <c>await</c> the command's
/// <c>ExecutionTask</c> to completion, to avoid blocking on the
/// (platform-dependent) picker.</para>
/// </summary>
public class MyFilesPageHeadlessTests
{
private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage()
{
var api = new StubFsApi();
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
var vm = new MyFilesViewModel(fsClient);
var page = new MyFilesPage { DataContext = vm };
return (page, vm, api);
}
/// <summary>
/// Mount the page the way the app does — pushed onto a
/// <see cref="NavigationPage"/> shown in a <see cref="Window"/>
/// (a <see cref="ContentPage"/> realizes its content template via
/// the navigation host, cf. <c>RdvPageHeadlessTests</c>) — after
/// listing a single file by awaiting
/// <see cref="MyFilesViewModel.InitializeAsync"/>. Populating
/// <see cref="MyFilesViewModel.Files"/> before the first layout
/// pass lets the <c>ItemsControl</c> realise its item templates
/// under headless.
/// </summary>
private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync()
{
var (page, vm, api) = CreatePage();
await vm.InitializeAsync();
var nav = new NavigationPage();
nav.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Content = nav };
window.Show();
// Let bindings / the ItemsControl item templates apply.
await Task.Delay(50);
return (page, vm, api);
}
/// <summary>
/// Find the first action button in a file row whose content
/// matches <paramref name="content"/>. The file rows live in an
/// <c>ItemsControl</c> DataTemplate (no <c>x:Name</c>), so we
/// walk the visual tree.
/// </summary>
private static Button? FindRowButton(MyFilesPage page, string content)
=> page.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => b.Content is string s && s == content);
[AvaloniaFact]
public async Task Download_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
// Sanity: the listing populated a file row.
Assert.NotEmpty(vm.Files);
Assert.NotNull(vm.Files[0].Id);
var downloadBtn = FindRowButton(page, "Télécharger");
Assert.NotNull(downloadBtn);
Assert.NotNull(downloadBtn!.Command); // binding resolved (not grayed-via-null)
var param = downloadBtn.CommandParameter;
Assert.True(downloadBtn.Command!.CanExecute(param), // armed (not stuck running)
"Télécharger must be armed (CanExecute true) for a listed file.");
// Act: click → fire the command.
downloadBtn.Command.Execute(param);
// Assert: the row activated — the API download was invoked
// (it is awaited before the headless-absent save picker).
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Get && c.Path == "fs/file/42"))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Télécharger must invoke GET fs/file/{id} on the API client.");
}
[AvaloniaFact]
public async Task Delete_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
Assert.NotEmpty(vm.Files);
var deleteBtn = FindRowButton(page, "Supprimer");
Assert.NotNull(deleteBtn);
Assert.NotNull(deleteBtn!.Command);
var param = deleteBtn.CommandParameter;
Assert.True(deleteBtn.Command!.CanExecute(param),
"Supprimer must be armed (CanExecute true) for a listed file.");
deleteBtn.Command.Execute(param);
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Delete))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Supprimer must invoke DELETE fs/{path} on the API client.");
}
private sealed class StubFsApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
=> Calls.Add((method, path, body));
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (typeof(T) == typeof(UserDirectoryDto))
{
var dir = new UserDirectoryDto
{
UserName = "alice",
Files = new[]
{
new FileEntryDto { Id = 42, Name = "note.txt", Size = 4 }
}
};
return Task.FromResult((T)(object)dir);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -8,13 +8,14 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-feat-sign-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Xamarin.UITest" /> <PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" /> <PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" /> <PackageReference Include="Avalonia.Headless.XUnit" />

View file

@ -45,7 +45,7 @@ public class SessionStatusBannerTests
// déconnecter, Se connecter, Paramètres. If any one is // déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the // missing, the user has no way to trigger the
// corresponding navigation event. // corresponding navigation event.
Assert.True(buttons.Count>=4); Assert.Equal(4, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter"); Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter"); Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres"); Assert.Contains(buttons, b => b.Content as string == "Paramètres");

View file

@ -1,35 +0,0 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>
/// The <see cref="StatusBar"/> surfaces the page's action status
/// (success / warning / error message). The user reported being
/// unable to copy these messages — the status text was a
/// <c>TextBlock</c>, which Avalonia does not make selectable. It is
/// now a <see cref="SelectableTextBlock"/> so the message can be
/// selected and copied (Ctrl+C / context menu). This test pins that:
/// if the message control regresses to a plain <c>TextBlock</c>,
/// selectability is lost and the test fails.
/// </summary>
public class StatusBarTests
{
[AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock()
{
var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
var window = new Window { Content = bar };
window.Show();
var message = bar.GetVisualDescendants()
.OfType<SelectableTextBlock>()
.FirstOrDefault();
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
}

View file

@ -11,6 +11,5 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles> </Application.Styles>
</Application> </Application>

View file

@ -17,7 +17,6 @@ namespace PostIt;
public partial class App : Application public partial class App : Application
{ {
private int _bootStarted; private int _bootStarted;
public string? CliConfigFileSpecification {  get; private set; }
/// <summary> /// <summary>
/// DI container the platform entry points hand to ViewModels so /// DI container the platform entry points hand to ViewModels so
@ -204,9 +203,4 @@ public partial class App : Application
{ {
await View!.NavRoot.PopAsync(); await View!.NavRoot.PopAsync();
} }
internal void UseConfigFileWhenLoading(string configFile)
{
this.CliConfigFileSpecification = configFile;
}
} }

View file

@ -1,33 +0,0 @@
using System;
using System.Collections.Generic;
using Mono.Options;
using PostIt;
using PostIt.ViewModels;
public static class CLIOptionHandler
{
public static void HandleArgs(string[] args)
{
bool showHelp = false;
string configFile = null;
var options = new OptionSet {
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
{ "h|help", "Show this message and exit", v => showHelp = true }
};
List<string> extra = options.Parse(args);
if (showHelp)
{
Console.WriteLine("Usage: app [OPTIONS]+");
options.WriteOptionDescriptions(Console.Out);
return;
}
if (configFile != null)
{
var app = App.Current as App;
app.UseConfigFileWhenLoading(configFile);
}
}
}

View file

@ -46,21 +46,6 @@ public class SignaturePadControl : TemplatedControl
set => SetValue(CaptureAreaProperty, value); set => SetValue(CaptureAreaProperty, value);
} }
/// <summary>
/// When true the control ignores pointer input — it only displays
/// strokes loaded into its buffer (e.g. the other party's
/// signature, which must be shown but not editable). <see cref="Load"/>
/// and <see cref="Clear"/> still work.
/// </summary>
public static readonly StyledProperty<bool> IsReadOnlyProperty =
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
public bool IsReadOnly
{
get => GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
/// <summary> /// <summary>
/// Captured strokes in wire form. Exposed as a read-only view /// Captured strokes in wire form. Exposed as a read-only view
/// over the internal buffer. The buffer only mutates on the UI /// over the internal buffer. The buffer only mutates on the UI
@ -124,7 +109,6 @@ public class SignaturePadControl : TemplatedControl
private void OnCapturePressed(object? sender, PointerPressedEventArgs e) private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
{ {
if (IsReadOnly) return;
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return; if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
e.Pointer.Capture(CaptureArea); e.Pointer.Capture(CaptureArea);
_capturing = true; _capturing = true;
@ -197,38 +181,6 @@ public class SignaturePadControl : TemplatedControl
RedrawRequested?.Invoke(this, EventArgs.Empty); RedrawRequested?.Invoke(this, EventArgs.Empty);
} }
/// <summary>
/// Replace the buffer with the given wire-format strokes — e.g. a
/// signature loaded back from the server — and raise
/// <see cref="RedrawRequested"/> so the host view repaints the ink.
/// Loaded strokes are all sealed (there is no pending stroke).
/// Malformed input (a non-positive length prefix, or a stroke that
/// runs past the end of the array) is truncated at the offending
/// point so the buffer never holds an inconsistent state.
/// </summary>
public void Load(int[] strokes)
{
if (strokes is null) throw new ArgumentNullException(nameof(strokes));
_strokes.Clear();
_pendingPoints = 0;
_capturing = false;
int i = 0;
while (i < strokes.Length)
{
int k = strokes[i];
if (k <= 0) break;
if (i + 1 + 2 * k > strokes.Length) break;
_strokes.Add(k);
for (int p = 0; p < 2 * k; p++)
_strokes.Add(strokes[i + 1 + p]);
i += 1 + 2 * k;
}
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary> /// <summary>
/// Defensive copy of the current buffer wrapped in a /// Defensive copy of the current buffer wrapped in a
/// <see cref="SignaturePadData"/>. Cheap; call only when the /// <see cref="SignaturePadData"/>. Cheap; call only when the

View file

@ -1,28 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PostIt.Controls"
x:Class="PostIt.Controls.SignaturePadView"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<!--
Capture surface. The SignaturePadControl is render-agnostic,
so we host it inside a Border that is the capture area, and
overlay a Canvas for the visual feedback. The code-behind
repaints the canvas on every RedrawRequested. The Border size
is driven by the CaptureWidth / CaptureHeight styled properties.
-->
<Border x:Name="PadFrame"
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="InnerPad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
</UserControl>

View file

@ -1,206 +0,0 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Models;
namespace PostIt.Controls;
/// <summary>
/// Reusable signature-capture surface: wraps the render-agnostic
/// <see cref="SignaturePadControl"/> in a sized <see cref="Border"/>
/// (the hit-test region) with an overlay <see cref="Canvas"/> that
/// the code-behind repaints from the control's stroke buffer. Owns
/// the ink-rendering logic so every page that captures a signature
/// (the dev-only <c>SignaturePage</c>, the estimate validation page,
/// ...) shares one implementation.
///
/// Hosts bind <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>
/// (DIPs, defaults 600×200) to size the surface. View models attach
/// to the inner <see cref="SignaturePad"/> control (exposed here) to
/// read snapshots, drive <c>Clear</c>, and subscribe to
/// <see cref="SignaturePadControl.StrokeCompleted"/> /
/// <see cref="SignaturePadControl.RedrawRequested"/> — the same
/// contract the headless tests use, since a <see cref="UserControl"/>
/// needs its XAML loaded to be drivable.
/// </summary>
public partial class SignaturePadView : UserControl
{
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
// Last size the ink layer was painted at. LayoutUpdated repaints
// only when this changes, so adding/removing polyline children
// (which invalidates layout) does not loop.
private Size _lastPaintedSize;
public static readonly StyledProperty<double> CaptureWidthProperty =
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
public static readonly StyledProperty<double> CaptureHeightProperty =
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureHeight), 200);
public double CaptureWidth
{
get => GetValue(CaptureWidthProperty);
set => SetValue(CaptureWidthProperty, value);
}
public double CaptureHeight
{
get => GetValue(CaptureHeightProperty);
set => SetValue(CaptureHeightProperty, value);
}
/// <summary>
/// The underlying capture control. View models attach to this to
/// read <see cref="SignaturePadControl.Snapshot"/> / call
/// <see cref="SignaturePadControl.Clear"/> and subscribe to its
/// events; this view repaints the ink layer from the same buffer.
/// </summary>
public SignaturePadControl SignaturePad => InnerPad;
public SignaturePadView()
{
InitializeComponent();
// Wire the capture area: the Pad is the control, the
// surrounding Border (PadFrame) is the hit-test region. For an
// inline control with no template, capture wiring happens on
// first measure, which is guaranteed before the user can
// interact, so attaching here is safe.
InnerPad.CaptureArea = PadFrame;
ApplyFrameSize();
InnerPad.RedrawRequested += OnPadRedrawRequested;
// Repaint once layout completes so an ink buffer loaded before
// the first arrange (e.g. an existing signature on reopen)
// still renders without waiting for a pointer event. The
// handler repaints only on a size change to avoid a layout
// loop (child changes invalidate layout → LayoutUpdated).
LayoutUpdated += OnLayoutUpdated;
}
private void OnLayoutUpdated(object? sender, EventArgs e)
{
var size = EffectivePadSize();
if (size != _lastPaintedSize)
Repaint();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == CaptureWidthProperty || change.Property == CaptureHeightProperty)
{
ApplyFrameSize();
Repaint();
}
}
private void ApplyFrameSize()
{
if (PadFrame is null) return;
PadFrame.Width = Math.Max(1, CaptureWidth);
PadFrame.Height = Math.Max(1, CaptureHeight);
}
private void OnPadRedrawRequested(object? sender, EventArgs e) => Repaint();
/// <summary>Defensive copy of the current buffer.</summary>
public SignaturePadData Snapshot() => InnerPad.Snapshot();
/// <summary>Forget every captured stroke.</summary>
public void Clear() => InnerPad.Clear();
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
/// <summary>
/// The effective ink surface size: the arranged bounds when
/// available, otherwise the explicitly sized
/// <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>. The
/// latter covers a repaint triggered before the first layout pass
/// (e.g. loading an existing signature on reopen), when Bounds is
/// still empty.
/// </summary>
private Size EffectivePadSize()
{
var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width;
var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height;
if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h))
return default;
return new Size(w, h);
}
private void Repaint()
{
if (InkLayer is null) return;
var size = EffectivePadSize();
if (size == default) return;
_lastPaintedSize = size;
InkLayer.Children.Clear();
var w = size.Width;
var h = size.Height;
var pending = InnerPad.PendingStroke;
var strokes = InnerPad.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0;
while (i < sealedCount)
{
int k = strokes[i];
if (k <= 0) break;
i++; // skip the length prefix
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
if (pending.Count > 0)
{
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(pending.Count / 2);
for (int p = 0; p < pending.Count; p += 2)
{
int nx = pending[p];
int ny = pending[p + 1];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
}
}

View file

@ -13,10 +13,7 @@
FontWeight="Bold" FontWeight="Bold"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
VerticalAlignment="Top"/> VerticalAlignment="Top"/>
<!-- SelectableTextBlock so the user can select + copy the <TextBlock Text="{Binding Message}"
status message (TextBlock is not selectable in
Avalonia). Keeps Text/TextWrapping/Foreground. -->
<SelectableTextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
TextWrapping="Wrap" TextWrapping="Wrap"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>

View file

@ -1,61 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
namespace PostIt.Helpers;
/// <summary>
/// Saves a downloaded byte payload to a file chosen by the user through
/// the platform save picker. This is the first file-save (as opposed to
/// file-open) helper in PostIt; the open picker is already used for blog
/// attachment and avatar uploads
/// (<see cref="Views.Blogs.BlogsPage"/>, <see cref="Views.Layout.SettingsPage"/>).
///
/// <para>The <c>TopLevel</c> hosting the picker is resolved from the
/// application's root <c>MainView</c> (<c>App.View</c>), which works for
/// both the desktop and single-view lifetimes.</para>
/// </summary>
public static class FileSaveHelpers
{
/// <summary>
/// Prompt the user to choose a destination and write
/// <paramref name="content"/> to it. Returns false when the user
/// cancels the picker; throws on IO/platform errors so the caller
/// can surface them.
/// </summary>
public static async Task<bool> SaveAsync(
string suggestedName,
string defaultExtension,
byte[] content,
CancellationToken ct = default)
{
var view = (Application.Current as App)?.View;
var topLevel = view is null ? null : TopLevel.GetTopLevel(view);
if (topLevel is null)
throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier.");
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Enregistrer sous",
DefaultExtension = defaultExtension,
SuggestedFileName = suggestedName,
FileTypeChoices = new[]
{
new FilePickerFileType($"{defaultExtension.ToUpper()} file")
{
Patterns = new[] { $"*.{defaultExtension}" }
}
}
}).ConfigureAwait(true);
if (file is null)
return false; // user cancelled
await using var stream = await file.OpenWriteAsync().ConfigureAwait(true);
await stream.WriteAsync(content, ct).ConfigureAwait(true);
return true;
}
}

View file

@ -22,7 +22,6 @@ public static class ServiceCollectionHelpers
var api = new YavscApiClient(settings, tokenStore); var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl); var client = new BlogApiClient(api, settings.BlogsApiUrl);
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
@ -32,7 +31,6 @@ public static class ServiceCollectionHelpers
() => settings.Authentication?.Authority); () => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService(); var reverseGeocoding = new NominatimReverseGeocodingService();
@ -53,7 +51,6 @@ public static class ServiceCollectionHelpers
// the navigation stack, each bound to a fresh // the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits. // SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>(); services.AddSingleton<SettingsPage>();
services.AddSingleton<AboutPage>();
services.AddSingleton<HomePage>(); services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>(); services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>(); services.AddSingleton<CirclesPage>();
@ -66,8 +63,6 @@ public static class ServiceCollectionHelpers
services.AddTransient<ProviderOngoingRequestsPage>(); services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>(); services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>(); services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
@ -79,11 +74,8 @@ public static class ServiceCollectionHelpers
services.AddSingleton(activityClient); services.AddSingleton(activityClient);
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient); services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding); services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<AboutPageViewModel>();
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>(); services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>(); services.AddSingleton<CirclesPageViewModel>();

View file

@ -5,8 +5,8 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-feat-sign-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@ -23,7 +23,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="Avalonia.Themes.Fluent" /> <PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" /> <PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport"> <PackageReference Include="AvaloniaUI.DiagnosticsSupport">
@ -36,7 +35,6 @@
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" /> <PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />

View file

@ -49,16 +49,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_bearer = new BearerTokenHandler(this); _bearer = new BearerTokenHandler(this);
Http = new HttpClient(_bearer, disposeHandler: true); Http = new HttpClient(_bearer, disposeHandler: true);
// HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur
// tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443").
// RequestVersionOrLower négocie h3 quand le serveur ET la plateforme
// le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1
// sinon — notamment WASM, où le navigateur n'expose pas QUIC au
// fetch, et Android si libmsquic n'est pas disponible. Tous les
// clients Yavsc.Api.Client partagent cette même instance `Http`.
Http.DefaultRequestVersion = new Version(3, 0);
Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
_tokens = store.Load(); _tokens = store.Load();
} }
@ -488,41 +478,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
return "Avatar mis à jour."; return "Avatar mis à jour.";
} }
/// <summary>
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
/// which JSON-decodes the response, this returns the raw response
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
/// contract as <see cref="SetAvatarAsync"/>.
/// </summary>
public virtual async Task<byte[]> DownloadAsync(
HttpMethod method,
string path,
CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var sendOnce = async () =>
{
using var request = new HttpRequestMessage(method, path);
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await sendOnce().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
_store.Clear(); _store.Clear();

View file

@ -41,7 +41,6 @@ public class ViewLocator : IDataTemplate
{ {
BlogsViewModel => services.GetRequiredService<BlogsPage>(), BlogsViewModel => services.GetRequiredService<BlogsPage>(),
Settings => services.GetRequiredService<SettingsPage>(), Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(), HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(), ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(), CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
@ -56,8 +55,6 @@ public class ViewLocator : IDataTemplate
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(), ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(), EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(), EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -7,7 +7,6 @@ using System.Threading.Tasks;
using Avalonia; using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers; using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;
@ -131,19 +130,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
try try
{ {
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
Activity, Activity,
Performer, Performer,
Form, Form,
_billingClient, _billingClient,
frontClient,
fsClient,
details, details,
IsReadOnly, IsReadOnly);
canAttach: !IsReadOnly);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
@ -178,9 +171,5 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
} }
private static bool IsOngoingStatus(QueryStatus status) private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted => status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
or QueryStatus.Accepted
or QueryStatus.ProAccepted
or QueryStatus.ClientAccepted
or QueryStatus.InProgress;
} }

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -15,18 +14,8 @@ namespace PostIt.ViewModels;
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly BillingApiClient _billingClient; private readonly BillingApiClient _billingClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQueryDetailsDto _details; private readonly BillingQueryDetailsDto _details;
/// <summary>
/// Vrai lorsque l'utilisateur courant est le client de la demande
/// et peut y attacher/détacher des fichiers de son espace perso.
/// Le fournisseur consulte et télécharge les pièces mais n'attache
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
/// </summary>
public bool CanAttach { get; }
public ActivityInfo Activity { get; } public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; } public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; } public CommandFormSummary Form { get; }
@ -62,10 +51,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public override bool CanNavigateNext public override bool CanNavigateNext
{ {
get => false; get => false;
@ -83,28 +68,19 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
ActivityUserDisplayItem performer, ActivityUserDisplayItem performer,
CommandFormSummary form, CommandFormSummary form,
BillingApiClient billingClient, BillingApiClient billingClient,
FrontOfficeApiClient frontClient,
UserFilesApiClient fsClient,
BillingQueryDetailsDto details, BillingQueryDetailsDto details,
bool isReadOnly, bool isReadOnly)
bool canAttach)
{ {
Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form)); Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
_details = details ?? throw new ArgumentNullException(nameof(details)); _details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly; IsReadOnly = isReadOnly;
CanAttach = canAttach;
this.SetInfoStatus("Details de commande charges."); this.SetInfoStatus("Details de commande charges.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
[RelayCommand] [RelayCommand]
private async Task OpenEditorAsync() private async Task OpenEditorAsync()
{ {
@ -155,157 +131,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>Charge les pièces jointes de la demande.</summary>
public async Task InitializeAsync()
{
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task LoadQueryAttachmentsAsync()
{
try
{
var list = await _frontClient
.GetQueryAttachmentsAsync(BillingCode, Id)
.ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
// Non bloquant : les pièces jointes sont un complément.
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
}
}
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché à la demande par référence via
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
/// (<see cref="CanAttach"/>).
/// </summary>
[RelayCommand]
private async Task AddQueryAttachmentAsync()
{
if (!CanAttach) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var billingCode = BillingCode;
var queryId = Id;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
{
if (attachment is null || !CanAttach) return;
IsBusy = true;
try
{
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée de la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Download the estimate linked to this command as a LaTeX source
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
/// resolved server-side from <c>Estimate.CommandId == query Id</c>.
/// </summary>
[RelayCommand]
public async Task DownloadTexAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimateTexAsync(_details.Id),
$"devis-{_details.Id}", "tex").ConfigureAwait(true);
}
/// <summary>
/// Download the estimate linked to this command as a compiled PDF
/// (<c>GET api/v1/front/query/{Id}/estimate.pdf</c>). Requires
/// <c>texi2pdf</c> on the server host.
/// </summary>
[RelayCommand]
public async Task DownloadPdfAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimatePdfAsync(_details.Id),
$"devis-{_details.Id}", "pdf").ConfigureAwait(true);
}
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
{
IsBusy = true;
try
{
var bytes = await fetch().ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
this.SetInfoStatus(saved
? $"Fichier .{ext} enregistré."
: "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private static string EmptyAsPlaceholder(string? value, string placeholder) private static string EmptyAsPlaceholder(string? value, string placeholder)
=> string.IsNullOrWhiteSpace(value) ? placeholder : value; => string.IsNullOrWhiteSpace(value) ? placeholder : value;
@ -341,8 +166,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#E6F7EC", QueryStatus.Accepted => "#E6F7EC",
QueryStatus.ProAccepted => "#E6F7EC",
QueryStatus.ClientAccepted => "#E1F5FE",
QueryStatus.InProgress => "#FFF4D6", QueryStatus.InProgress => "#FFF4D6",
QueryStatus.Rejected => "#FDECEA", QueryStatus.Rejected => "#FDECEA",
QueryStatus.Failed => "#ECEFF1", QueryStatus.Failed => "#ECEFF1",
@ -354,8 +177,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#2E7D32", QueryStatus.Accepted => "#2E7D32",
QueryStatus.ProAccepted => "#2E7D32",
QueryStatus.ClientAccepted => "#1565C0",
QueryStatus.InProgress => "#B26A00", QueryStatus.InProgress => "#B26A00",
QueryStatus.Rejected => "#C62828", QueryStatus.Rejected => "#C62828",
QueryStatus.Failed => "#607D8B", QueryStatus.Failed => "#607D8B",
@ -367,8 +188,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#1B5E20", QueryStatus.Accepted => "#1B5E20",
QueryStatus.ProAccepted => "#1B5E20",
QueryStatus.ClientAccepted => "#0D47A1",
QueryStatus.InProgress => "#7A4A00", QueryStatus.InProgress => "#7A4A00",
QueryStatus.Rejected => "#8E0000", QueryStatus.Rejected => "#8E0000",
QueryStatus.Failed => "#37474F", QueryStatus.Failed => "#37474F",
@ -380,8 +199,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "OK", QueryStatus.Accepted => "OK",
QueryStatus.ProAccepted => "P",
QueryStatus.ClientAccepted => "C",
QueryStatus.InProgress => "~", QueryStatus.InProgress => "~",
QueryStatus.Rejected => "!", QueryStatus.Rejected => "!",
QueryStatus.Failed => "X", QueryStatus.Failed => "X",

View file

@ -25,7 +25,6 @@ namespace PostIt.ViewModels;
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly EstimateApiClient _estimateClient; private readonly EstimateApiClient _estimateClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQuerySummaryDto _query; private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id; public long QueryId => _query.Id;
@ -69,14 +68,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Identifiant du devis, renseigné après l'envoi.</summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))]
public partial long? EstimateId { get; set; }
/// <summary>Pièces jointes rattachées au devis (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public decimal Total => Lines.Sum(line => line.LineTotal); public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}"; public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
@ -95,14 +86,10 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
protected set { _ = value; } protected set { _ = value; }
} }
public EstimateEditionPageViewModel( public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
BillingQuerySummaryDto query,
EstimateApiClient estimateClient,
UserFilesApiClient fsClient)
{ {
_query = query ?? throw new ArgumentNullException(nameof(query)); _query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
EstimateDescription = query.Description ?? string.Empty; EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged; Lines.CollectionChanged += OnLinesCollectionChanged;
@ -111,9 +98,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée."); this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public EstimateEditionPageViewModel() : this(null!, null!, null!) { }
[RelayCommand] [RelayCommand]
private void AddLine() private void AddLine()
{ {
@ -157,11 +141,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true); var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true; HasSent = true;
EstimateId = created.Id;
OnPropertyChanged(nameof(SendLabel)); OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus( this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée."); $"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
await LoadAttachmentsAsync().ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{ {
@ -189,106 +171,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>
/// Charge les pièces jointes rattachées au devis courant
/// (<c>GET estimate/{id}/attachments</c>).
/// </summary>
[RelayCommand]
private async Task LoadAttachmentsAsync()
{
if (EstimateId is null || _estimateClient is null) return;
try
{
var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}");
}
}
private bool CanAddAttachment() => EstimateId is not null && !IsBusy;
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché au devis par référence via
/// <c>POST estimate/{id}/attachments</c>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanAddAttachment))]
private async Task AddAttachmentAsync()
{
if (EstimateId is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var estimateId = EstimateId.Value;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true);
await LoadAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée au devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachFileAsync(AttachmentDto? attachment)
{
if (attachment is null || EstimateId is null) return;
IsBusy = true;
try
{
await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée du devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
internal EstimateDto BuildPayload() internal EstimateDto BuildPayload()
{ {
return new EstimateDto return new EstimateDto

View file

@ -5,7 +5,6 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
@ -19,10 +18,10 @@ namespace PostIt.ViewModels;
/// </summary> /// </summary>
public enum EstimateListPerspective public enum EstimateListPerspective
{ {
/// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary> /// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
Client, Client,
/// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary> /// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
Provider, Provider,
} }
@ -33,15 +32,14 @@ public enum EstimateListPerspective
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly EstimateApiClient _estimateClient; private readonly EstimateApiClient _estimateClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly EstimateListPerspective _perspective; private readonly EstimateListPerspective _perspective;
private List<EstimateDto> _allEstimates = new(); private List<EstimateDto> _allEstimates = new();
public EstimateListPerspective Perspective => _perspective; public EstimateListPerspective Perspective => _perspective;
public string Title => _perspective == EstimateListPerspective.Client public string Title => _perspective == EstimateListPerspective.Client
? "Devis en attente de signature client" ? "Mes devis à valider"
: "Devis en attente de signature fournisseur"; : "Mes devis en attente";
public string Subtitle => _perspective == EstimateListPerspective.Client public string Subtitle => _perspective == EstimateListPerspective.Client
? "Point de vue client" ? "Point de vue client"
@ -85,14 +83,10 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
protected set { _ = value; } protected set { _ = value; }
} }
public EstimateListPageViewModel( public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective)
EstimateApiClient estimateClient,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{ {
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_perspective = perspective; _perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
} }
public Task InitializeAsync() => RefreshAsync(); public Task InitializeAsync() => RefreshAsync();
@ -159,25 +153,4 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
private static bool ContainsInsensitive(string? value, string query) private static bool ContainsInsensitive(string? value, string query)
=> !string.IsNullOrWhiteSpace(value) => !string.IsNullOrWhiteSpace(value)
&& value.Contains(query, StringComparison.OrdinalIgnoreCase); && value.Contains(query, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Open the estimate validation (accept/reject with signature)
/// page for a specific estimate. The perspective the list was
/// opened in is forwarded as the actor-role UI hint; the server
/// independently enforces the real authorization.
/// </summary>
[RelayCommand]
private async Task OpenEstimateAsync(EstimateDto? estimate)
{
if (estimate is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateValidationPageViewModel(estimate, _perspective, _frontClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
} }

View file

@ -1,399 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Controls;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels;
/// <summary>
/// Validation (accept / reject) of a single estimate, with optional
/// signatures. Backs the estimate validation page opened from
/// <see cref="EstimateListPageViewModel"/> for either perspective.
///
/// <para>Two signatures are shown, each in its own pad: the provider's
/// (Pro) and the client's (Client). Both are rendered from the estimate
/// payload when present, so reopening a partly-signed estimate shows
/// what was already captured. Only the pad matching the current
/// <see cref="EstimateListPerspective"/> is writable — the server
/// (<c>POST api/v1/front/query/accept</c>) independently enforces the
/// same rule (it resolves the caller's role from the authenticated user
/// vs the query's <c>PerformerId</c>/<c>ClientId</c>), and the UI marks
/// the other pad read-only. The billing code sent to the server is the
/// estimate's <see cref="EstimateDto.CommandType"/> and the query id is
/// its <see cref="EstimateDto.CommandId"/>.</para>
/// </summary>
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateDto _estimate;
private readonly EstimateListPerspective _perspective;
private readonly FrontOfficeApiClient _frontClient;
private SignaturePadControl? _proPad;
private SignaturePadControl? _clientPad;
public string Title => _estimate.Title;
public string Description => _estimate.Description;
public long EstimateId => _estimate.Id;
public string CommandType => _estimate.CommandType;
public IReadOnlyList<EstimateLineDto> Bill => _estimate.Bill;
public DateTime ProviderValidationDate => _estimate.ProviderValidationDate;
public DateTime ClientValidationDate => _estimate.ClientValidationDate;
public string RoleLabel => _perspective == EstimateListPerspective.Provider
? "Vous validez en tant que prestataire"
: "Vous validez en tant que client";
/// <summary>
/// True when the current user may sign the provider's signature
/// (i.e. they opened the list as the provider). The other party's
/// pad is read-only.
/// </summary>
public bool CanWritePro => _perspective == EstimateListPerspective.Provider;
/// <summary>
/// True when the current user may sign the client's signature
/// (i.e. they opened the list as the client).
/// </summary>
public bool CanWriteClient => _perspective == EstimateListPerspective.Client;
/// <summary>
/// Human-readable summary of who has already validated this
/// estimate, shown above the signature pads.
/// </summary>
public string ValidationStateText
{
get
{
var parts = new List<string>();
if (ProviderValidationDate != default)
parts.Add($"Prestataire validé le {ProviderValidationDate.ToLocalTime():g}");
if (ClientValidationDate != default)
parts.Add($"Client validé le {ClientValidationDate.ToLocalTime():g}");
return parts.Count == 0 ? "Devis en attente de validation." : string.Join(" · ", parts);
}
}
/// <summary>
/// True when the estimate has a linked query to accept/reject.
/// Estimates without a <see cref="EstimateDto.CommandId"/> are
/// templates and cannot be validated through the front-office
/// endpoint.
/// </summary>
public bool HasCommand => _estimate.CommandId is not null;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Stroke/point counts for the writable pad.</summary>
[ObservableProperty]
public partial int StrokeCount { get; set; }
[ObservableProperty]
public partial int PointCount { get; set; }
/// <summary>True when the writable pad holds a signature.</summary>
[ObservableProperty]
public partial bool HasSignature { get; set; }
[ObservableProperty]
public partial bool HasProSignature { get; set; }
[ObservableProperty]
public partial bool HasClientSignature { get; set; }
[ObservableProperty]
public partial string ProSignatureHint { get; set; } = string.Empty;
[ObservableProperty]
public partial string ClientSignatureHint { get; set; } = string.Empty;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateValidationPageViewModel(
EstimateDto estimate,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{
_estimate = estimate ?? throw new ArgumentNullException(nameof(estimate));
_perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
}
/// <summary>
/// Bind the page's two signature pads (the inner controls of the
/// page's <see cref="SignaturePadView"/>s): the provider's pad and
/// the client's pad. Called from the view's code-behind once the
/// view is in the visual tree. Marks the non-author pad read-only,
/// then renders any signatures already carried on the estimate
/// payload.
/// </summary>
public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad)
{
if (proPad is null) throw new ArgumentNullException(nameof(proPad));
if (clientPad is null) throw new ArgumentNullException(nameof(clientPad));
Detach();
_proPad = proPad;
_clientPad = clientPad;
_proPad.IsReadOnly = !CanWritePro;
_clientPad.IsReadOnly = !CanWriteClient;
_proPad.RedrawRequested += OnPadRedraw;
_clientPad.RedrawRequested += OnPadRedraw;
LoadExistingSignatures();
RefreshFromPad();
}
public void Detach()
{
if (_proPad is not null)
_proPad.RedrawRequested -= OnPadRedraw;
if (_clientPad is not null)
_clientPad.RedrawRequested -= OnPadRedraw;
_proPad = null;
_clientPad = null;
}
private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad();
/// <summary>The pad the current user may sign in.</summary>
private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad;
/// <summary>
/// Render the signatures already stored for this estimate back into
/// their pads. The strokes are carried on the estimate payload as
/// two dedicated slots — <see cref="EstimateDto.SignaturePro"/> and
/// <see cref="EstimateDto.SignatureClient"/> — so each pad loads
/// its own side directly, in the same wire format PostIt captured.
/// No network round-trip.
/// </summary>
public void LoadExistingSignatures()
{
LoadSide(_proPad, _estimate.SignaturePro);
LoadSide(_clientPad, _estimate.SignatureClient);
RefreshFromPad();
}
private static void LoadSide(SignaturePadControl? pad, EstimateSignatureDto? sig)
{
if (pad is null || sig is null) return;
if (sig.Strokes is { Length: > 0 } strokes)
pad.Load(strokes);
}
private void RefreshFromPad()
{
HasProSignature = _proPad?.Snapshot().IsEmpty == false;
HasClientSignature = _clientPad?.Snapshot().IsEmpty == false;
ProSignatureHint = CanWritePro
? (HasProSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que prestataire")
: (HasProSignature ? "Signature du prestataire (lecture seule)" : "En attente de signature du prestataire");
ClientSignatureHint = CanWriteClient
? (HasClientSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que client")
: (HasClientSignature ? "Signature du client (lecture seule)" : "En attente de signature du client");
var pad = WritablePad;
if (pad is null) return;
var snap = pad.Snapshot();
StrokeCount = snap.StrokeCount;
PointCount = snap.PointCount;
HasSignature = !snap.IsEmpty;
}
partial void OnIsBusyChanged(bool value)
{
ValidateCommand.NotifyCanExecuteChanged();
RejectCommand.NotifyCanExecuteChanged();
}
partial void OnHasSignatureChanged(bool value)
=> ValidateCommand.NotifyCanExecuteChanged();
private bool CanValidate => !IsBusy && HasSignature && HasCommand;
private bool CanReject => !IsBusy && HasCommand;
[RelayCommand(CanExecute = nameof(CanValidate))]
public async Task ValidateAsync()
{
if (_estimate.CommandId is not long queryId)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé.");
return;
}
var pad = WritablePad;
if (pad is null || pad.Snapshot().IsEmpty)
{
this.SetWarningStatus("Veuillez signer avant de valider.");
return;
}
var snap = pad.Snapshot();
IsBusy = true;
try
{
var body = new QueryAcceptanceRequestDto
{
Strokes = snap.Strokes,
CoordinateMax = SignaturePadData.CoordinateMax,
CapturedAtUtc = DateTime.UtcNow,
// Declare which side the caller is signing as, so the
// server stores the signature under the right author
// (Pro/Client) even when one user is both parties.
SignatureType = CanWritePro ? SignatureType.Pro : SignatureType.Client,
};
await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body)
.ConfigureAwait(true);
this.SetInfoStatus("Devis validé.");
await GoBackAsync().ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Vous n'êtes pas partie à ce devis.");
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.BadRequest)
{
this.SetWarningStatus($"Validation refusée: {ex.Message}");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanReject))]
public async Task RejectAsync()
{
if (_estimate.CommandId is not long queryId)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être refusé.");
return;
}
IsBusy = true;
try
{
await _frontClient.RejectQueryAsync(_estimate.CommandType, queryId)
.ConfigureAwait(true);
this.SetInfoStatus("Devis refusé.");
await GoBackAsync().ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Vous n'êtes pas partie à ce devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public void ClearSignature()
{
WritablePad?.Clear();
RefreshFromPad();
}
/// <summary>
/// Download the estimate as a LaTeX source document. The server
/// renders the Estimate_tex template to a <c>.tex</c> byte stream
/// (<c>GET api/v1/front/query/{CommandId}/estimate.tex</c>); the
/// user picks a destination via the platform save picker.
/// </summary>
[RelayCommand]
public async Task DownloadTexAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value),
$"devis-{EstimateId}", "tex").ConfigureAwait(true);
}
/// <summary>
/// Download the estimate as a compiled PDF
/// (<c>GET api/v1/front/query/{CommandId}/estimate.pdf</c>).
/// Requires <c>texi2pdf</c> on the server host; a failure is
/// surfaced as a status message.
/// </summary>
[RelayCommand]
public async Task DownloadPdfAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value),
$"devis-{EstimateId}", "pdf").ConfigureAwait(true);
}
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
{
if (_estimate.CommandId is not long)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande.");
return;
}
IsBusy = true;
try
{
var bytes = await fetch().ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
this.SetInfoStatus(saved
? $"Fichier .{ext} enregistré."
: "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true);
private static async Task GoBackAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.GoBackAsync().ConfigureAwait(true);
}
}

View file

@ -8,7 +8,6 @@ using System.Threading.Tasks;
using Avalonia; using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers; using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
@ -154,21 +153,15 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id) .GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
.ConfigureAwait(true); .ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery); var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
activity, activity,
performer, performer,
form, form,
_billingClient, _billingClient,
frontClient,
fsClient,
details, details,
isReadOnly: false, isReadOnly: false);
canAttach: false);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (Exception ex) catch (Exception ex)
@ -247,13 +240,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
throw new InvalidOperationException("Application PostIt indisponible."); throw new InvalidOperationException("Application PostIt indisponible.");
} }
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>(); var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
@ -357,13 +344,11 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
=> status switch => status switch
{ {
QueryStatus.InProgress => 0, QueryStatus.InProgress => 0,
QueryStatus.ClientAccepted => 1, QueryStatus.Accepted => 1,
QueryStatus.ProAccepted => 2, QueryStatus.Inserted => 2,
QueryStatus.Accepted => 2, QueryStatus.Success => 3,
QueryStatus.Inserted => 3, QueryStatus.Rejected => 4,
QueryStatus.Success => 4, QueryStatus.Failed => 5,
QueryStatus.Rejected => 5,
QueryStatus.Failed => 6,
_ => 99, _ => 99,
}; };

View file

@ -11,7 +11,6 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Files; using Yavsc.Abstract.Files;
using PostIt.Helpers; using PostIt.Helpers;
using AvaloniaEdit.Document;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -37,19 +36,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// <summary>Editor buffer for the post body. Same pattern as /// <summary>Editor buffer for the post body. Same pattern as
/// <see cref="DraftTitle"/>.</summary> /// <see cref="DraftTitle"/>.</summary>
public string DraftArticle
{
get => DraftArticleDocument.Text;
set {
OnPropertyChanging(nameof(DraftArticle));
DraftArticleDocument = new TextDocument(value);
this.OnPropertyChanged(nameof(DraftArticle));
}
}
[ObservableProperty] [ObservableProperty]
public partial TextDocument DraftArticleDocument {get; set;} public partial string DraftArticle { get; set; }
/// <summary>Editor buffer for the post's publication state. /// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the /// Reflects the server-side <c>IsPublished</c> flag (the
@ -393,7 +381,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
WindowTitle = "PostIt"; WindowTitle = "PostIt";
DraftTitle = string.Empty; DraftTitle = string.Empty;
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftIsPublished = false; DraftIsPublished = false;
IsLoaded = false; IsLoaded = false;
// Production path: DI injects the canonical Settings singleton // Production path: DI injects the canonical Settings singleton
@ -477,6 +464,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// enable as soon as the user has typed a non-whitespace // enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected. // title, regardless of whether a post is selected.
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
private async Task RefreshPostsAsync() private async Task RefreshPostsAsync()

View file

@ -1,77 +0,0 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace PostIt.ViewModels;
/// <summary>
/// ViewModel de la page « À propos de PostIt ». Page statique :
/// version de l'application, description, licence et liens vers le
/// dépôt Forgejo et son suivi de tickets.
/// </summary>
public partial class AboutPageViewModel : ViewModelBase
{
public const string RepositoryUrl = "https://forgejo.pschneider.fr/notazof/yavsc";
public const string IssuesUrl = RepositoryUrl + "/issues";
public AboutPageViewModel()
{
Version = ReadVersion();
}
public string Version { get; }
public string Description { get; } =
"PostIt est le client de la plateforme Yavsc : blog, devis, " +
"demandes de prestations et fichiers personnels.";
public string License { get; } =
"Licence WTFPL — Do What The Fuck You Want To Public License, version 2";
public override bool CanNavigateNext
{
get => false;
protected set => throw new NotImplementedException();
}
public override bool CanNavigatePrevious
{
get => true;
protected set => throw new NotImplementedException();
}
[RelayCommand]
private Task OpenRepository() => OpenUrlAsync(RepositoryUrl);
[RelayCommand]
private Task OpenIssues() => OpenUrlAsync(IssuesUrl);
private static async Task OpenUrlAsync(string url)
{
var app = (App)App.Current!;
var topLevel = TopLevel.GetTopLevel(app.View);
if (topLevel is not null)
{
await topLevel.Launcher.LaunchUriAsync(new Uri(url)).ConfigureAwait(true);
}
}
private static string ReadVersion()
{
var assembly = typeof(AboutPageViewModel).Assembly;
var info = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrEmpty(info))
{
// "1.1.0-estimate.1+364.Branch.x.Sha.y" → "1.1.0-estimate.1"
var plus = info.IndexOf('+');
return plus > 0 ? info[..plus] : info;
}
return assembly.GetName().Version?.ToString() ?? "inconnue";
}
}

View file

@ -35,14 +35,12 @@ public class HomePageViewModel : ViewModelBase
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client)); OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
} }
public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; } public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
private async Task OpenActivitiesAsync() private async Task OpenActivitiesAsync()
{ {
@ -92,36 +90,7 @@ public class HomePageViewModel : ViewModelBase
throw new InvalidOperationException("Client devis indisponible."); throw new InvalidOperationException("Client devis indisponible.");
} }
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>(); var vm = new EstimateListPageViewModel(estimateClient, perspective);
if (frontClient is null)
{
throw new InvalidOperationException("Client front-office indisponible.");
}
var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
/// servie par le host Blogs via <c>api/v1/fs</c>.
/// </summary>
private async Task OpenMyFilesAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var fsClient = app.ServiceProvider?.GetRequiredService<Yavsc.Api.Client.UserFilesApiClient>();
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new MyFilesViewModel(fsClient);
await vm.InitializeAsync(); await vm.InitializeAsync();
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }

View file

@ -143,11 +143,4 @@ public partial class SessionStatusViewModel : ViewModelBase
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true); await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
} }
[RelayCommand]
internal async Task OpenAbout()
{
var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<AboutPageViewModel>()).ConfigureAwait(true);
}
} }

View file

@ -1,229 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Gestion de l'espace de stockage personnel (« My Files ») :
/// navigation dans l'arbre, téléversement, suppression, téléchargement.
/// Sert aussi de **sélecteur** pour attacher un fichier à un devis
/// (fournisseur) ou à une demande (client) par référence : lorsque
/// <see cref="IsPickerMode"/> est vrai, la sélection d'un fichier invoque
/// <see cref="_onFilePicked"/> avec l'identifiant <see cref="FileEntryDto.Id"/>
/// puis revient à la page précédente.
/// </summary>
public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly UserFilesApiClient _fs;
private readonly Action<long>? _onFilePicked;
/// <summary>
/// Sous-répertoire courant, relatif à la racine de l'espace perso
/// (chaîne vide à la racine). Servi tel quel à <c>GET/POST fs/{subdir}</c>.
/// </summary>
[ObservableProperty]
public partial string CurrentPath { get; set; } = string.Empty;
public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath)
? "Mes fichiers"
: $"Mes fichiers / {CurrentPath}";
public bool IsPickerMode => _onFilePicked is not null;
public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath);
[ObservableProperty]
public partial ObservableCollection<FileEntryDto> Files { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<DirEntryDto> SubDirectories { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public MyFilesViewModel(UserFilesApiClient fs, Action<long>? onFilePicked = null)
{
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
_onFilePicked = onFilePicked;
}
/// <summary>Constructeur pour le designer Avalonia.</summary>
public MyFilesViewModel() : this(null!) { }
public async Task InitializeAsync()
{
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task RefreshAsync()
{
IsBusy = true;
try
{
var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true);
Files.Clear();
SubDirectories.Clear();
if (dir?.Files is not null)
foreach (var f in dir.Files) Files.Add(f);
if (dir?.SubDirectories is not null)
foreach (var d in dir.SubDirectories) SubDirectories.Add(d);
this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s).");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur de listage : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task OpenDirAsync(DirEntryDto? dir)
{
if (dir is null) return;
CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task UpAsync()
{
if (!CanGoUp) return;
CurrentPath = ParentPath(CurrentPath);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
/// <summary>Téléversement déclenché par le code-behind (picker OS).</summary>
public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection<UploadFile> uploads)
{
if (uploads is null || uploads.Count == 0) return;
IsBusy = true;
try
{
var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true);
this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s).");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléversement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteFileAsync(FileEntryDto? file)
{
if (file is null) return;
IsBusy = true;
try
{
await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true);
this.SetInfoStatus($"Fichier supprimé.");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de la suppression : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null) return;
IsBusy = true;
try
{
var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task PickFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null)
{
this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord.");
return;
}
var callback = _onFilePicked;
if (callback is null)
{
// Mode gestion : le téléchargement est l'action par défaut.
await DownloadFileAsync(file).ConfigureAwait(true);
return;
}
callback(file.Id.Value);
await GoBackAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task BackAsync()
{
await GoBackAsync().ConfigureAwait(true);
}
private static async Task GoBackAsync()
{
var app = (App?)Application.Current;
if (app is null) throw new InvalidOperationException("Application PostIt indisponible.");
await app.GoBackAsync().ConfigureAwait(true);
}
private static string CombinePath(string current, string name)
{
if (string.IsNullOrWhiteSpace(current)) return name;
return $"{current}/{name}";
}
private static string ParentPath(string current)
{
if (string.IsNullOrWhiteSpace(current)) return string.Empty;
var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx];
}
}

View file

@ -259,13 +259,25 @@ public partial class Settings : ViewModelBase
return; return;
} }
} }
if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson
var app = App.Current as App; && !string.IsNullOrWhiteSpace(envJson))
if (app!=null)
if (app.CliConfigFileSpecification!=null)
{ {
SettingsFileFullName = app.CliConfigFileSpecification; Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable.");
FileInfo configByEnvFileInfo = new FileInfo(envJson);
if (!configByEnvFileInfo.Exists)
{
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
} }
string json = File.ReadAllText(configByEnvFileInfo.FullName);
ApplyJson(json, "POSTIT_SETTINGS_JSON");
SettingsFileFullName = configByEnvFileInfo.FullName;
Loaded = true;
return;
}
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
if (SettingsFileFullName is not null) if (SettingsFileFullName is not null)
{ {
@ -275,18 +287,10 @@ public partial class Settings : ViewModelBase
else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath
&& !string.IsNullOrWhiteSpace(envPath)) && !string.IsNullOrWhiteSpace(envPath))
{ {
FileInfo configByEnvFileInfo = new FileInfo(envPath); SettingsFileFullName = envPath;
if (!configByEnvFileInfo.Exists)
{
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
}
SettingsFileFullName = configByEnvFileInfo.FullName;
} }
else else
{ {
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
SettingsFileFullName = Path.Combine(configDir, "postit-settings.json"); SettingsFileFullName = Path.Combine(configDir, "postit-settings.json");
} }
@ -532,7 +536,6 @@ public partial class Settings : ViewModelBase
/// </summary> /// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
} }

View file

@ -85,12 +85,10 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo
} }
/// <summary> /// <summary>
/// Bind the inner <see cref="SignaturePadControl"/> of the page's /// Bind a freshly-constructed (or re-templated) control to this
/// <see cref="SignaturePadView"/> to this VM. Called from the /// VM. Called from the view's code-behind once the control has
/// page's code-behind once the view is in the visual tree (so the /// been added to the visual tree and its template applied (so
/// control's <see cref="SignaturePadControl.CaptureArea"/> is /// <see cref="SignaturePadControl.CaptureArea"/> is wired).
/// wired). Attaching to the control directly (rather than the
/// view) keeps the same contract the headless tests use.
/// </summary> /// </summary>
public void Attach(SignaturePadControl control) public void Attach(SignaturePadControl control)
{ {

View file

@ -1,7 +1,6 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.BillingQueryDetailsPage" x:Class="PostIt.Views.BillingQueryDetailsPage"
x:DataType="vm:BillingQueryDetailsPageViewModel" x:DataType="vm:BillingQueryDetailsPageViewModel"
@ -15,7 +14,7 @@
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" /> <TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel> </StackPanel>
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,8,Auto,*"> <Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,*">
<Button Grid.Column="0" <Button Grid.Column="0"
Content="Retour" Content="Retour"
Command="{Binding BackCommand}" /> Command="{Binding BackCommand}" />
@ -23,12 +22,6 @@
Content="Ouvrir en edition" Content="Ouvrir en edition"
Command="{Binding OpenEditorCommand}" Command="{Binding OpenEditorCommand}"
IsVisible="{Binding CanEdit}" /> IsVisible="{Binding CanEdit}" />
<Button Grid.Column="4"
Content="Télécharger PDF"
Command="{Binding DownloadPdfCommand}" />
<Button Grid.Column="6"
Content="Télécharger TeX"
Command="{Binding DownloadTexCommand}" />
</Grid> </Grid>
<ScrollViewer Grid.Row="2" Margin="0,12,0,12"> <ScrollViewer Grid.Row="2" Margin="0,12,0,12">
@ -81,53 +74,6 @@
</Grid> </Grid>
</Border> </Border>
<!-- Pièces jointes (par référence à l'espace perso du client) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddQueryAttachmentCommand}"
IsVisible="{Binding CanAttach}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DownloadQueryAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DetachQueryFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).CanAttach}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Expander Header="Metadonnees techniques" <Expander Header="Metadonnees techniques"
IsExpanded="False"> IsExpanded="False">
<Border BorderThickness="1" <Border BorderThickness="1"

View file

@ -1,7 +1,6 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage" x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel" x:DataType="vm:EstimateEditionPageViewModel"
@ -134,52 +133,6 @@
</Grid> </Grid>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Pièces jointes (par référence à l'espace perso du fournisseur) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
IsVisible="{Binding HasSent}">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddAttachmentCommand}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DownloadAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DetachFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

View file

@ -29,7 +29,6 @@
CornerRadius="8" CornerRadius="8"
Padding="10" Padding="10"
Margin="0,0,0,8"> Margin="0,0,0,8">
<StackPanel Spacing="8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto"> <Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0" <TextBlock Grid.Row="0"
Grid.Column="0" Grid.Column="0"
@ -58,11 +57,6 @@
Opacity="0.65" Opacity="0.65"
HorizontalAlignment="Right" /> HorizontalAlignment="Right" />
</Grid> </Grid>
<Button Content="Ouvrir"
HorizontalAlignment="Right"
Command="{Binding $parent[ContentPage].((vm:EstimateListPageViewModel)DataContext).OpenEstimateCommand}"
CommandParameter="{Binding}" />
</StackPanel>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>

View file

@ -1,6 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using PostIt.ViewModels;
namespace PostIt.Views; namespace PostIt.Views;
@ -15,19 +14,4 @@ public partial class EstimateListPage : ContentPage
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
// Re-fetch the list when returning to it (e.g. after validating an
// estimate on the validation page). Avalonia's Page.OnNavigatedTo
// fires on the page popped back to with NavigationType.Pop; the list
// VM is retained on the nav stack while away, so this refresh picks
// up the server-side filter change that removed the signed estimate.
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (e.NavigationType == NavigationType.Pop
&& DataContext is EstimateListPageViewModel vm)
{
_ = vm.RefreshAsync();
}
}
} }

View file

@ -1,146 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:controls="using:PostIt.Controls"
xmlns:dto="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateValidationPage"
x:DataType="vm:EstimateValidationPageViewModel"
Header="Validation du devis">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,8">
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Text="{Binding RoleLabel}" Opacity="0.75" />
<TextBlock Text="{Binding ValidationStateText}"
FontSize="11"
Opacity="0.75"
TextWrapping="Wrap" />
</StackPanel>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="10">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,12,*">
<TextBlock Grid.Column="0"
Text="N°"
FontWeight="SemiBold" />
<TextBlock Grid.Column="2"
Text="{Binding EstimateId}" />
</Grid>
<Grid ColumnDefinitions="Auto,12,*">
<TextBlock Grid.Column="0"
Text="Type"
FontWeight="SemiBold" />
<TextBlock Grid.Column="2"
Text="{Binding CommandType}" />
</Grid>
<TextBlock Text="{Binding Description}"
TextWrapping="Wrap"
Opacity="0.85" />
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Lignes du devis" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Bill}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="dto:EstimateLineDto">
<Grid ColumnDefinitions="*,8,60,8,120" Margin="0,0,0,4">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
TextWrapping="Wrap" />
<TextBlock Grid.Column="2"
Text="{Binding Count}"
HorizontalAlignment="Right" />
<StackPanel Grid.Column="4"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="4">
<TextBlock Text="{Binding UnitaryCost, StringFormat='{}{0:N2}'}" />
<TextBlock Text="{Binding Currency}" Opacity="0.7" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Signature du prestataire" FontWeight="SemiBold" />
<TextBlock Text="{Binding ProSignatureHint}"
FontSize="11"
Opacity="0.7" />
<controls:SignaturePadView x:Name="ProPad"
CaptureWidth="560"
CaptureHeight="160" />
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Signature du client" FontWeight="SemiBold" />
<TextBlock Text="{Binding ClientSignatureHint}"
FontSize="11"
Opacity="0.7" />
<controls:SignaturePadView x:Name="ClientPad"
CaptureWidth="560"
CaptureHeight="160" />
</StackPanel>
</Border>
<TextBlock FontSize="11"
Foreground="DarkSlateGray">
<Run Text="Votre signature : traits " />
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
<Run Text=" · points " />
<Run Text="{Binding PointCount, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
<Button Content="Effacer"
Command="{Binding ClearSignatureCommand}" />
<Button Content="Valider"
Command="{Binding ValidateCommand}" />
<Button Content="Refuser"
Command="{Binding RejectCommand}" />
<Button Content="Télécharger PDF"
Command="{Binding DownloadPdfCommand}" />
<Button Content="Télécharger TeX"
Command="{Binding DownloadTexCommand}" />
<Button Content="Retour"
Command="{Binding BackCommand}" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<ProgressBar Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
<postitControls:StatusBar DataContext="{Binding ActionStatus}" />
</StackPanel>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,32 +0,0 @@
using Avalonia.Controls;
using PostIt.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class EstimateValidationPage : ContentPage
{
private EstimateValidationPageViewModel? _vm;
public EstimateValidationPage()
{
InitializeComponent();
DataContextChanged += (_, _) => RebindViewModel(DataContext as EstimateValidationPageViewModel);
Unloaded += (_, _) => _vm?.Detach();
}
private void RebindViewModel(EstimateValidationPageViewModel? vm)
{
if (_vm is not null)
{
_vm.Detach();
}
_vm = vm;
if (_vm is null || ProPad is null || ClientPad is null) return;
_vm.Attach(ProPad.SignaturePad, ClientPad.SignaturePad);
}
}

View file

@ -1,101 +1,86 @@
<ContentPage <ContentPage xmlns="https://github.com/avaloniaui"
x:Class="PostIt.Views.Blogs.BlogsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:md="using:MarkdownViewer.Core.Controls"
xmlns:models="using:Yavsc.Blogspot"
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Blogs"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
HorizontalAlignment="Stretch" xmlns:postitControls="using:PostIt.Controls"
VerticalAlignment="Stretch" xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views.Blogs"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:md="using:MarkdownViewer.Core.Controls"
mc:Ignorable="d"
x:Class="PostIt.Views.Blogs.BlogsPage"
x:DataType="vm:BlogsViewModel" x:DataType="vm:BlogsViewModel"
mc:Ignorable="d"> HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Design.DataContext> <Design.DataContext>
<vm:BlogsViewModel /> <vm:BlogsViewModel />
</Design.DataContext> </Design.DataContext>
<ScrollViewer HorizontalScrollBarVisibility="Disabled" <ScrollViewer
Margin="5,5,10,5" HorizontalAlignment="Stretch" BringIntoViewOnFocusChange="False" VerticalScrollBarVisibility="Visible"
VerticalAlignment="Stretch"> HorizontalScrollBarVisibility="Disabled"
<StackPanel> Padding="5"
<WrapPanel x:Name="BlogsHeaderPanel"
Margin="5" Margin="5"
Orientation="Horizontal"> >
<TextBlock
FontSize="20"
FontWeight="SemiBold" <StackPanel HorizontalAlignment="Stretch">
Text="Blog Posts" /> <StackPanel Orientation="Horizontal" Spacing="10" Margin="5">
<TextBox <TextBlock Text="Blog Posts" FontSize="20" FontWeight="SemiBold" />
HorizontalAlignment="Right" <TextBox HorizontalAlignment="Right"
PlaceholderText="Filter by title or author..." Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Filter by title or author..." />
Text="{Binding SearchText, Mode=TwoWay}" /> </StackPanel>
</WrapPanel> <CommandBar IsDynamicOverflowEnabled="True"
<CommandBar x:Name="BlogsCommandBar" HorizontalAlignment="Right" >
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
<CommandBar.PrimaryCommands> <CommandBar.PrimaryCommands>
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh"> <CommandBarButton Label="Refresh" Command="{Binding RefreshAsync}">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource RefreshIcon}" /> <PathIcon Data="{StaticResource RefreshIcon}" />
</CommandBarButton.Icon> </CommandBarButton.Icon>
</CommandBarButton> </CommandBarButton>
<CommandBarButton <CommandBarButton x:Name="SaveButton" Label="Save" Command="{Binding SaveAsync}">
x:Name="SaveButton"
Command="{Binding SaveAsync}"
Label="Save">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource SaveIcon}" /> <PathIcon Data="{StaticResource SaveIcon}" />
</CommandBarButton.Icon> </CommandBarButton.Icon>
</CommandBarButton> </CommandBarButton>
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete"> <CommandBarButton Label="Delete" Command="{Binding DeleteAsync}">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource DeleteIcon}" /> <PathIcon Data="{StaticResource DeleteIcon}" />
</CommandBarButton.Icon> </CommandBarButton.Icon>
</CommandBarButton> </CommandBarButton>
<CommandBarButton <CommandBarButton Label="Ajouter fichiers" Click="AddAttachment_Click"
x:Name="AddAttachmentButton" x:Name="AddAttachmentButton">
Click="AddAttachment_Click"
Label="Ajouter fichiers">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource AddIcon}" /> <PathIcon Data="{StaticResource AddIcon}" />
</CommandBarButton.Icon> </CommandBarButton.Icon>
</CommandBarButton> </CommandBarButton>
<CommandBarButton <CommandBarButton x:Name="ManageAclButton"
x:Name="ManageAclButton" Label="ACL"
Command="{Binding ManageAclAsync}" Command="{Binding ManageAclAsync}">
Label="ACL">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource AclIcon}" /> <PathIcon Data="{StaticResource AclIcon}" />
</CommandBarButton.Icon> </CommandBarButton.Icon>
</CommandBarButton> </CommandBarButton>
<CommandBarButton <CommandBarButton x:Name="OpenCirclesButton"
x:Name="OpenCirclesButton" Label="Mes cercles" Command="{Binding OpenCirclesAsync}">
Command="{Binding OpenCirclesAsync}"
Label="Mes cercles">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource CircleIcon}" /> <PathIcon Data="{StaticResource CircleIcon}" />
</CommandBarButton.Icon> </CommandBarButton.Icon>
</CommandBarButton> </CommandBarButton>
<CommandBarToggleButton <CommandBarToggleButton Label="Publié"
Command="{Binding TogglePublishCommand}" IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Label="Publié">
<CommandBarToggleButton.Icon> <CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource PublishedIcon}" /> <PathIcon Data="{StaticResource PublishedIcon}" />
</CommandBarToggleButton.Icon> </CommandBarToggleButton.Icon>
</CommandBarToggleButton> </CommandBarToggleButton>
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Markdown Viewer"> <CommandBarToggleButton Label="Markdown Viewer"
IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}">
<CommandBarToggleButton.Icon> <CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource MarkdownIcon}" /> <PathIcon Data="{StaticResource MarkdownIcon}" />
</CommandBarToggleButton.Icon> </CommandBarToggleButton.Icon>
</CommandBarToggleButton> </CommandBarToggleButton>
<CommandBarButton <CommandBarButton x:Name="OpenSignatureDevButton"
x:Name="OpenSignatureDevButton" Label="[DEV] Signature" Command="{Binding OpenSignatureDevAsync}">
Command="{Binding OpenSignatureDevAsync}"
Label="[DEV] Signature">
<CommandBarButton.Icon> <CommandBarButton.Icon>
<PathIcon Data="{StaticResource SignIcon}" /> <PathIcon Data="{StaticResource SignIcon}" />
</CommandBarButton.Icon> </CommandBarButton.Icon>
@ -103,63 +88,36 @@
</CommandBar.PrimaryCommands> </CommandBar.PrimaryCommands>
</CommandBar> </CommandBar>
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid"> <Grid Margin="12" RowSpacing="12">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Border <Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
Grid.Row="1" <ListBox ItemsSource="{Binding FilteredPosts}"
Padding="8" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
BorderBrush="Gray"
BorderThickness="1">
<ListBox
x:Name="PostsListBox"
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4" > <StackPanel Spacing="4" >
<TextBlock FontWeight="SemiBold" Text="{Binding Title}" /> <TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock <TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
FontSize="10" <TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
Foreground="Gray"
Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" />
<TextBlock
FontSize="10"
Foreground="DarkSlateGray"
Text="{Binding AuthorId}" />
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
</Border> </Border>
<Border <md:MarkdownViewer Grid.Row="2" Name="MarkdownViewer"
Grid.Row="2" IsVisible="{Binding IsMarkdownViewerVisible}"
Padding="8" MarkdownText="{Binding DraftArticle}" />
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="Gray"
BorderThickness="1"
IsVisible="{Binding IsMarkdownViewerVisible}">
<md:MarkdownViewer Name="MarkdownViewer" MarkdownText="{Binding DraftArticle}" />
</Border>
<Border <Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8"
Grid.Row="2"
Padding="8"
BorderBrush="Gray"
BorderThickness="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
IsVisible="{Binding !IsMarkdownViewerVisible}"> IsVisible="{Binding !IsMarkdownViewerVisible}">
<Grid <Grid RowSpacing="10"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" VerticalAlignment="Stretch">
RowSpacing="10">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
@ -170,43 +128,18 @@
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock <TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
Grid.Row="0" <TextBox x:Name="DraftTitleTextBox"
FontWeight="SemiBold" Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
Text="Post detail" /> <TextBox Grid.Row="2" PlaceholderText="Write something here !"
<TextBox Text="{Binding DraftArticle, Mode=TwoWay}"
x:Name="DraftTitleTextBox" MinHeight="320"
Grid.Row="1"
PlaceholderText="Title"
Text="{Binding DraftTitle, Mode=TwoWay}" />
<AvaloniaEdit:TextEditor
x:Name="DraftArticleTextEditor"
Grid.Row="2"
VerticalAlignment="Stretch"
Document="{Binding DraftArticleDocument, Mode=TwoWay}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="True"
BorderThickness="1"
BorderBrush="LightGray"
CornerRadius="5"
Padding="5,5,5,15"
HorizontalScrollBarVisibility="Disabled"
TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default"
/>
<Border x:Name="AttachmentsBorder"
Grid.Row="3"
Padding="8"
BorderBrush="LightGray"
BorderThickness="1"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch" />
<Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock FontWeight="SemiBold" Text="Pièces jointes" /> <TextBlock Text="Pièces jointes" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding DraftAttachments}"> <ItemsControl ItemsSource="{Binding DraftAttachments}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
@ -217,7 +150,8 @@
</StackPanel> </StackPanel>
</Border> </Border>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" /> <postitControls:StatusBar Grid.Row="4"
DataContext="{Binding ActionStatus}" />
</Grid> </Grid>
</Border> </Border>

View file

@ -1,75 +1,19 @@
using System;
using System.IO; using System.IO;
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Controls.Platform;
using Avalonia.Input;
using System;
using Avalonia;
namespace PostIt.Views.Blogs; namespace PostIt.Views.Blogs;
public partial class BlogsPage : ContentPage public partial class BlogsPage : ContentPage
{ {
private IInputPane _inputPane;
public BlogsPage() public BlogsPage()
{ {
InitializeComponent(); InitializeComponent();
} }
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
// Récupère l'accès au TopLevel de la fenêtre/application
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel != null)
{
_inputPane = topLevel.InputPane;
if (_inputPane != null)
{
// S'abonne aux changements d'état du clavier virtuel
_inputPane.StateChanged += OnInputPaneStateChanged;
// Applique la configuration initiale si le clavier est déjà présent
UpdateLayoutForKeyboard(_inputPane.OccludedRect.Height);
}
}
}
protected override void OnUnloaded(RoutedEventArgs e)
{
if (_inputPane != null)
{
_inputPane.StateChanged -= OnInputPaneStateChanged;
}
base.OnUnloaded(e);
}
private void UpdateLayoutForKeyboard(double keyboardHeight)
{
double visibleHeight = this.Bounds.Height - keyboardHeight;
if (keyboardHeight > 0)
{
DraftArticleTextEditor.MaxHeight = visibleHeight;
}
else
{
DraftArticleTextEditor.MaxHeight = double.PositiveInfinity;
}
}
private void OnInputPaneStateChanged(object? sender, InputPaneStateEventArgs e)
{
// e.EndRect contient le rectangle final du clavier virtuel
// e.EndRect.Height vous donne sa hauteur exacte en pixels indépendants du périphérique (pixels logiques)
UpdateLayoutForKeyboard(e.EndRect.Height);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{ {
base.OnApplyTemplate(e); base.OnApplyTemplate(e);

View file

@ -15,9 +15,6 @@ using PostIt.Services;
using PostIt.ViewModels.Commands; using PostIt.ViewModels.Commands;
namespace PostIt.Views.Commands; namespace PostIt.Views.Commands;
using Mapsui.Tiling; // Ensure you have this namespace
public partial class RdvPage : ContentPage public partial class RdvPage : ContentPage
{ {
@ -47,16 +44,6 @@ public partial class RdvPage : ContentPage
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService(); _reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
InitializeComponent(); InitializeComponent();
InitializeMap(); InitializeMap();
// ... inside your Map setup logic ...
string version = GetType().Assembly.GetName().Version?.ToString() ?? "1.1";
// 1. Create a unique user-agent string for your app (e.g., "AppName/Version (ContactEmail)")
string myCustomUserAgent = $"PostIt/{version} (paul@pschneider.fr)";
// 2. Pass your custom user agent into the OpenStreetMap helper
var osmLayer = OpenStreetMap.CreateTileLayer(myCustomUserAgent);
// 3. add your newly identified layer
_locationMap.Map.Layers.Add(osmLayer);
} }
private void InitializeComponent() private void InitializeComponent()

View file

@ -1,38 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.AboutPage"
x:DataType="vm:AboutPageViewModel"
Header="À propos">
<Design.DataContext>
<vm:AboutPageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12"
Margin="16">
<TextBlock Text="PostIt"
FontSize="28"
FontWeight="Bold"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Version, StringFormat='Version {0}'}"
HorizontalAlignment="Center"
Opacity="0.8"/>
<TextBlock Text="{Binding Description}"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="420"/>
<TextBlock Text="{Binding License}"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="420"
Opacity="0.8"
FontStyle="Italic"/>
<Button Content="Dépôt du projet"
Command="{Binding OpenRepositoryCommand}"
HorizontalAlignment="Center"/>
<Button Content="Signaler un problème"
Command="{Binding OpenIssuesCommand}"
HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage>

View file

@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class AboutPage : ContentPage
{
public AboutPage()
{
InitializeComponent();
}
}

View file

@ -26,17 +26,13 @@
Command="{Binding OpenProviderRequests}" Command="{Binding OpenProviderRequests}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Devis en attente de signature client" <Button Content="Mes devis à valider"
Command="{Binding OpenClientEstimates}" Command="{Binding OpenClientEstimates}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Devis en attente de signature fournisseur" <Button Content="Mes devis en attente"
Command="{Binding OpenProviderEstimates}" Command="{Binding OpenProviderEstimates}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes fichiers"
Command="{Binding OpenMyFiles}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel> </StackPanel>
</ContentPage> </ContentPage>

View file

@ -3,11 +3,6 @@
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.SessionStatusBanner" x:Class="PostIt.Views.SessionStatusBanner"
x:DataType="vm:SessionStatusViewModel"> x:DataType="vm:SessionStatusViewModel">
<Design.DataContext>
<vm:SessionStatusViewModel />
</Design.DataContext>
<Border DockPanel.Dock="Top" <Border DockPanel.Dock="Top"
BorderBrush="#B0BEC5" BorderBrush="#B0BEC5"
BorderThickness="0,0,0,1" BorderThickness="0,0,0,1"
@ -28,9 +23,6 @@
<Button Content="Paramètres" <Button Content="Paramètres"
Command="{Binding OpenSettingsCommand}" Command="{Binding OpenSettingsCommand}"
DockPanel.Dock="Right"/> DockPanel.Dock="Right"/>
<Button Content="À propos"
Command="{Binding OpenAboutCommand}"
DockPanel.Dock="Right"/>
</DockPanel> </DockPanel>
</Border> </Border>
</UserControl> </UserControl>

View file

@ -1,90 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.MyFilesPage"
x:DataType="vm:MyFilesViewModel"
Header="Mes fichiers">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding PathLabel}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="Espace de stockage personnel. Téléversez vos fichiers puis attachez-les à un devis ou une demande."
Opacity="0.7"
TextWrapping="Wrap" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
<Button Grid.Column="2"
Content="Remonter"
Command="{Binding UpCommand}"
IsEnabled="{Binding CanGoUp}" />
<Button Grid.Column="4"
Content="Téléverser…"
Click="Upload_Click"
HorizontalAlignment="Right" />
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<ItemsControl ItemsSource="{Binding SubDirectories}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:DirEntryDto">
<Button Content="📁 {Binding Name}"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).OpenDirCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Left"
Margin="0,0,0,4" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:FileEntryDto">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
VerticalAlignment="Center"
TextWrapping="NoWrap" />
<Button Grid.Column="1"
Content="Joindre"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).PickFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).IsPickerMode}" />
<Button Grid.Column="3"
Content="Télécharger"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).DownloadFileCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="5"
Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).DeleteFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -1,58 +0,0 @@
using System;
using System.IO;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Interactivity;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class MyFilesPage : ContentPage
{
public MyFilesPage()
{
InitializeComponent();
}
private async void Upload_Click(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not MyFilesViewModel vm)
return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir des fichiers à téléverser dans Mes fichiers",
AllowMultiple = true,
});
if (files.Count == 0)
return;
var uploads = new List<Yavsc.Api.Client.UploadFile>(files.Count);
foreach (var file in files)
{
await using var stream = await file.OpenReadAsync();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
uploads.Add(new Yavsc.Api.Client.UploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
}
await vm.UploadAsync(uploads);
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
}

View file

@ -29,15 +29,29 @@
FontWeight="Bold" /> FontWeight="Bold" />
<!-- <!--
Capture surface. SignaturePadView owns the SignaturePadControl, Capture surface. The SignaturePadControl is render-
the capture-area Border, the ink overlay canvas, and the agnostic, so we host it inside a fixed-size Border
repaint logic; the page just sizes it from the VM and reads that is the PART_CaptureArea, and overlay a Canvas
the captured data through the view's API. for the visual feedback. The view's code-behind
repaints the canvas on every RedrawRequested.
--> -->
<controls:SignaturePadView Grid.Row="1" <Border Grid.Row="1"
x:Name="Pad" x:Name="PadFrame"
CaptureWidth="{Binding Width}" Width="{Binding Width}"
CaptureHeight="{Binding Height}" /> Height="{Binding Height}"
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="Pad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
<StackPanel Grid.Row="2" <StackPanel Grid.Row="2"
Orientation="Horizontal" Orientation="Horizontal"

View file

@ -1,4 +1,9 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Controls; using PostIt.Controls;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -6,15 +11,26 @@ namespace PostIt.Views;
public partial class SignaturePage : ContentPage public partial class SignaturePage : ContentPage
{ {
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
private SignaturePageViewModel? _vm; private SignaturePageViewModel? _vm;
private SignaturePadControl? _control;
public SignaturePage() public SignaturePage()
{ {
InitializeComponent(); InitializeComponent();
// SignaturePadView owns the capture area wiring and the ink // Wire the capture area: the Pad itself is the control, the
// repaint; the page only forwards the view to the VM so it can // surrounding Border (PadFrame) is the hit-test region. We
// read snapshots / subscribe to stroke events. // set CaptureArea once the control's template has been
// applied — for an inline control with no template, that
// happens on first measure, which is guaranteed before
// the user can interact, so attaching here is safe.
_control = Pad;
_control.CaptureArea = PadFrame;
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel); DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
} }
@ -23,12 +39,82 @@ public partial class SignaturePage : ContentPage
if (_vm is not null) if (_vm is not null)
{ {
_vm.Detach(); _vm.Detach();
_control!.RedrawRequested -= OnRedrawRequested;
} }
_vm = vm; _vm = vm;
if (_vm is null || Pad is null) return; if (_vm is null || _control is null) return;
_vm.Attach(Pad.SignaturePad); _vm.Attach(_control);
_control.RedrawRequested += OnRedrawRequested;
Repaint();
}
private void OnRedrawRequested(object? sender, EventArgs e) => Repaint();
private void Repaint()
{
if (_control is null || InkLayer is null) return;
InkLayer.Children.Clear();
var w = PadFrame.Bounds.Width;
var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return;
var pending = _control.PendingStroke;
var strokes = _control.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0;
while (i < sealedCount)
{
int k = strokes[i];
if (k <= 0) break;
i++; // skip the length prefix
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
if (pending.Count > 0)
{
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(pending.Count / 2);
for (int p = 0; p < pending.Count; p += 2)
{
int nx = pending[p];
int ny = pending[p + 1];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"Authentication": { "Authentication": {
"Authority": "https://yavsc.pschneider.fr", "Authority": "https://localhost:5001",
"ClientId": "postit", "ClientId": "postit",
"Scopes": [ "Scopes": [
"blogs", "blogs",
@ -9,8 +9,13 @@
"RedirectUri": "postit://callback" "RedirectUri": "postit://callback"
}, },
"DarkMode": true, "DarkMode": true,
"BlogsApiUrl": "https://blogs.pschneider.fr/api/v1/", "BlogsApiUrl": "https://localhost:5003/api/v1/",
"ApiUrl": "https://api.pschneider.fr/api/v1/", "ApiUrl": "https://localhost:5005/api/v1/",
"SearchText": "", "SearchText": "",
"ProviderOngoingRequestsSortOption": "" "ProviderOngoingRequestsSortOption": "",
"Loaded": true,
"IsDirty": true,
"CanNavigateNext": false,
"CanNavigatePrevious": true,
"SaveCommand": {}
} }

View file

@ -15,23 +15,5 @@ namespace Yavsc.Blogspot
// BlogPost entity implements this getter by mapping its // BlogPost entity implements this getter by mapping its
// ApplicationUser navigation to a BlogPostAuthorDto. // ApplicationUser navigation to a BlogPostAuthorDto.
BlogPostAuthorDto? Author { get; } BlogPostAuthorDto? Author { get; }
/// <summary>
/// Whether this post is published. The server derives this
/// from the existence of a row in <c>BlogSpotPublication</c>
/// and serves it on the wire so clients (PostIt) can render
/// the current publication state without a follow-up request.
///
/// <para>This MUST be declared on the interface — not just on
/// the concrete <c>BlogPost</c> entity / <c>BlogPostDto</c> —
/// because the blog API's list endpoint returns
/// <c>IEnumerable&lt;IBlogPost&gt;</c>, and the blog API host
/// serialises with System.Text.Json, which emits only the
/// members of the declared (interface) type. When
/// <c>IsPublished</c> lived only on the concrete classes, STJ
/// dropped it from the list payload and PostIt read
/// <c>false</c> for every post, including published ones.</para>
/// </summary>
bool IsPublished { get; set; }
} }
} }

View file

@ -8,17 +8,7 @@
public DateTime CreationTime { get; set; } public DateTime CreationTime { get; set; }
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
/// <summary>
/// Identifiant de la ligne <c>UploadedFile</c> associée, quand le
/// fichier a été téléversé via <c>api/v1/fs</c> (donc référencé en
/// base). <c>null</c> pour un fichier présent sur disque sans ligne
/// de métadonnées (fichier antérieur à la fonctionnalité, ou non
/// encore backfillé). C'est cette valeur que le client lourd
/// (PostIt) envoie pour attacher le fichier à un devis/demande.
/// </summary>
public long? Id { get; set; }
} }

View file

@ -5,28 +5,15 @@ namespace Yavsc
/// should be associated to any /// should be associated to any
/// client user query to a provider user or /// client user query to a provider user or
/// other external entity. /// other external entity.
///
/// <para>
/// Acceptance is split by party: <see cref="ProAccepted"/> when the
/// provider accepts the client's request (and produces/submits the
/// estimate), <see cref="ClientAccepted"/> when the client accepts
/// the provider's estimate. <see cref="Accepted"/> is the legacy
/// single-value kept for rows written before the split; new writes
/// use the party-specific values. Its integer value (2) is stable
/// so existing rows still deserialize.
/// </para>
/// </summary> /// </summary>
public enum QueryStatus: int public enum QueryStatus: int
{ {
Inserted = 0, Inserted,
Rejected = 1, Rejected,
Accepted = 2, Accepted,
InProgress = 3, InProgress,
// final states // final states
Failed = 4, Failed,
Success = 5, Success
// party-specific acceptance (see summary)
ProAccepted = 6,
ClientAccepted = 7,
} }
} }

View file

@ -11,7 +11,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-feat-sign-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -39,39 +39,6 @@ public sealed class EstimateDto
public string CommandType { get; set; } = string.Empty; public string CommandType { get; set; } = string.Empty;
public DateTime ProviderValidationDate { get; set; } public DateTime ProviderValidationDate { get; set; }
public DateTime ClientValidationDate { get; set; } public DateTime ClientValidationDate { get; set; }
/// <summary>
/// The provider's signature on this estimate, or null when the
/// provider has not signed. Carries the PostIt wire-format
/// <see cref="EstimateSignatureDto.Strokes"/> so the pad can
/// repaint a previously drawn signature on reopen without a second
/// round-trip.
/// </summary>
public EstimateSignatureDto? SignaturePro { get; set; }
/// <summary>
/// The client's signature on this estimate, or null when the
/// client has not signed.
/// </summary>
public EstimateSignatureDto? SignatureClient { get; set; }
}
/// <summary>
/// One stored signature on an estimate, mirroring the JSON shape of
/// the server-side <c>Yavsc.Models.Billing.Signature</c> entity. The
/// <see cref="Strokes"/> are the same PostIt wire-format payload that
/// was captured and are loaded back into the pad unchanged. The side
/// (Pro/Client) is implicit in which property of
/// <see cref="EstimateDto"/> carries this DTO, so no <c>Type</c>
/// field is needed.
/// </summary>
public sealed class EstimateSignatureDto
{
public long Id { get; set; }
public long EstimateId { get; set; }
public int CoordinateMax { get; set; }
public DateTime CapturedAtUtc { get; set; }
public int[]? Strokes { get; set; }
} }
/// <summary> /// <summary>

View file

@ -1,56 +0,0 @@
using System;
using Yavsc.Models.Billing;
namespace Yavsc.Api.Client;
/// <summary>
/// Body of <c>POST api/v1/front/query/accept</c> and
/// <c>.../query/reject</c>, mirroring the server-side
/// <c>Yavsc.ViewModels.FrontOffice.QueryAcceptanceRequest</c>.
/// <see cref="Strokes"/> is optional: when non-empty on accept it is
/// the PostIt wire-format signature captured with the acceptance; null
/// or empty means no signature (the only valid value on reject).
/// </summary>
public sealed class QueryAcceptanceRequestDto
{
public int[]? Strokes { get; set; }
public int CoordinateMax { get; set; } = 10_000;
public DateTime? CapturedAtUtc { get; set; }
/// <summary>
/// Which signature is being submitted:
/// <see cref="SignatureType.Pro"/> (the provider) or
/// <see cref="SignatureType.Client"/>. The PostIt client sets this
/// from the perspective the page was opened in (provider signs Pro,
/// client signs Client), so the server stores the signature under
/// the right author even when one user is both parties. Null lets
/// the server infer the role from the caller's identity.
/// </summary>
public SignatureType? SignatureType { get; set; }
}
/// <summary>
/// Response of <c>POST .../query/accept</c> / <c>.../query/reject</c>
/// (<c>Ok(new { queryId, status, signature = ... })</c>). The
/// <see cref="Signature"/> ref is non-null only when a signature was
/// staged on accept.
/// </summary>
public sealed class QueryAcceptanceResponseDto
{
public long QueryId { get; set; }
public string? Status { get; set; }
public QuerySignatureRefDto? Signature { get; set; }
}
public sealed class QuerySignatureRefDto
{
public long Id { get; set; }
public long EstimateId { get; set; }
public string? Type { get; set; }
}

View file

@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
namespace Yavsc.Api.Client;
/// <summary>
/// Représentation d'un répertoire de l'espace de stockage personnel,
/// renvoyée par <c>GET api/v1/fs/{subdir}</c> (host Blogs). Ne dépend pas
/// des helpers serveur : c'est un pur DTO de désérialisation.
/// </summary>
public sealed class UserDirectoryDto
{
public string? UserName { get; set; }
public string? SubPath { get; set; }
public FileEntryDto[] Files { get; set; } = Array.Empty<FileEntryDto>();
public DirEntryDto[] SubDirectories { get; set; } = Array.Empty<DirEntryDto>();
}
public sealed class FileEntryDto
{
/// <summary>Identifiant de la ligne UploadedFile, pour l'attachement par référence.</summary>
public long? Id { get; set; }
public string? Name { get; set; }
public long Size { get; set; }
public DateTime LastModified { get; set; }
}
public sealed class DirEntryDto
{
public string? Name { get; set; }
}
/// <summary>
/// Métadonnées d'une pièce jointe (fichier de l'espace perso rattaché à
/// un devis ou une demande), renvoyées par les endpoints d'attachement.
/// </summary>
public sealed class AttachmentDto
{
public long FileId { get; set; }
/// <summary>Chemin relatif dans l'espace perso du propriétaire.</summary>
public string? Path { get; set; }
public long Size { get; set; }
public string? ContentType { get; set; }
public string? OwnerId { get; set; }
}
/// <summary>
/// Métadonnées d'un fichier reçu après un téléversement multipart vers
/// <c>POST api/v1/fs/{subdir}</c>. Porte notamment l'identifiant
/// <see cref="FileId"/> de la ligne UploadedFile créée côté serveur.
/// </summary>
public sealed class FileReceivedDto
{
public long? FileId { get; set; }
public string? FileName { get; set; }
public long Length { get; set; }
public string? ContentType { get; set; }
public bool QuotaOffense { get; set; }
public bool Overridden { get; set; }
}
/// <summary>
/// Contenu d'un fichier à téléverser (nom, octets, type MIME), à l'image
/// de <see cref="BlogUploadFile"/> mais pour l'espace perso générique.
/// </summary>
public sealed record UploadFile(string FileName, byte[] Content, string? ContentType);

View file

@ -113,41 +113,6 @@ public sealed class EstimateApiClient
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct); return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
} }
/// <summary>
/// Liste les pièces jointes (fichiers de l'espace perso du fournisseur)
/// rattachées au devis <paramref name="id"/> par référence
/// (<c>GET estimate/{id}/attachments</c>).
/// </summary>
public Task<List<AttachmentDto>> GetAttachmentsAsync(long id, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}/attachments"), ct: ct);
}
/// <summary>
/// Attache un fichier de l'espace perso du fournisseur au devis
/// <paramref name="id"/> par référence (<c>POST estimate/{id}/attachments</c>).
/// Réservé au fournisseur côté serveur.
/// </summary>
public Task AttachFileAsync(long id, long fileId, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.CallAsync(HttpMethod.Post, Absolute($"{PathPrefix}/{id}/attachments"),
body: new { fileId }, ct: ct);
}
/// <summary>
/// Détache un fichier du devis <paramref name="id"/>
/// (<c>DELETE estimate/{id}/attachments/{fileId}</c>). Réservé au fournisseur.
/// </summary>
public Task DetachFileAsync(long id, long fileId, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}/attachments/{fileId}"), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress() private Uri ResolveBusinessBaseAddress()

View file

@ -1,177 +0,0 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for the front-office query routes
/// (<c>api/v1/front/query/accept</c> / <c>.../reject</c>, served by
/// <see cref="Yavsc.ApiControllers.FrontOfficeApiController"/>). Same
/// DTO↔path mapper shape as <see cref="EstimateApiClient"/>: transport
/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated
/// to <see cref="IYavscApiClient"/>.
///
/// The server resolves the caller's role (provider vs client vs admin)
/// from the authenticated user compared to the query's
/// <c>PerformerId</c>/<c>ClientId</c>; the client does not declare a
/// role. <paramref name="billingCode"/> is the estimate's
/// <see cref="EstimateDto.CommandType"/> and <paramref name="queryId"/>
/// is its <see cref="EstimateDto.CommandId"/>.
/// </summary>
public sealed class FrontOfficeApiClient
{
private const string PathPrefix = "front";
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public FrontOfficeApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_ = ResolveBusinessBaseAddress();
}
/// <summary>
/// Accept (validate) the query <paramref name="queryId"/> under
/// billing code <paramref name="billingCode"/>. When
/// <paramref name="body"/> carries a non-empty signature, the
/// server stages it on the query's linked estimate and stamps the
/// matching validation date.
/// </summary>
public async Task<QueryAcceptanceResponseDto> AcceptQueryAsync(
string billingCode,
long queryId,
QueryAcceptanceRequestDto body,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0)
throw new ArgumentOutOfRangeException(nameof(queryId));
if (body is null)
throw new ArgumentNullException(nameof(body));
var path = $"{PathPrefix}/query/accept?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
HttpMethod.Post,
Absolute(path),
body: body,
ct: ct).ConfigureAwait(false);
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
}
/// <summary>
/// Reject the query <paramref name="queryId"/> under billing code
/// <paramref name="billingCode"/>. A signature is not meaningful on
/// a rejection; the server refuses one with 400, so this sends no
/// body.
/// </summary>
public async Task<QueryAcceptanceResponseDto> RejectQueryAsync(
string billingCode,
long queryId,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0)
throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/reject?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
HttpMethod.Post,
Absolute(path),
body: null,
ct: ct).ConfigureAwait(false);
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
}
/// <summary>
/// Download the estimate linked to query <paramref name="queryId"/>
/// as a LaTeX (<c>.tex</c>) source document
/// (<c>GET api/v1/front/query/{queryId}/estimate.tex</c>). Returns
/// the raw TeX bytes (UTF-8 text).
/// </summary>
public Task<byte[]> GetEstimateTexAsync(long queryId, CancellationToken ct = default)
{
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/{queryId}/estimate.tex";
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
}
/// <summary>
/// Download the estimate linked to query <paramref name="queryId"/>
/// as a compiled PDF document
/// (<c>GET api/v1/front/query/{queryId}/estimate.pdf</c>). Returns
/// the raw PDF bytes. Requires <c>texi2pdf</c> on the server host;
/// a non-2xx response throws <see cref="HttpRequestException"/>.
/// </summary>
public Task<byte[]> GetEstimatePdfAsync(long queryId, CancellationToken ct = default)
{
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/{queryId}/estimate.pdf";
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
}
/// <summary>
/// Liste les pièces jointes (fichiers de l'espace perso du client)
/// rattachées à la demande <paramref name="queryId"/> par référence
/// (<c>GET front/query/{queryId}/attachments</c>).
/// </summary>
public Task<List<AttachmentDto>> GetQueryAttachmentsAsync(string billingCode, long queryId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute(path), ct: ct);
}
/// <summary>
/// Attache un fichier de l'espace perso du client à sa demande
/// (<c>POST front/query/{queryId}/attachments</c>). Réservé au client.
/// </summary>
public Task AttachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync(HttpMethod.Post, Absolute(path), body: new { fileId }, ct: ct);
}
/// <summary>
/// Détache un fichier de la demande
/// (<c>DELETE front/query/{queryId}/attachments/{fileId}</c>). Réservé au client.
/// </summary>
public Task DetachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
var path = $"{PathPrefix}/query/{queryId}/attachments/{fileId}?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync(HttpMethod.Delete, Absolute(path), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -73,23 +73,4 @@ public interface IYavscApiClient : IAsyncDisposable
string path, string path,
Func<HttpContent> contentFactory, Func<HttpContent> contentFactory,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary>
/// Download a binary resource (PDF, TeX, image, …). Unlike
/// <see cref="CallAsync{T}(HttpMethod,string,object?,CancellationToken)"/>,
/// which JSON-decodes the response, this returns the raw response
/// bytes. The same Bearer-token attach + 401-refresh-retry-once
/// contract applies.
/// </summary>
/// <remarks>
/// A default interface implementation is provided so that test
/// fakes that do not exercise binary download still compile without
/// declaring the member; the real <see cref="YavscApiClient"/>
/// overrides it with the authenticated transport.
/// </remarks>
Task<byte[]> DownloadAsync(
HttpMethod method,
string path,
CancellationToken ct = default)
=> Task.FromResult(Array.Empty<byte>());
} }

View file

@ -1,112 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// Client HTTP de l'espace de stockage personnel
/// (<c>api/v1/fs</c>, servi par le host <c>Yavsc.Blogs</c>). Même forme
/// que <see cref="BlogApiClient"/> : le transport (URL de base, JSON,
/// Bearer, refresh silencieux sur 401) est délégué à
/// <see cref="IYavscApiClient"/> ; cette classe n'est qu'un mappeur
/// DTO↔chemin.
///
/// <para><b>Convention d'URL.</b> Le <c>pathPrefix</c> est relatif au
/// segment de version déjà porté par l'URL de base
/// (<c>…/api/v1/</c>) : <c>"fs"</c> résout en <c>…/api/v1/fs</c>, qui
/// correspond au <c>[Route(APIPrefix + "/fs")]</c> de
/// <c>FileSystemApiController</c>. Ne pas ré-inclure <c>api/</c>.</para>
/// </summary>
public sealed class UserFilesApiClient
{
private const string PathPrefix = "fs";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
public UserFilesApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
_baseAddress = new Uri(blogsBaseAddress);
_api.Http.BaseAddress = _baseAddress;
}
/// <summary>
/// Liste le contenu du sous-répertoire <paramref name="subdir"/> de
/// l'espace perso de l'utilisateur (<c>GET fs/{subdir}</c>).
/// </summary>
public Task<UserDirectoryDto> ListAsync(string? subdir = null, CancellationToken ct = default)
{
var path = string.IsNullOrWhiteSpace(subdir) ? PathPrefix : $"{PathPrefix}/{subdir.TrimStart('/')}";
return _api.CallAsync<UserDirectoryDto>(HttpMethod.Get, path, ct: ct);
}
/// <summary>
/// Téléverse des fichiers dans le sous-répertoire
/// <paramref name="subdir"/> (<c>POST fs/{subdir}</c> multipart).
/// Renvoie les métadonnées des fichiers reçus, dont l'identifiant
/// <see cref="FileReceivedDto.FileId"/> servant à l'attachement.
/// </summary>
public Task<List<FileReceivedDto>> UploadAsync(string subdir, IReadOnlyCollection<UploadFile> files, CancellationToken ct = default)
{
if (files is null || files.Count == 0)
throw new ArgumentException("At least one file is required.", nameof(files));
var path = string.IsNullOrWhiteSpace(subdir)
? PathPrefix
: $"{PathPrefix}/{subdir.TrimStart('/')}";
return _api.CallAsync<List<FileReceivedDto>>(HttpMethod.Post, path, () => CreateMultipartContent(files), ct);
}
/// <summary>
/// Supprime un fichier ou répertoire de l'espace perso
/// (<c>DELETE fs/{path}</c>).
/// </summary>
public Task DeleteAsync(string path, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path is required.", nameof(path));
return _api.CallAsync(HttpMethod.Delete, $"{PathPrefix}/{path.TrimStart('/')}", ct: ct);
}
/// <summary>
/// Télécharge une pièce jointe référencée par son identifiant
/// <see cref="FileReceivedDto.FileId"/> / <see cref="AttachmentDto.FileId"/>
/// (<c>GET fs/file/{fileId}</c>). Renvoie les octets bruts ; le serveur
/// (host Blogs) autorise l'appelant (propriétaire ou partie au
/// devis/demande auquel le fichier est rattaché).
/// </summary>
public Task<byte[]> DownloadFileAsync(long fileId, CancellationToken ct = default)
{
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.DownloadAsync(HttpMethod.Get, $"{PathPrefix}/file/{fileId}", ct);
}
/// <summary>
/// Construit le corps <c>multipart/form-data</c> d'un téléversement :
/// une part <c>file</c> par fichier (type MIME retombant sur
/// <c>application/octet-stream</c>). Exposé <c>public</c> pour les
/// tests backend qui assertent la forme filaire.
/// </summary>
public static HttpContent CreateMultipartContent(IReadOnlyCollection<UploadFile> files)
{
var content = new MultipartFormDataContent();
foreach (var file in files)
{
var fileContent = new ByteArrayContent(file.Content);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
content.Add(fileContent, "file", file.FileName);
}
return content;
}
}

View file

@ -17,8 +17,8 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-feat-sign-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />

View file

@ -0,0 +1,7 @@
<Project>
<!--
Yavsc.Api.Test has no project-specific package versions. All
package versions are declared at the repository root.
-->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
</Project>

View file

@ -209,12 +209,9 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
/// <summary> /// <summary>
/// Seeds an estimate owned by alice (the provider) for the given /// Seeds an estimate owned by alice (the provider) for the given
/// client, optionally already validated by the client. By default the /// client, optionally already validated by the client.
/// estimate is also provider-validated (i.e. the provider has submitted
/// it); pass <paramref name="providerValidated"/> = false to seed a
/// freshly inserted request the provider hasn't worked on yet.
/// </summary> /// </summary>
private long SeedEstimate(string clientId, bool clientValidated, bool providerValidated = true) private long SeedEstimate(string clientId, bool clientValidated)
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -232,7 +229,6 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
{ {
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" }, new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
}, },
ProviderValidationDate = providerValidated ? DateTime.UtcNow : default,
ClientValidationDate = clientValidated ? DateTime.UtcNow : default, ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
}; };
db.Estimates.Add(estimate); db.Estimates.Add(estimate);
@ -264,34 +260,11 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
} }
[Fact] [Fact]
public async Task GetOngoingEstimatesAsClient_excludes_inserted_requests_not_yet_provider_validated() public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider()
{ {
_fixture.ResetAndSeedActivityGraph(); _fixture.ResetAndSeedActivityGraph();
var submittedId = SeedEstimate(clientId: "bob", clientValidated: false); // provider submitted: visible var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false); // inserted, not worked on: excluded SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
using var http = NewClient("bob");
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items);
Assert.Equal(submittedId, item.GetProperty("id").GetInt64());
}
[Fact]
public async Task GetOngoingEstimatesAsProvider_returns_estimates_the_provider_has_not_yet_signed()
{
_fixture.ResetAndSeedActivityGraph();
// Not yet provider-signed: visible in the provider's "awaiting
// provider signature" list.
var pendingId = SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false);
// Provider already signed: must leave the list immediately.
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: true);
using var http = NewClient("alice"); using var http = NewClient("alice");
@ -303,7 +276,7 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray(); var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items); var item = Assert.Single(items);
Assert.Equal(pendingId, item.GetProperty("id").GetInt64()); Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
Assert.Equal("alice", item.GetProperty("ownerId").GetString()); Assert.Equal("alice", item.GetProperty("ownerId").GetString());
Assert.Single(item.GetProperty("bill").EnumerateArray()); Assert.Single(item.GetProperty("bill").EnumerateArray());
} }

View file

@ -24,6 +24,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER"; private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION"; private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb"; private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
protected override int HttpsPort => 5104; protected override int HttpsPort => 5104;
@ -167,32 +168,25 @@ public sealed class ApiWebServerFixture : WebHostFixture
private static string BuildAdminConnectionString() private static string BuildAdminConnectionString()
{ {
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar); var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
if (string.IsNullOrWhiteSpace(configured)) var source = string.IsNullOrWhiteSpace(configured)
throw new InvalidOperationException( ? DefaultDevelopmentConnectionString
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test " : configured;
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(configured) var builder = new NpgsqlConnectionStringBuilder(source)
{ {
Pooling = false, Pooling = false,
IncludeErrorDetail = true IncludeErrorDetail = true
}; };
// The admin connection targets the maintenance database if (string.IsNullOrWhiteSpace(configured))
// (postgres) so we can CREATE/DROP the dedicated test database; {
// whatever database the env var names is overridden here.
if (string.IsNullOrWhiteSpace(builder.Database))
builder.Database = "postgres"; builder.Database = "postgres";
}
else if (string.IsNullOrWhiteSpace(builder.Database))
{
builder.Database = "postgres";
}
return builder.ToString(); return builder.ToString();
} }

View file

@ -0,0 +1,62 @@
using System.Net;
using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public FrontOfficeApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
[Fact]
public async Task Front_accept_query_updates_status_without_server_error()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedRdvQueryGraph();
long queryId;
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
queryId = db.RdvQueries.Select(q => q.Id).Single();
}
using var http = NewClient();
var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
Assert.Equal(QueryStatus.Accepted, updated.Status);
}
}

Some files were not shown because too many files have changed in this diff Show more