Compare commits

...

60 commits

Author SHA1 Message Date
3824a928fe
Bad test, fix the test
Some checks failed
Dotnet build and test / build (push) Failing after 7m26s
2026-09-11 13:42:29 +01:00
77e521b96d
Test the release, don't ignore test return code
Some checks failed
Dotnet build and test / build (push) Failing after 3m7s
2026-09-11 13:25:32 +01:00
38bc659a58
CircleAuthorizationToFile EF migration
All checks were successful
Dotnet build and test / build (pull_request) Successful in 6m15s
2026-09-07 11:02:41 +01:00
10fdcb288c
unique email handling 2026-09-07 10:58:20 +01:00
229971783a
code reorg 2026-09-07 10:30:45 +01:00
d0c79e449d chore: ignore tmp dirs and remove accidental temp service file
All checks were successful
Dotnet build and test / build (push) Successful in 5m5s
2026-09-06 21:27:14 +01:00
ff9c6c7209 Merge pull request 'postit: persist settings save and apply ApiUrl changes without restart' (#51) from feat/estimate into main
Reviewed-on: #51
2026-09-06 21:05:57 +01:00
1ec7c7a75a postit: add billing query details page and refresh rc14 changelog
All checks were successful
Forgejo Release / release (push) Successful in 4m39s
2026-09-06 20:31:42 +01:00
ebe9d0b740 correctif UTC sur le POST/PUT RDV 2026-09-06 19:48:51 +01:00
703757d326 handles duplicate email at register 2026-09-06 19:47:38 +01:00
ad5e9090ee fix(api): harden billing/blog validation and update rc14 changelog 2026-09-06 19:16:41 +01:00
e1d9bb38a3 Release 1.0.8-rc13
All checks were successful
Forgejo Release / release (push) Successful in 6m21s
2026-09-06 18:02:30 +01:00
bf569a4ac5 postit: fix rdv date picker binding 2026-09-06 17:42:46 +01:00
92e82edf72 postit-tests: cover rdv suggested address panel 2026-09-06 17:31:41 +01:00
52be2e6066 postit: tolerate nearby rdv map cache hits 2026-09-06 17:25:10 +01:00
0975db38b3 postit: cache rdv reverse geocoding results 2026-09-06 17:22:35 +01:00
0c0a401f18 postit: debounce rdv reverse geocoding 2026-09-06 17:19:21 +01:00
1c4a079d60 postit: preserve manual rdv address with map suggestion 2026-09-06 17:16:14 +01:00
4010defde7 postit: reverse geocode rdv map selections 2026-09-06 17:13:31 +01:00
389cd050da postit: add rdv map marker and recenter action 2026-09-06 17:05:18 +01:00
2ede36cb8f postit: add mapsui location picker to rdv form 2026-09-06 16:58:39 +01:00
3488abc7fd postit-tests: lock first-load settings regression 2026-09-06 16:32:59 +01:00
66614ef2e1 postit: ignore runtime status in settings json 2026-09-06 16:24:46 +01:00
148a3aa271 postit: harden oidc settings defaults on startup 2026-09-06 16:20:17 +01:00
0ba44c21e7 server: enable nullable annotations in legacy files 2026-09-06 16:11:37 +01:00
a987116eb5 abstract: enable nullable annotations in legacy files 2026-09-06 15:50:40 +01:00
5d94270fd8 postit: harmonize main status messages in french 2026-09-06 15:47:01 +01:00
37d6551a23 postit: replace inferred status severity with explicit setters 2026-09-06 15:45:24 +01:00
3e37a857c5 postit: guard app navigation paths in circle member flows 2026-09-06 15:22:50 +01:00
285996ac1a postit: extend shared status bar to main, dialogs and billing flows 2026-09-06 15:21:22 +01:00
4f26f14e56 postit: unify action status bar with severity across settings/activities/circles/rdv 2026-09-06 14:52:50 +01:00
bff7cff59f postit: persist settings save and apply ApiUrl changes without restart
All checks were successful
Forgejo Release / release (push) Successful in 11m52s
2026-09-06 14:17:40 +01:00
a9233f8842 Merge pull request 'feat/estimate' (#50) from feat/estimate into main
Reviewed-on: #50
2026-09-04 22:06:49 +01:00
daf766d762 prepare 1.0.8-rc12: avatar loading, authority binding, and command header 2026-09-04 21:56:04 +01:00
a680ce533b render the avatar upload status 2026-09-04 20:28:54 +01:00
c4f65f664b setup the avatar 2026-09-04 20:25:06 +01:00
138467496f Fix set-avatar auth flow and API avatar upload stability 2026-09-04 19:03:08 +01:00
85bffba785 la version impair est stable 2026-09-04 15:50:22 +01:00
190584d0e3 test the avatar failback 2026-09-04 15:21:42 +01:00
1fc49fcfe3 Dead code cleanup 2026-09-04 15:08:51 +01:00
a72b0cba7b ensure the avatar path 2026-09-04 15:08:35 +01:00
d5d1f686f3 code spaces 2026-09-04 15:02:11 +01:00
3c3323ccd4 refactoring the command 2026-09-04 14:53:00 +01:00
8edef1ef91 refactoring the command form creation 2026-09-04 13:19:31 +01:00
da7e93a19a renaming 2026-09-02 14:52:17 +01:00
3b9d296a52 Platform.TryGetCurrentLocationAsync 2026-09-02 14:41:05 +01:00
b2fcd165f7 test(oidc): cover discovery regression and consume fixed IdentityServer8 rc007
Add an application-level smoke test for /.well-known/openid-configuration
and a direct IResourceStore check so the IdentityServer8 mapper failure is
caught from the Yavsc side.

Bump HigginsSoft.IdentityServer8 packages to 8.1.0-pazofrc007, which
includes the EntityFramework mapper fix removing the failing AutoMapper
static initialization.

This closes the 500 on OIDC discovery and restores remoting/auth flows
that depend on discovery metadata.
2026-08-31 17:35:02 +01:00
590fb0eb27 Release 1.0.8-rc11
Some checks failed
Forgejo Release / release (push) Failing after 14m8s
2026-08-31 13:37:54 +01:00
92da2e1369 chore(deps): cleanup Yavsc.Org restore package configuration 2026-08-31 13:29:12 +01:00
966fc27df1 fix: bump EF Core Sqlite to address NU1903 vulnerability 2026-08-31 13:26:03 +01:00
f992916d08 set your activity country
All checks were successful
Forgejo Release / release (push) Successful in 7m15s
2026-08-31 05:40:34 +01:00
8b9a9103fc Release 1.0.8-rc10 2026-08-31 04:45:39 +01:00
d5b2930092 display my queries 2026-08-31 04:27:39 +01:00
341b49cbfa hitting the performer 2026-08-31 04:15:10 +01:00
fbc72f0c85 RdvQuery from PostIt 2026-08-31 03:37:09 +01:00
6826d345a9 code cleanup 2026-08-31 03:09:49 +01:00
3c51e66e50 Exploration des activités 2026-08-31 02:14:04 +01:00
da994435cb List the activities 2026-08-31 00:25:53 +01:00
8d7bb8f6b6 An activity interface 2026-08-30 23:55:09 +01:00
ce5d3ce810 bug fix painting the signature 2026-08-30 21:05:43 +01:00
259 changed files with 24423 additions and 658 deletions

View file

@ -48,4 +48,4 @@ jobs:
--verbosity normal \
--filter="Category!=Platform-Android" \
--logger "xunit;LogFileName=test-results.xml" \
&& echo "✅ Success !" || echo "❌ Fail ($?)!"
&& echo "✅ Success !" || { echo "❌ Fail ($?)!"; exit 1; }

View file

@ -175,6 +175,12 @@ jobs:
run: |
cd /src/_src
dotnet restore
- name: Test
run: |
cd /src/_src && dotnet test \
--verbosity normal \
--filter="Category!=Platform-Android" \
--logger "xunit;LogFileName=test-results.xml"
- name: Build de PostIt.Android ARM64
run: |
@ -200,6 +206,7 @@ jobs:
RELEASE_BODY: ${{ env.RELEASE_BODY }}
IS_PRERELEASE: ${{ env.IS_PRERELEASE }}
run: |
set -e
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved for the API call."
exit 1

1
.gitignore vendored
View file

@ -35,6 +35,7 @@ appsettings-*.*.json
generated/
*.tmp
tmp/
DataDir/
*.tests.trx

2
.vscode/tasks.json vendored
View file

@ -7,7 +7,7 @@
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$",
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
"file": 1,
"line": 2,
"column": 3,

View file

@ -1,5 +1,116 @@
# Changelog
## [1.0.8-rc14] - unstable
### Added
* [PostIt] Ajout d'un `BillingQueryDetailsPageViewModel` et de sa page associee pour afficher le detail d'une commande billing depuis l'historique.
* [PostIt] Ajout d'un mode detail avec section metier (statut, date, description, motif, infos) et section technique repliable (code, client, provision, lieu, prestations).
* [PostIt] Ajout d'un badge de statut enrichi (couleur + pictogramme) sur le detail d'une commande pour visualiser l'etat en un coup d'oeil.
* [PostIt] Ajout d'un bloc d'actions rapide en tete du detail (`Retour`, `Ouvrir en edition`) pour eviter le scroll jusqu'au bas de page.
* [PostIt] Ajout d'un style monospace sur les metadonnees techniques (code billing, client, provision, lieu, prestations) pour faciliter la lecture des identifiants et valeurs brutes.
### Changed
* [PostIt] Le bouton d'ouverture depuis la liste billing ouvre maintenant une page de detail dediee avant l'eventuelle edition.
* [PostIt] Amelioration UX des pages billing: badges de statut colores, actions remontees en haut de page, et typographie monospace sur les metadonnees techniques.
### Fixed
* [Yavsc.Api] Correction d'un 500 sur le refresh du catalogue d'activites lorsque `Activity.Description` est `NULL` en base (nullabilite explicite + projection null-safe + gardes sur codes vides).
* [Yavsc.Api] Correction des erreurs 400/500 sur les routes billing (`Rdv`, `Brush`, `MBrush`) en imposant `ClientId` depuis l'utilisateur authentifie et en ignorant les champs server-owned lors de la validation modele.
* [Yavsc.Api] Correction du `PUT /api/v1/billing/Rdv/{id}`: mise a jour controlee de l'entite existante (et non remplacement brut du graphe JSON), ce qui supprime les `BadRequest` parasites.
* [Yavsc.Api] Correction PostgreSQL `timestamptz` sur RDV: normalisation UTC de `EventDate` sur `POST/PUT /api/v1/billing/Rdv` pour eviter l'erreur `Cannot write DateTime with Kind=Local`.
* [Yavsc.Api] Correction du flux FrontOffice accept/reject de query: sauvegarde avec contexte utilisateur et fallback d'injection pour `IBillingService` afin d'eviter les erreurs serveur en environnement de test.
* [Yavsc.Blogs] Correction des `BadRequest` sur `POST/PUT /api/v1/blogspot` avec payload JSON (PostIt): les proprietes de navigation/serveur (`Author`, `Tags`, `Comments`, audit) ne bloquent plus la validation.
* [Yavsc.Org] Correction du flux MVC de creation de commentaire: `SaveChangesAsync(userId)` est utilise pour renseigner les champs d'audit requis (`UserCreated`/`UserModified`).
* [Yavsc.Api.Test] Stabilisation des fixtures de seed billing: remplissage des metadonnees d'audit (`UserCreated`, `UserModified`, dates) pour eviter les echecs SQLite `NOT NULL`.
## [1.0.8-rc13] - unstable
### Added
* [PostIt] Integration d'un selecteur de lieu RDV base sur Mapsui (carte interactive dans le formulaire `Rdv`).
* [PostIt] Ajout d'un marqueur de position et d'une action de recentrage sur la carte RDV.
* [PostIt] Ajout d'un service de reverse geocoding pour suggerer une adresse a partir des coordonnees carte.
* [PostIt] Cache et debounce des resolutions d'adresse RDV pour limiter les appels reseau et lisser l'UX.
* [PostIt.Tests] Nouvelles non-regressions sur le panneau d'adresse suggeree RDV et le comportement de la carte.
* [Yavsc.Abstract] Activation de `#nullable enable annotations` sur les fichiers legacy avec annotations nullable.
* [Yavsc.Server] Activation de `#nullable enable annotations` sur les fichiers legacy avec annotations nullable.
### Changed
* [PostIt] Generalisation de la barre de statut d'action (severite explicite) sur pages principales, dialogues et formulaires billing.
* [PostIt] Harmonisation des messages de statut utilisateur en francais.
* [PostIt] Renforcement des gardes de navigation dans les flux de gestion des membres de cercle.
* [PostIt] Le flux RDV conserve l'adresse saisie manuellement et propose l'adresse resolue comme suggestion explicite.
* [PostIt] Le flux de geolocalisation RDV tolere les positions proches dans le cache de suggestion d'adresse.
### Fixed
* [PostIt.Desktop] Correction d'un crash au demarrage OIDC (`No authority specified`) via durcissement des valeurs par defaut de configuration d'authentification.
* [PostIt] Correction de la persistance des settings: l'etat runtime de statut n'est plus serialize dans le JSON utilisateur.
* [PostIt.Tests] Ajout d'un verrou de non-regression sur le premier chargement des settings.
* [PostIt] Correction du binding de la date RDV: `DatePicker.SelectedDate` est aligne sur un proxy `DateTimeOffset?` (`EventDateSelection`).
## [1.0.8-rc12] - unstable
### Added
* [PostIt] Nouveau helper d'image `ImageHelper` pour charger des bitmaps depuis les ressources et depuis le web.
* [PostIt] Affichage de l'avatar XS dans la liste des performers d'activites, avec fallback visuel (initiale utilisateur).
* [PostIt.Tests] Nouveaux tests autour des URLs avatar et de la source d'autorite.
* [contrib] Ajout d'un `README.md` utilitaire pour les symboles/icones.
### Changed
* [PostIt] Les avatars ne sont plus relies en string sur `Image.Source`: ils sont telecharges et lies en `Bitmap`.
* [Yavsc.Api.Client] `ActivityApiClient` accepte une base d'avatar dediee et construit les URLs avatar depuis l'autorite d'identification.
* [PostIt] Les clients Activites/Billing utilisent maintenant `ApiUrl` en lecture dynamique: un changement via Parametres prend effet sans redemarrer l'application (apres sauvegarde et rafraichissement de la page).
* [PostIt] Le header de `MainPage` n'utilise plus `ScrollViewer`; remplacement par une barre de commandes basee sur `WrapPanel`.
* [PostIt] Alignement de la navigation blogs: renommage `PushMainPageAsync` -> `PushBlogsPageAsync` et ajustement de `HomePageViewModel`.
### Fixed
* [PostIt.Android] Correction d'un 404 sur la page Activites au premier lancement: la configuration embarquee pointait `ApiUrl` vers le host Blogs au lieu de l'API metier.
* [PostIt] Correction du bouton Sauver de la page Parametres: binding vers `SaveCommand` pour persister correctement `ApiUrl`/`BlogsApiUrl`.
## [1.0.8-rc11] - unstable
### Added
nothing
### Changed
* [Yavsc.Api.Test] Mise a jour de `Microsoft.EntityFrameworkCore.Sqlite` vers `10.0.11` afin de supprimer l'alerte NU1903 liee a `SQLitePCLRaw.lib.e_sqlite3` 2.1.11.
* [Yavsc.Org] Nettoyage de la configuration NuGet pour le restore: suppression du fichier local `Directory.Packages.props` au profit du fichier racine centralise.
* [Yavsc.Org] Suppression de references de packages redondantes dans le projet, sans impact fonctionnel attendu.
### Fixed
* [Yavsc.Api.Test] Le restore n'emet plus le warning de vulnerabilite `NU1903` sur `SQLitePCLRaw.lib.e_sqlite3`.
* [Yavsc.Org] Suppression d'une vulnerabilite de severite elevee sur AutoMapper apres publication et consommation de la nouvelle version candidate de `HigginsSoft.IdentityServer8`.
## [1.0.8-rc10] - unstable
### Added
* [PostIt] Une page d'historique des commandes billing permet maintenant d'ouvrir une commande existante.
* [PostIt] Une vue "Demandes en cours" en lecture seule est disponible pour le performer, filtrée sur les statuts actifs (Inserted, Accepted, InProgress).
* [Yavsc.Org] Nouvelles entités `Country` et `PerformerCodeInputValidation` pour piloter la validation du code entreprise performer par pays.
### Changed
* [PostIt] La page détail billing se préremplit depuis une commande existante (Rdv, Brush, MBrush) et passe en mode mise à jour.
* [Yavsc.Org] Le formulaire `Manage/SetActivity` inclut désormais le pays d'exercice (`fr`, `en`, `pt`) et applique la regex associée au champ `SIREN`.
* [Yavsc.Org] La vérification externe du numéro d'entreprise est conservée uniquement pour le pays `fr`.
### Fixed
* [PostIt] Le flux historique n'est plus limité à une simple liste: l'action d'ouverture charge la commande cible puis navigue vers la page détail.
* [Yavsc.Org] Le champ `SIREN` n'est plus validé avec une règle unique indépendante du pays d'exercice.
## [1.0.8-rc9] - unstable
### Added

View file

@ -49,6 +49,19 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
## Onboarding assiste par agents IA
Pour accelerer la prise en main du depot avec Copilot/Plan/Explore :
- Parcours pas-a-pas : [doc/onboarding-agents.md](./doc/onboarding-agents.md)
- Playbook d'usage des agents : [doc/agent-playbook.md](./doc/agent-playbook.md)
- Matrice intentions -> agent -> preuves : [doc/agent-intent-matrix.md](./doc/agent-intent-matrix.md)
Regle minimale en contribution assistee par agent :
- expliciter l'impact architecture,
- justifier le niveau de tests execute,
- documenter les risques residuels.
## Le CHANGELOG.md
Le `CHANGELOG.md` est un document de changement de version
@ -61,8 +74,8 @@ et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
À noter : la **parité du numéro de patch** porte une signification de canal :
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **preview**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **stable**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
Cette convention est partagée avec le dépôt

View file

@ -1,15 +1,31 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.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="System.Security.Cryptography.Pkcs" Version="10.0.9" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
<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="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
@ -17,10 +33,20 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.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.common" Version="3.2.2" />

View file

@ -28,6 +28,10 @@ sous [`doc/`](./doc/). Voir l'[index de la documentation](./doc/README.md)
pour le sommaire complet. La racine de l'architecture est
[Architecture.md](./doc/Architecture.md).
Pour une prise en main guidee avec agents IA:
- parcours onboarding: [doc/onboarding-agents.md](./doc/onboarding-agents.md)
- playbook d'usage: [doc/agent-playbook.md](./doc/agent-playbook.md)
# Construction et déploiement

View file

@ -68,7 +68,7 @@ Trois principes non négociables traversent tous les jalons :
>
> Chaque jalon a un **critère de sortie** vérifiable.
### Jalon 0 — Fondations techniques *(en cours)*
### Jalon 0 — Fondations techniques
> Cible : pouvoir parler du domaine sans se battre avec le runtime.
@ -81,7 +81,7 @@ Trois principes non négociables traversent tous les jalons :
---
### Jalon 1 — Prestation signée de bout en bout
### Jalon 1 — Prestation signée de bout en bout *(en cours)*
> Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications.

24
contrib/.env-sample Normal file
View file

@ -0,0 +1,24 @@
# parametres de déploiement au Makefile
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=yavsc
POSTGRES_USER=yavsc
POSTGRES_PASSWORD=<your-password-here>
HTTP_HOST=localhost
Org_PORT=83
Blogs_PORT=85
Api_PORT=87
PostIt_CLIENT_ID=postit
ASPNETCORE_Smtp__Host="mercure.pschneider.fr"
ASPNETCORE_Smtp__Port=465
ASPNETCORE_Smtp__SenderName="Paul Schneider"
ASPNETCORE_Smtp__SenderEmail="paul@pschneider.fr"
ASPNETCORE_Smtp__UserName="paul"
ASPNETCORE_Smtp__Password="<your-smtp-password-here>"
DESTDIR=/srv/www/yavsc

View file

@ -1,4 +1,4 @@
APP_PROJECT_NAMES=Org Blogs
APP_PROJECT_NAMES=Org Blogs Api
SLNDIR=..
include $(SLNDIR)/.env
@ -9,9 +9,11 @@ generated/:
generated/yavscOrg.service:
generated/yavscBlogs.service:
generated/yavscApi.service:
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \
BASEAPPDIR="$(BASEAPPDIR)" \
@ -33,11 +35,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@
copy-services: copy-service-Org copy-service-Blogs
copy-services: copy-service-Org copy-service-Blogs copy-service-Api
copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
copy-service-Api: /etc/systemd/system/yavscApi.service
copy-binaries: build_publish_Org build_publish_Blogs stop-services
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
@for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \
@ -60,6 +63,8 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services
build_publish_%: clean_publish_dir_%
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
@ -84,6 +89,7 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@ -92,4 +98,3 @@ showConfig:
clean:
@rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean

5
contrib/README.md Normal file
View file

@ -0,0 +1,5 @@
# Read me
## Note aux icones
㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩

View file

@ -18,6 +18,9 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
| [onboarding-agents.md](onboarding-agents.md) | Parcours pas-à-pas pour prise en main agents IA + architecture + tests |
| [agent-playbook.md](agent-playbook.md) | Playbook d'usage de Copilot, Plan, Explore avec scénarios et anti-patterns |
| [agent-intent-matrix.md](agent-intent-matrix.md) | Matrice intentions développeur -> agent -> preuves attendues |
## Roadmap & design exploration

View file

@ -0,0 +1,20 @@
# Matrice intentions -> agent -> preuves
Cette matrice aide a choisir rapidement l'agent adapte et a exiger
une sortie verifiable.
| Intention developpeur | Agent principal | Entrees minimales | Sortie minimale attendue | Verification |
|---|---|---|---|---|
| Comprendre un BC avant changement | Explore | BC cible, profondeur, contrainte de perimetre | Composants, points d'entree, tests relies, risques | Lire les fichiers cites + confirmer tests proposes |
| Decomposer une tache transverse | Plan | Objectif, contraintes, definition of done | Etapes ordonnees, dependances, criteres de verif | Verifier que chaque etape a une preuve observable |
| Implementer une modif locale | Copilot | Fichier cible, comportement attendu, conventions | Patch minimal, justification courte | Build/test du projet impacte |
| Ajouter un test smoke | Copilot (+Explore) | Route/endpoint, projet de test cible | Test + commande cible | Execution test cible |
| Corriger une regression | Plan + Copilot | Symptome, zone suspecte, test attendu | Fix + test NonRegression | Test rouge avant, vert apres |
| Diagnostiquer flux PostIt/OIDC | Explore + Plan | Flux, symptome, plateforme | Carte du flux + hypotheses testables | Verification manuelle + tests existants |
## Regles d'arbitrage
- Si l'intention est "comprendre": commencer par Explore.
- Si l'intention est "orchestrer": commencer par Plan.
- Si l'intention est "produire": utiliser Copilot apres cadrage.
- Si une sortie n'inclut pas de preuve, elle est incomplete.

101
doc/agent-playbook.md Normal file
View file

@ -0,0 +1,101 @@
# Playbook d'usage des agents IA (Yavsc)
Ce playbook normalise l'usage de Copilot, Plan et Explore dans le depot.
Il privilegie des sorties verifiables: fichiers, commandes tests, risques.
## Quand utiliser quel agent
- Plan: quand la tache est ambigue, transverse ou risquee.
- Explore: quand il faut cartographier rapidement des zones du code.
- Copilot: quand les specifications sont claires et localisees.
## Prompt type (base)
Utiliser ce squelette avant toute tache non triviale:
```text
Contexte: <projet/fichier/fonction>
Objectif: <resultat observable>
Contraintes: <style, archi, perimetre>
Verification: <tests exacts a lancer>
Sortie attendue: <fichiers modifies + risques>
```
## 4 scenarios de reference
## 1) Explorer un bounded context
Intention:
- Comprendre ou implementer un changement dans un BC sans regression laterale.
Prompt minimal:
```text
Explore le BC <nom> avec profondeur medium.
Retour: composants touches, points d'entree, tests existants et risques.
```
Preuves attendues:
- Carte des fichiers a modifier.
- Test(s) smoke/mandatory proposes.
## 2) Ajouter un smoke test
Intention:
- Couvrir rapidement un endpoint ou une route publique.
Prompt minimal:
```text
Propose un smoke test pour <route/endpoint> dans le projet de test approprie.
Respecte les conventions de doc/testing.md.
```
Preuves attendues:
- Fichier test cree/modifie.
- Commande precise pour executer le test cible.
## 3) Corriger une regression backend API
Intention:
- Corriger un bug sans casser un flux voisin.
Prompt minimal:
```text
Planifie puis implemente un fix de <symptome> dans <projet>.
Ajoute/ajuste un test NonRegression rouge puis vert.
```
Preuves attendues:
- Explication cause racine.
- Test non-regression associe.
- Commande d'execution et resultat attendu.
## 4) Tracer un flux PostIt/OIDC
Intention:
- Localiser une cassure d'authentification entre client et serveur.
Prompt minimal:
```text
Cartographie le flux OIDC PostIt: entrypoints, callback, stockage token,
refresh. Donne points de rupture probables et tests/verification proposes.
```
Preuves attendues:
- Liste ordonnee des etapes du flux.
- Fichiers critiques.
- Hypotheses testables.
## Anti-patterns a eviter
- Prompt sans objectif verifiable.
- Demande trop large sans perimetre de fichiers.
- Validation basee uniquement sur "ca semble correct".
- Pas de lien entre changement et niveau de test.
## Gate PR minimale (agent-assiste)
Avant validation:
- Impact architecture explicite.
- Rationale de choix agent explicite.
- Test(s) executes et justifies.
- Risques residuels documentes.

73
doc/onboarding-agents.md Normal file
View file

@ -0,0 +1,73 @@
# Onboarding guide: agents IA + architecture + tests
Ce guide est optimise pour accelerer la prise en main des agents IA
(Copilot, Plan, Explore) dans Yavsc, avec une verification rapide
par les tests.
## Resultat attendu
A la fin du parcours, un contributeur doit pouvoir:
- Identifier les projets impactes par une modification.
- Choisir l'agent adapte a l'intention de travail.
- Produire une proposition de changement verifiable par les tests.
## Parcours en 3 modules
## Module A - Comprendre le terrain (30-45 min)
Objectif: acquerir une lecture fiable de l'architecture.
1. Lire [README.md](../README.md) puis [Architecture.md](Architecture.md).
2. Lire [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md).
3. Selon le domaine:
- Backend/API: [architecture/workflow-multi-parties.md](architecture/workflow-multi-parties.md)
- PostIt: [architecture/postit.md](architecture/postit.md) puis [architecture/postit-oidc.md](architecture/postit-oidc.md)
Definition of done:
- Expliquer en 5 phrases quelles couches sont touchees.
- Citer le ou les points d'entree applicatifs a verifier.
## Module B - Boucle tests rapide (20-30 min)
Objectif: verifier rapidement sans lancer toute la suite.
1. Lire [testing.md](testing.md).
2. Lancer les smoke tests d'abord, puis mandatory selon le projet.
3. N'elargir au test complet que si le scope depasse le BC touche.
Definition of done:
- Fournir la commande test executee.
- Expliquer pourquoi ce niveau de test est suffisant.
## Module C - Usage agentique en production (30-40 min)
Objectif: utiliser les agents comme accelerateurs, pas comme boites noires.
1. Plan: decomposer la tache en etapes verifiables.
2. Explore: collecter le contexte code/doc precise.
3. Copilot: implementer localement et verifier.
Regles:
- Toujours donner un contexte explicite (fichier, but, contrainte).
- Demander des preuves observables (fichiers modifies, tests, risques).
- Refuser toute sortie non verifiable.
Definition of done:
- Une tache simple est livree avec:
- Plan
- Changement local
- Preuve par test
## Routine continue (sans echeance fixe)
Rituels recommandes:
- Hebdo: revue des prompts qui ont bien fonctionne.
- Mensuel: mise a jour du present guide et du playbook.
- A chaque incident: ajouter un anti-pattern dans le playbook.
## Check-list de validation
- Le changement indique son impact architecture.
- Le choix de l'agent est justifie.
- La preuve test est incluse.
- Les risques residuels sont explicitement listes.

View file

@ -16,6 +16,8 @@
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />

View file

@ -1,4 +1,5 @@
using Android.App;
using Android;
using Android.Runtime;
using Avalonia;
using Avalonia.Android;
@ -9,6 +10,9 @@ using Avalonia.Controls;
using Avalonia.Styling;
using Yavsc.Api.Client;
[assembly: UsesPermission(Manifest.Permission.AccessFineLocation)]
[assembly: UsesPermission(Manifest.Permission.AccessCoarseLocation)]
namespace PostIt.Android
{
[Application]

View file

@ -57,6 +57,17 @@ public class MainActivity : AvaloniaMainActivity
}
public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults)
{
if (PostIt.Android.Services.AndroidCurrentLocationProvider
.HandlePermissionResult(requestCode, grantResults))
{
return;
}
base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
}
internal static class AndroidOidcCallbackSink
{
private static System.Threading.Tasks.TaskCompletionSource<string>? _pending;

View file

@ -14,11 +14,12 @@ internal static class PlatformBootstrap
{
internal static void InitPlatform()
{
Platform.CreateBrowser = () =>
{
var activity = MainActivity.Current;
return activity is null ? null : new AndroidSystemBrowser(activity);
};
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
}
}

View file

@ -0,0 +1,130 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Android;
using Android.App;
using Android.Content.PM;
using Android.Locations;
using AndroidX.Core.App;
using AndroidX.Core.Content;
using PostIt.Services;
namespace PostIt.Android.Services;
internal static class AndroidCurrentLocationProvider
{
public static async Task<CurrentLocationResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken)
{
var activity = MainActivity.Current;
if (activity is null)
{
return CurrentLocationResult.Unavailable("L'activité Android n'est pas encore prête.");
}
var permissionGranted = await LocationPermissionBroker.EnsureGrantedAsync(activity, cancellationToken).ConfigureAwait(false);
if (!permissionGranted)
{
return CurrentLocationResult.PermissionDenied();
}
var locationManager = activity.GetSystemService(global::Android.Content.Context.LocationService) as LocationManager;
if (locationManager is null)
{
return CurrentLocationResult.Unavailable("Le service de localisation Android est indisponible.");
}
var location = locationManager.GetProviders(enabledOnly: true)?
.Select(provider => locationManager.GetLastKnownLocation(provider))
.Where(candidate => candidate is not null)
.OrderByDescending(candidate => candidate!.Time)
.ThenBy(candidate => candidate!.Accuracy)
.FirstOrDefault();
if (location is null)
{
return CurrentLocationResult.Unavailable("Aucune position n'est disponible. Activez la localisation du système puis réessayez.");
}
return CurrentLocationResult.Success(location.Latitude, location.Longitude);
}
public static bool HandlePermissionResult(int requestCode, Permission[]? grantResults)
=> LocationPermissionBroker.HandleResult(requestCode, grantResults);
private static class LocationPermissionBroker
{
private const int RequestCode = 4042;
private static readonly string[] RequestedPermissions =
{
Manifest.Permission.AccessFineLocation,
Manifest.Permission.AccessCoarseLocation,
};
private static readonly object SyncRoot = new();
private static TaskCompletionSource<bool>? _pendingRequest;
public static Task<bool> EnsureGrantedAsync(Activity activity, CancellationToken cancellationToken)
{
if (HasLocationPermission(activity))
{
return Task.FromResult(true);
}
lock (SyncRoot)
{
if (_pendingRequest is null)
{
_pendingRequest = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
ActivityCompat.RequestPermissions(activity, RequestedPermissions, RequestCode);
}
if (!cancellationToken.CanBeCanceled)
{
return _pendingRequest.Task;
}
return WaitAsync(_pendingRequest.Task, cancellationToken);
}
}
public static bool HandleResult(int requestCode, Permission[]? grantResults)
{
if (requestCode != RequestCode)
{
return false;
}
var granted = grantResults is { Length: > 0 } && grantResults.All(result => result == Permission.Granted);
TaskCompletionSource<bool>? pendingRequest;
lock (SyncRoot)
{
pendingRequest = _pendingRequest;
_pendingRequest = null;
}
pendingRequest?.TrySetResult(granted);
return true;
}
private static bool HasLocationPermission(Activity activity)
{
return ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessFineLocation) == Permission.Granted
|| ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessCoarseLocation) == Permission.Granted;
}
private static async Task<bool> WaitAsync(Task<bool> task, CancellationToken cancellationToken)
{
using var registration = cancellationToken.Register(() =>
{
lock (SyncRoot)
{
_pendingRequest?.TrySetCanceled(cancellationToken);
_pendingRequest = null;
}
});
return await task.ConfigureAwait(false);
}
}
}

View file

@ -0,0 +1,167 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ActivitiesPageViewModelTests
{
[Fact]
public void ActivityApiClient_uses_avatar_authority_when_provided()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(
api,
"https://api.pschneider.fr/api/v1/",
"https://yavsc.pschneider.fr/");
var url = client.BuildAvatarXsUrl("paul");
Assert.Equal("https://yavsc.pschneider.fr/avatars/paul.xs.png", url);
}
[Fact]
public async Task ActivityApiClient_uses_business_absolute_paths()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
var billingClient = new BillingApiClient(api, "https://business.example/api/v1/");
await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken);
await client.GetUsersAsync("brush-pro", TestContext.Current.CancellationToken);
await billingClient.CreateAsync("Rdv", new { Foo = "Bar" }, TestContext.Current.CancellationToken);
await billingClient.GetQuerySummariesAsync("Rdv", TestContext.Current.CancellationToken);
Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]);
Assert.Equal("https://business.example/api/v1/activity/brush-pro/users", api.Paths[1]);
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[2]);
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[3]);
}
[Fact]
public async Task ActivityApiClient_uses_updated_business_base_without_restart()
{
var api = new StubActivityApi();
var baseUrl = "https://business-a.example/api/v1/";
var client = new ActivityApiClient(api, () => baseUrl);
await client.GetCatalogAsync(ct: TestContext.Current.CancellationToken);
baseUrl = "https://business-b.example/api/v1/";
await client.GetUsersAsync("brush", TestContext.Current.CancellationToken);
Assert.Equal("https://business-a.example/api/v1/activity/catalog", api.Paths[0]);
Assert.Equal("https://business-b.example/api/v1/activity/brush/users", api.Paths[1]);
}
[Fact]
public async Task RefreshAsync_loads_first_activity_then_specialization_performers()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
var billingClient = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ActivitiesPageViewModel(client, billingClient);
await vm.RefreshAsync();
Assert.Equal("brush", vm.SelectedActivity?.Code);
Assert.Single(vm.Specializations);
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
Assert.Equal("https://business.example/avatars/Alice.xs.png", vm.Performers[0].AvatarXsUrl);
Assert.True(vm.Performers[0].HasPerformerProfile);
Assert.True(vm.Performers[0].IsPerformerActive);
Assert.Equal("Actif", vm.Performers[0].PerformerStatusBadgeLabel);
Assert.Equal("Pas d'autre activité", vm.Performers[0].ExtraActivityLabel);
await vm.ShowSpecializationAsync(vm.Specializations[0]);
Assert.Equal("brush-pro", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Bob", vm.Performers[0].UserName);
Assert.Equal("https://business.example/avatars/Bob.xs.png", vm.Performers[0].AvatarXsUrl);
Assert.True(vm.Performers[0].HasPerformerProfile);
Assert.False(vm.Performers[0].IsPerformerActive);
Assert.Equal("Inactif", vm.Performers[0].PerformerStatusBadgeLabel);
Assert.Equal("Autres spécialisations: 2", vm.Performers[0].ExtraActivityLabel);
Assert.Contains("brush pro", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
await vm.ShowSpecializationAsync(null);
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
}
private sealed class StubActivityApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<ActivityInfo>))
{
var activities = new List<ActivityInfo>
{
new()
{
Code = "brush",
Name = "Brush",
Description = "Coiffure à domicile",
PerformerCount = 1,
Forms = new List<CommandFormSummary>
{
new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }
},
Children = new List<ActivityInfo>
{
new()
{
Code = "brush-pro",
Name = "Brush Pro",
Description = "Spécialisation premium",
ParentCode = "brush",
PerformerCount = 1,
Forms = new List<CommandFormSummary>
{
new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" }
}
}
}
}
};
return Task.FromResult((T)(object)activities);
}
if (typeof(T) == typeof(List<PerformerActivity>))
{
var performers = path.EndsWith("brush-pro/users", StringComparison.Ordinal)
? new List<PerformerActivity>
{
new() { PerformerId = "pro-2", HasPerformerProfile = true, Active = false, UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro", ExtraActivityCount = 2 }
}
: new List<PerformerActivity>
{
new() { PerformerId = "pro-1", HasPerformerProfile = true, Active = true, UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush", ExtraActivityCount = 0 }
};
return Task.FromResult((T)(object)performers);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -67,7 +67,7 @@ public class BearerScopeTests
Scopes = userScopes,
RedirectUri = "postit://callback",
},
BusinessApiUrl = "https://example.invalid/api/v1/",
ApiUrl = "https://example.invalid/api/v1/",
};
var tokensPath = Path.Combine(
@ -266,7 +266,7 @@ public class BearerScopeTests
// private HttpClient is independent, so we resolve the
// absolute URI ourselves from Settings.BusinessApiUrl —
// the same URL BlogApiClient would have set as BaseAddress.
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);

View file

@ -0,0 +1,357 @@
using System.Text.Json;
using PostIt.Helpers;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Haircut;
namespace PostIt.Tests;
public class BillingCommandPageViewModelTests
{
[Fact]
public async Task SubmitAsync_posts_rdv_payload_to_selected_billing_route()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Reason = "Point de cadrage";
vm!.Address = "1 rue du Test";
vm!.Latitude = 48.8566;
vm!.Longitude = 2.3522;
vm!.Consent = true;
await vm.SubmitCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.LastPath);
Assert.NotNull(api.LastBody);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
Assert.Equal("dev", json.RootElement.GetProperty("ActivityCode").GetString());
Assert.Equal("perf-1", json.RootElement.GetProperty("PerformerId").GetString());
Assert.Equal("Point de cadrage", json.RootElement.GetProperty("Reason").GetString());
Assert.Equal((int)QueryStatus.Inserted, json.RootElement.GetProperty("Status").GetInt32());
}
[Fact]
public async Task SubmitAsync_refuses_unsupported_billing_code()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 13, ActionName = "Book", Title = "Réservation" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "book", Name = "Book" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
client);
Assert.Null(vm);
}
[Fact]
public async Task SubmitAsync_allows_missing_coordinates_and_omits_them_from_payload()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Reason = "Point de cadrage";
vm!.Address = "1 rue du Test";
vm!.Latitude = null;
vm!.Longitude = null;
vm!.Consent = true;
await vm.SubmitCommand.ExecuteAsync(null);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
var location = json.RootElement.GetProperty("Location");
Assert.Equal("1 rue du Test", location.GetProperty("Address").GetString());
Assert.False(location.TryGetProperty("Latitude", out _));
Assert.False(location.TryGetProperty("Longitude", out _));
}
[Fact]
public async Task UseCurrentLocationAsync_prefills_coordinates_from_platform_provider()
{
var original = Platform.TryGetCurrentLocationAsync;
try
{
Platform.TryGetCurrentLocationAsync = _ => Task.FromResult(CurrentLocationResult.Success(48.8566, 2.3522));
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
await vm!.UseCurrentLocationCommand.ExecuteAsync(null);
Assert.Equal(48.8566, vm!.Latitude);
Assert.Equal(2.3522, vm!.Longitude);
}
finally
{
Platform.TryGetCurrentLocationAsync = original;
}
}
[Fact]
public void ApplyLocationFromMap_sets_coordinates_and_updates_status()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
vm!.Address = string.Empty;
vm.ApplyLocationFromMap(48.85661234, 2.35224567);
Assert.Equal(48.856612, vm.Latitude);
Assert.Equal(2.352246, vm.Longitude);
Assert.Contains("Position sélectionnée", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void EventDateSelection_round_trips_with_EventDate_for_DatePicker_binding()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
var selected = new DateTimeOffset(2026, 9, 7, 14, 30, 0, TimeSpan.FromHours(2));
vm!.EventDateSelection = selected;
Assert.Equal(selected.LocalDateTime, vm.EventDate);
Assert.Equal(vm.EventDate, vm.EventDateSelection!.Value.LocalDateTime);
}
[Fact]
public void ApplyResolvedAddress_populates_empty_address_directly()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
vm!.Address = string.Empty;
vm.ApplyResolvedAddress("10 rue de Rivoli, 75001 Paris");
Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.Address);
Assert.False(vm.HasSuggestedAddress);
}
[Fact]
public void ApplyResolvedAddress_preserves_manual_address_and_exposes_suggestion()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
vm!.Address = "Saisie manuelle";
vm.ApplyResolvedAddress("10 rue de Rivoli, 75001 Paris");
Assert.Equal("Saisie manuelle", vm.Address);
Assert.True(vm.HasSuggestedAddress);
Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.SuggestedAddress);
vm.ApplySuggestedAddressCommand.Execute(null);
Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.Address);
Assert.False(vm.HasSuggestedAddress);
}
[Fact]
public async Task InitializeAsync_loads_prestations_for_brush_and_submit_posts_selected_prestation()
{
var api = new RecordingApi
{
HairPrestations = new List<HairPrestationDto>
{
new() { Id = 10, Title = "Femme · Cheveux mi-longs", Details = "Coupe · Brushing" },
new() { Id = 11, Title = "Homme · Cheveux courts", Details = "Coupe · Coiffage" },
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "brush", Name = "Brush" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
client) as BrushViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Address = "1 rue du Test";
vm!.Latitude = 48.8566;
vm!.Longitude = 2.3522;
vm!.Consent = true;
vm!.AdditionalInfo = "Prévoir shampoing";
await vm.InitializeAsync();
vm.SelectedPrestation = vm.AvailablePrestations[1];
await vm.SubmitCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/billing/Brush", api.LastPath);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
Assert.Equal(11, json.RootElement.GetProperty("PrestationId").GetInt32());
Assert.Equal("Prévoir shampoing", json.RootElement.GetProperty("AdditionalInfo").GetString());
}
[Fact]
public async Task InitializeAsync_loads_prestations_for_mbrush_and_submit_posts_selected_prestations()
{
var api = new RecordingApi
{
HairPrestations = new List<HairPrestationDto>
{
new() { Id = 21, Title = "Femme · Cheveux longs", Details = "Coupe · Couleur" },
new() { Id = 22, Title = "Enfant · Cheveux courts", Details = "Coupe · Sans technique" },
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "mbrush", Name = "MBrush" },
new ActivityUserDisplayItem { PerformerId = "perf-3", UserName = "Cara" },
client) as MBrushViewModel;
vm!.EventDate = DateTime.Parse("2026-09-03 10:00");
vm!.Address = "2 rue du Test";
vm!.Latitude = 48.8567;
vm!.Longitude = 2.3523;
vm!.Consent = true;
await vm.InitializeAsync();
vm!.MultiPrestations[0].IsSelected = true;
vm!.MultiPrestations[1].IsSelected = true;
await vm!.SubmitCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/billing/MBrush", api.LastPath);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
var prestations = json.RootElement.GetProperty("Prestations");
Assert.Equal(2, prestations.GetArrayLength());
Assert.Equal(21, prestations[0].GetProperty("PrestationId").GetInt32());
Assert.Equal(22, prestations[1].GetProperty("PrestationId").GetInt32());
}
[Fact]
public async Task InitializeAsync_with_existing_brush_query_prefills_and_submit_updates_query()
{
var api = new RecordingApi
{
HairPrestations = new List<HairPrestationDto>
{
new() { Id = 30, Title = "Femme · Cheveux longs", Details = "Coupe · Brushing" },
new() { Id = 31, Title = "Homme · Cheveux courts", Details = "Coupe" },
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "brush", Name = "Brush" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
client) as BrushViewModel;
await vm!.InitializeAsync(new BillingQueryDetailsDto
{
Id = 77,
BillingCode = "Brush",
ActivityCode = "brush",
PerformerId = "perf-2",
ClientId = "cli-1",
EventDate = new DateTime(2026, 9, 2, 14, 30, 0, DateTimeKind.Utc),
Consent = true,
Status = QueryStatus.Accepted,
PrestationId = 30,
AdditionalInfo = "Ancienne note",
Location = new BillingLocationDto
{
Address = "1 rue du Test",
Latitude = 48.8566,
Longitude = 2.3522,
}
});
vm!.SelectedPrestation = vm!.AvailablePrestations[1];
vm!.AdditionalInfo = "Note mise à jour";
await vm!.SubmitCommand.ExecuteAsync(null);
Assert.Equal(HttpMethod.Put, api.LastMethod);
Assert.Equal("https://business.example/api/v1/billing/Brush/77", api.LastPath);
Assert.True(vm.IsEditingExisting);
Assert.Equal("Mettre à jour la commande", vm.SubmitLabel);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
Assert.Equal(77, json.RootElement.GetProperty("Id").GetInt32());
Assert.Equal(31, json.RootElement.GetProperty("PrestationId").GetInt32());
Assert.Equal("Note mise à jour", json.RootElement.GetProperty("AdditionalInfo").GetString());
Assert.Equal((int)QueryStatus.Accepted, json.RootElement.GetProperty("Status").GetInt32());
}
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 List<HairPrestationDto>? HairPrestations { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
if (typeof(T) == typeof(List<HairPrestationDto>))
{
return Task.FromResult((T)(object)(HairPrestations ?? new List<HairPrestationDto>()));
}
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 ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,132 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class BillingQueriesPageViewModelTests
{
[Fact]
public async Task RefreshAsync_filters_queries_by_selected_activity_and_performer()
{
var api = new StubBillingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingQueriesPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
client);
await vm.InitializeAsync();
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths.Single());
Assert.Equal(3, vm.Queries.Count);
Assert.Contains(vm.Queries, q => q.Description == "Rendez-vous #1");
Assert.Contains("3 commande", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RefreshAsync_in_readonly_ongoing_mode_keeps_only_ongoing_statuses_and_disables_open()
{
var api = new StubBillingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingQueriesPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
client,
isReadOnly: true,
ongoingOnly: true);
await vm.InitializeAsync();
Assert.Equal(2, vm.Queries.Count);
Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase));
Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.True(vm.Queries.Count > 0);
}
private sealed class StubBillingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<BillingQuerySummaryDto>))
{
var data = new List<BillingQuerySummaryDto>
{
new()
{
Id = 11,
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Inserted,
Description = "Rendez-vous #1",
Reason = "Point de cadrage",
EventDate = new DateTime(2026, 9, 1, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 12,
ActivityCode = "other",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Accepted,
Description = "Autre activité",
EventDate = new DateTime(2026, 9, 2, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 13,
ActivityCode = "dev",
PerformerId = "perf-2",
ClientId = "cli-1",
Status = QueryStatus.Accepted,
Description = "Autre performer",
EventDate = new DateTime(2026, 9, 3, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 14,
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.InProgress,
Description = "En cours",
EventDate = new DateTime(2026, 9, 4, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 15,
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Rejected,
Description = "Rejetée",
EventDate = new DateTime(2026, 9, 5, 10, 0, 0, DateTimeKind.Utc),
}
};
return Task.FromResult((T)(object)data);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,72 @@
using System.Net;
using System.Text;
using PostIt.Services;
namespace PostIt.Tests;
public class NominatimReverseGeocodingServiceTests
{
[Fact]
public async Task TryResolveAddressAsync_formats_compact_street_address_from_nominatim_payload()
{
var handler = new StubHandler("""
{
"display_name": "6, Place de l'Hôtel-de-Ville - Esplanade de la Libération, Paris, 75004, France",
"address": {
"house_number": "6",
"road": "Place de l'Hôtel-de-Ville - Esplanade de la Libération",
"postcode": "75004",
"city": "Paris"
}
}
""");
var service = new NominatimReverseGeocodingService(new HttpClient(handler)
{
BaseAddress = new Uri("https://nominatim.openstreetmap.org/")
});
var result = await service.TryResolveAddressAsync(48.8566, 2.3522);
Assert.Equal("6, Place de l'Hôtel-de-Ville - Esplanade de la Libération, 75004, Paris", result);
Assert.NotNull(handler.LastRequest);
Assert.Contains("reverse?format=jsonv2", handler.LastRequest!.RequestUri!.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task TryResolveAddressAsync_returns_null_on_unsuccessful_response()
{
var handler = new StubHandler("{}", HttpStatusCode.TooManyRequests);
var service = new NominatimReverseGeocodingService(new HttpClient(handler)
{
BaseAddress = new Uri("https://nominatim.openstreetmap.org/")
});
var result = await service.TryResolveAddressAsync(48.8566, 2.3522);
Assert.Null(result);
}
private sealed class StubHandler : HttpMessageHandler
{
private readonly string _payload;
private readonly HttpStatusCode _statusCode;
public HttpRequestMessage? LastRequest { get; private set; }
public StubHandler(string payload, HttpStatusCode statusCode = HttpStatusCode.OK)
{
_payload = payload;
_statusCode = statusCode;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_payload, Encoding.UTF8, "application/json")
});
}
}
}

View file

@ -95,7 +95,7 @@ public class PostAclDialogTests
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
@ -123,8 +123,8 @@ public class PostAclDialogTests
var handler = new CountingHttpHandler();
var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var aclClient = new BlogAclApiClient(api, settings.ApiUrl);
var circleClient = new CircleApiClient(api, settings.ApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);

View file

@ -0,0 +1,79 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using PostIt.Views.Commands;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class RdvPageHeadlessTests
{
[AvaloniaFact]
public void Suggested_address_panel_is_hidden_by_default()
{
var page = CreatePage(out _);
var panel = page.FindControl<Grid>("SuggestedAddressPanel");
var progress = page.FindControl<ProgressBar>("SuggestedAddressProgress");
Assert.NotNull(panel);
Assert.NotNull(progress);
Assert.False(panel!.IsVisible);
Assert.False(progress!.IsVisible);
}
[AvaloniaFact]
public async Task Suggested_address_panel_and_spinner_follow_viewmodel_state()
{
var page = CreatePage(out var vm);
var panel = page.FindControl<Grid>("SuggestedAddressPanel")!;
var progress = page.FindControl<ProgressBar>("SuggestedAddressProgress")!;
var applyButton = page.FindControl<Button>("ApplySuggestedAddressButton")!;
vm.Address = "Saisie manuelle";
vm.NotifyReverseGeocodingStarted();
await Task.Delay(50);
Assert.True(panel.IsVisible);
Assert.True(progress.IsVisible);
Assert.False(applyButton.IsVisible);
vm.ApplyResolvedAddress("10 rue de Rivoli, 75001 Paris");
await Task.Delay(50);
Assert.True(panel.IsVisible);
Assert.False(progress.IsVisible);
Assert.True(applyButton.IsVisible);
}
private static RdvPage CreatePage(out RdvViewModel vm)
{
vm = new RdvViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" },
new BillingApiClient(new RecordingApi(), "https://business.example/api/v1/"));
var page = new RdvPage { DataContext = vm };
var nav = new NavigationPage();
_ = nav.PushAsync(page);
var window = new Window { Content = nav };
window.Show();
return page;
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.FromResult(default(T)!);
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,30 @@
using PostIt.Views.Commands;
namespace PostIt.Tests;
public class RdvPageTests
{
[Fact]
public void AreCoordinatesClose_returns_true_for_nearby_points_within_cache_tolerance()
{
var result = RdvPage.AreCoordinatesClose(
48.8566,
2.3522,
48.85665,
2.35225);
Assert.True(result);
}
[Fact]
public void AreCoordinatesClose_returns_false_for_points_outside_cache_tolerance()
{
var result = RdvPage.AreCoordinatesClose(
48.8566,
2.3522,
48.85685,
2.3522);
Assert.False(result);
}
}

View file

@ -89,7 +89,7 @@ public class SettingsLoadTests
settings.Authentication.RedirectUri =
global::AuthenticationSettings.DesktopRedirectUri;
settings.BusinessApiUrl = flip
settings.ApiUrl = flip
? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/";
@ -173,4 +173,51 @@ public class SettingsLoadTests
Assert.NotNull(roundTrip);
Assert.Equal("bonjour", roundTrip.SearchText);
}
/// <summary>
/// First-start regression guard: older settings payloads can
/// still contain <c>ActionStatus</c> from a previous write. This
/// runtime-only UI state must not be persisted anymore and must
/// not break deserialization when present.
/// </summary>
[Fact]
public void ActionStatus_is_not_persisted_and_legacy_payload_with_it_still_deserializes()
{
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://example.test/",
ClientId = "postit-tests",
Scopes = new[] { "openid" }
}
};
var serialized = JsonSerializer.Serialize(settings);
Assert.DoesNotContain("\"ActionStatus\"", serialized, StringComparison.Ordinal);
const string legacyPayload = """
{
"Authentication": {
"Authority": "https://example.test/",
"ClientId": "postit-tests",
"Scopes": ["openid"],
"RedirectUri": "postit://callback"
},
"DarkMode": false,
"BlogsApiUrl": "https://blogs.example.test/api/v1/",
"ApiUrl": "https://api.example.test/api/v1/",
"SearchText": "hello",
"ActionStatus": {
"Message": "runtime only",
"Severity": "Error"
}
}
""";
var roundTrip = JsonSerializer.Deserialize<PostIt.ViewModels.Settings>(legacyPayload);
Assert.NotNull(roundTrip);
Assert.Equal("hello", roundTrip.SearchText);
}
}

View file

@ -94,6 +94,26 @@ public class SignaturePadControlTests
Assert.NotEqual(first.Strokes, third.Strokes);
}
[Fact]
public void PendingStroke_is_exposed_only_while_capturing()
{
var pad = new SignaturePadControl();
Assert.Empty(pad.PendingStroke);
pad.BeginCaptureForTest();
pad.AppendPointForTest(1_000, 2_000);
pad.AppendPointForTest(3_000, 4_000);
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.PendingStroke);
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
pad.SealStrokeForTest();
Assert.Empty(pad.PendingStroke);
Assert.Equal(new[] { 2, 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
}
[Fact]
public void Clear_empties_buffer_and_raises_redraw()
{

View file

@ -58,7 +58,7 @@ public class YavscApiClientTests
// calls CallAsync("posts", ...) directly (bypassing
// BlogApiClient, which is the only thing that would set
// it in production). Mirror prod here.
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
reloaded.Http.BaseAddress = new Uri(settings.ApiUrl);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@ -118,7 +118,7 @@ public class YavscApiClientTests
RedirectUri = "postit://callback",
Scopes = new[] { "openid" },
},
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
ApiUrl = "https://127.0.0.1:5003/api/v1",
};
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
@ -162,7 +162,7 @@ public class YavscApiClientTests
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
},
BusinessApiUrl = apiBaseUrl
ApiUrl = apiBaseUrl
};
private static async Task<YavscApiClient> LoginAndPersistAsync(
@ -175,7 +175,7 @@ public class YavscApiClientTests
// directly (bypassing BlogApiClient) rely on the same
// BaseAddress the production chain sets in BlogApiClient's
// ctor. Mirror that here so "posts" resolves to the stub.
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
// Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper.

View file

@ -86,11 +86,6 @@ private void ConfigureRootView(MainView rootView)
rootView.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += async () =>
{
await PushMainPageAsync();
};
rootView.SessionBanner.DataContext = sessionStatus;
}
@ -140,9 +135,6 @@ private void ConfigureRootView(MainView rootView)
var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!;
await app.PushPageAsync(homePage);
if (!refreshed) return;
await PushMainPageAsync().ConfigureAwait(true);
}
/// <summary>
@ -153,7 +145,7 @@ private void ConfigureRootView(MainView rootView)
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// </summary>
public static async Task PushMainPageAsync()
public static async Task PushBlogsPageAsync()
{
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();

View file

@ -68,21 +68,38 @@ public class SignaturePadControl : TemplatedControl
public event EventHandler? RedrawRequested;
private readonly List<int> _strokes = new(capacity: 256);
private InputElement? _wiredCaptureArea;
private int _pendingPoints; // number of (x, y) pairs awaiting a length prefix
private bool _capturing;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
RewireCaptureArea();
}
if (CaptureArea is { } previous)
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == CaptureAreaProperty)
{
RewireCaptureArea();
}
}
private void RewireCaptureArea()
{
if (_wiredCaptureArea is { } previous)
{
previous.PointerPressed -= OnCapturePressed;
previous.PointerMoved -= OnCaptureMoved;
previous.PointerReleased -= OnCaptureReleased;
}
if (CaptureArea is { } area)
_wiredCaptureArea = CaptureArea;
if (_wiredCaptureArea is { } area)
{
area.PointerPressed += OnCapturePressed;
area.PointerMoved += OnCaptureMoved;
@ -97,12 +114,14 @@ public class SignaturePadControl : TemplatedControl
_capturing = true;
_pendingPoints = 0;
AppendPoint(e.GetPosition(CaptureArea));
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
private void OnCaptureMoved(object? sender, PointerEventArgs e)
{
if (!_capturing) return;
AppendPoint(e.GetPosition(CaptureArea));
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
private void OnCaptureReleased(object? sender, PointerReleasedEventArgs e)
@ -169,6 +188,16 @@ public class SignaturePadControl : TemplatedControl
/// </summary>
public SignaturePadData Snapshot() => new(_strokes.ToArray());
/// <summary>
/// Copy of the current in-progress stroke, without the length
/// prefix used for sealed strokes. The view can render this as a
/// live preview while the user is still drawing.
/// </summary>
internal IReadOnlyList<int> PendingStroke
=> _capturing && _pendingPoints > 0
? _strokes.GetRange(_strokes.Count - 2 * _pendingPoints, 2 * _pendingPoints)
: Array.Empty<int>();
// --- Test-only surface (visible to PostIt.Tests) -------------------
/// <summary>
@ -183,6 +212,17 @@ public class SignaturePadControl : TemplatedControl
_pendingPoints++;
}
/// <summary>
/// Test hook: mark the control as actively capturing so tests
/// can exercise the live-preview path without synthetic pointer
/// events.
/// </summary>
internal void BeginCaptureForTest()
{
_capturing = true;
_pendingPoints = 0;
}
/// <summary>
/// Test hook: seal the currently-pending stroke with a length
/// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at

View file

@ -0,0 +1,22 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Controls.StatusBar"
x:DataType="vm:StatusNotice">
<Border Background="{Binding Background}"
BorderBrush="{Binding BorderBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Glyph}"
FontWeight="Bold"
Foreground="{Binding Foreground}"
VerticalAlignment="Top"/>
<TextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}"
TextWrapping="Wrap"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</UserControl>

View file

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

View file

@ -0,0 +1,50 @@
using System;
using PostIt.ViewModels;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
public static class FormHelpers
{
public static BillingCommandPageViewModel?
CreateCommandPageViewModel(
this CommandFormSummary form,
ActivityInfo activity,
ActivityUserDisplayItem performer,
BillingApiClient billingClient)
{
string namespacePrefix = typeof(PostIt.ViewModels.Commands.RdvViewModel).Namespace + ".";
string formVMName = form.ActionName + "ViewModel";
string formOnActivityVMName = activity.Code + formVMName + "ViewModel";
var vmType = Type.GetType(namespacePrefix +formOnActivityVMName);
if (vmType == null)
{
vmType = Type.GetType(namespacePrefix + formVMName);
}
if (vmType == null)
{
Console.Error.WriteLine(
$"! Cannot find type '{formOnActivityVMName}' or '{formVMName}'");
return null;
}
if (!typeof(BillingCommandPageViewModel).IsAssignableFrom(vmType))
{
Console.Error.WriteLine($"! The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel");
return null;
}
var vm = Activator.CreateInstance(vmType, activity, performer, form, billingClient);
if (vm == null)
{
throw new InvalidOperationException($"Cannot create instance of '{formOnActivityVMName}' or '{formVMName}'");
}
return vm as BillingCommandPageViewModel ?? throw new InvalidOperationException($"The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel");
}
}

View file

@ -0,0 +1,34 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace PostIt.Helpers;
public static class ImageHelper
{
private static readonly HttpClient HttpClient = new();
public static Bitmap LoadFromResource(Uri resourceUri)
{
return new Bitmap(AssetLoader.Open(resourceUri));
}
public static async Task<Bitmap?> LoadFromWeb(Uri url)
{
try
{
var response = await HttpClient.GetAsync(url).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var data = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
return new Bitmap(new MemoryStream(data));
}
catch (HttpRequestException ex)
{
Console.WriteLine($"An error occurred while downloading image '{url}': {ex.Message}");
return null;
}
}
}

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Commands;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
@ -23,7 +24,13 @@ public static class ServiceCollectionHelpers
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient(
api,
() => settings.ApiUrl,
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues
services.AddSingleton<MainView>();
@ -45,6 +52,12 @@ public static class ServiceCollectionHelpers
services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>();
services.AddTransient<RdvPage>();
services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>();
services.AddTransient<BillingQueryDetailsPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
@ -52,10 +65,15 @@ public static class ServiceCollectionHelpers
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>();
services.AddTransient<SelectableHairPrestationItem>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or

View file

@ -29,6 +29,8 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="Mapsui" />
<PackageReference Include="Mapsui.Avalonia12" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />

View file

@ -0,0 +1,28 @@
namespace PostIt.Services;
public sealed class CurrentLocationResult
{
private CurrentLocationResult(bool isSuccess, bool isPermissionDenied, double? latitude, double? longitude, string message)
{
IsSuccess = isSuccess;
IsPermissionDenied = isPermissionDenied;
Latitude = latitude;
Longitude = longitude;
Message = message;
}
public bool IsSuccess { get; }
public bool IsPermissionDenied { get; }
public double? Latitude { get; }
public double? Longitude { get; }
public string Message { get; }
public static CurrentLocationResult Success(double latitude, double longitude, string? message = null)
=> new(true, false, latitude, longitude, message ?? "Position récupérée.");
public static CurrentLocationResult PermissionDenied(string? message = null)
=> new(false, true, null, null, message ?? "La géolocalisation n'est pas autorisée.");
public static CurrentLocationResult Unavailable(string? message = null)
=> new(false, false, null, null, message ?? "La géolocalisation n'est pas disponible sur cette plateforme.");
}

View file

@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
public interface IReverseGeocodingService
{
Task<string?> TryResolveAddressAsync(double latitude, double longitude, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
public sealed class NominatimReverseGeocodingService : IReverseGeocodingService
{
private static readonly Uri BaseUri = new("https://nominatim.openstreetmap.org/");
private readonly HttpClient _httpClient;
public NominatimReverseGeocodingService(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? CreateDefaultClient();
}
public async Task<string?> TryResolveAddressAsync(double latitude, double longitude, CancellationToken cancellationToken = default)
{
var requestUri = BuildReverseUri(latitude, longitude);
try
{
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
return null;
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return FormatAddress(json.RootElement);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return null;
}
}
private static HttpClient CreateDefaultClient()
{
var client = new HttpClient
{
BaseAddress = BaseUri,
Timeout = TimeSpan.FromSeconds(10),
};
client.DefaultRequestHeaders.UserAgent.Clear();
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PostIt", "1.1"));
client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("fr-FR"));
client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("fr", 0.9));
return client;
}
private static Uri BuildReverseUri(double latitude, double longitude)
{
var lat = latitude.ToString("0.######", CultureInfo.InvariantCulture);
var lon = longitude.ToString("0.######", CultureInfo.InvariantCulture);
var path = $"reverse?format=jsonv2&addressdetails=1&accept-language=fr&zoom=18&lat={lat}&lon={lon}";
return new Uri(path, UriKind.Relative);
}
private static string? FormatAddress(JsonElement root)
{
if (root.TryGetProperty("address", out var address))
{
var street = JoinNonEmpty(
TryGetString(address, "house_number"),
TryGetString(address, "road"));
var locality = JoinNonEmpty(
TryGetString(address, "postcode"),
TryGetString(address, "city")
?? TryGetString(address, "town")
?? TryGetString(address, "village")
?? TryGetString(address, "municipality"));
var formatted = JoinNonEmpty(street, locality);
if (!string.IsNullOrWhiteSpace(formatted))
return formatted;
}
if (root.TryGetProperty("display_name", out var displayName))
{
var value = displayName.GetString();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
return null;
}
private static string? TryGetString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property)
? property.GetString()
: null;
}
private static string? JoinNonEmpty(params string?[] values)
{
List<string>? parts = null;
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
continue;
parts ??= new List<string>();
parts.Add(value.Trim());
}
return parts is null || parts.Count == 0 ? null : string.Join(", ", parts);
}
}

View file

@ -1,4 +1,7 @@
using System;
using IdentityModel.OidcClient.Browser;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
@ -37,4 +40,12 @@ public static class Platform
/// </summary>
public static System.Func<IBrowser?>? CreateBrowser { get; set; } =
() => new CustomSchemeBrowser(CustomScheme);
/// <summary>
/// Optional platform hook used by the shared billing form to request a
/// current device position. Platforms that do not expose a native
/// location provider can leave the default delegate in place.
/// </summary>
public static Func<CancellationToken, Task<CurrentLocationResult>> TryGetCurrentLocationAsync { get; set; } =
_ => Task.FromResult(CurrentLocationResult.Unavailable());
}

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@ -351,6 +352,77 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_store.Save(_tokens);
}
/// <summary>
/// Upload a user avatar to the Yavsc API. The server expects a
/// single multipart file named <c>file</c> and validates the image
/// content type before persisting it.
/// </summary>
public async Task<string> SetAvatarAsync(
Stream imageStream,
string fileName,
string? contentType = null,
CancellationToken ct = default)
{
if (imageStream is null)
throw new ArgumentNullException(nameof(imageStream));
if (string.IsNullOrWhiteSpace(fileName))
throw new ArgumentException("A file name is required.", nameof(fileName));
var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var attemptUpload = async () =>
{
if (imageStream.CanSeek)
imageStream.Position = 0;
using var content = new MultipartFormDataContent();
using var fileContent = new StreamContent(imageStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
content.Add(fileContent, "file", fileName);
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content,
};
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(payload))
return "Avatar mis à jour.";
try
{
using var json = JsonDocument.Parse(payload);
if (json.RootElement.TryGetProperty("message", out var msgEl))
{
var message = msgEl.GetString();
if (!string.IsNullOrWhiteSpace(message))
return message;
}
}
catch (JsonException)
{
// Keep a user-friendly fallback when the API payload is not JSON.
}
return "Avatar mis à jour.";
}
public async Task LogoutAsync()
{
_store.Clear();

View file

@ -4,7 +4,9 @@ using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using PostIt.Views;
using PostIt.Views.Commands;
namespace PostIt;
@ -17,7 +19,7 @@ namespace PostIt;
public class ViewLocator : IDataTemplate
{
public Control Build(object? data)
public Control Build(object? data)
{
try
{
@ -39,14 +41,20 @@ public class ViewLocator : IDataTemplate
MainViewModel => services.GetRequiredService<MainPage>(),
Settings => services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
BrushViewModel => services.GetRequiredService<BrushPage>(),
RdvViewModel => services.GetRequiredService<RdvPage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(),
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
}
public bool Match(object? data) => data is ViewModelBase;
public bool Match(object? data) => data is ViewModelBase;
}

View file

@ -29,7 +29,7 @@ namespace PostIt.ViewModels;
/// <c>CirclesPage</c> then calls
/// <see cref="CircleApiClient.AddMemberAsync"/>.</para>
/// </summary>
public partial class AddCircleMemberDialogViewModel : ViewModelBase
public partial class AddCircleMemberDialogViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly IUserDirectory _directory;
@ -46,7 +46,10 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
public partial string StatusMessage { get; set; } = "Pret.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
/// <summary>
/// Raised when the user confirms a selection. The hosting
@ -79,7 +82,7 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
if (string.IsNullOrWhiteSpace(SearchQuery))
{
Results.Clear();
StatusMessage = "Tapez un nom ou un email";
this.SetWarningStatus("Tapez un nom ou un email");
return;
}
@ -88,11 +91,11 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
{
var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true);
Results = new ObservableCollection<UserSummary>(hits ?? Array.Empty<UserSummary>());
StatusMessage = $"{Results.Count} résultat(s)";
this.SetInfoStatus($"{Results.Count} résultat(s)");
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
@ -110,18 +113,20 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
{
if (Selected is null)
{
StatusMessage = "Sélectionnez un utilisateur";
this.SetWarningStatus("Sélectionnez un utilisateur");
return;
}
Confirmed?.Invoke(this, Selected);
var app = App.Current as App;
var app = App.Current as App
?? throw new InvalidOperationException("Application PostIt indisponible.");
await app.GoBackAsync();
}
[RelayCommand]
public async Task CloseAsync()
{
var app = App.Current as App;
var app = App.Current as App
?? throw new InvalidOperationException("Application PostIt indisponible.");
await app.GoBackAsync();
}
}

View file

@ -34,7 +34,7 @@ namespace PostIt.ViewModels;
/// <see cref="OnAddMemberConfirmedAsync"/>. The "remove"
/// command is per-row and runs inline.</para>
/// </summary>
public partial class CirclesPageViewModel : ViewModelBase
public partial class CirclesPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly CircleApiClient _client;
@ -63,8 +63,10 @@ public partial class CirclesPageViewModel : ViewModelBase
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
public partial string StatusMessage { get; set; } = "Pret.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
public CirclesPageViewModel(CircleApiClient client)
{
@ -100,11 +102,11 @@ public partial class CirclesPageViewModel : ViewModelBase
{
var list = await _client.GetMyCirclesAsync();
Circles = new ObservableCollection<CircleDto>(list ?? new());
StatusMessage = $"{Circles.Count} cercle(s)";
this.SetInfoStatus($"{Circles.Count} cercle(s)");
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
@ -115,8 +117,10 @@ public partial class CirclesPageViewModel : ViewModelBase
[RelayCommand]
internal async Task OpenAddMemberAsync()
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
var app = Application.Current as App
?? throw new InvalidOperationException("Application PostIt indisponible.");
var services = app.ServiceProvider
?? throw new InvalidOperationException("ServiceProvider PostIt indisponible.");
var directory = services.GetRequiredService<IUserDirectory>();
AddCircleMemberDialogViewModel model =
new AddCircleMemberDialogViewModel(directory);
@ -147,11 +151,11 @@ public partial class CirclesPageViewModel : ViewModelBase
{
var list = await _client.GetMembersAsync(circleId);
Members = new ObservableCollection<CircleMemberDto>(list ?? new());
StatusMessage = $"{Members.Count} membre(s)";
this.SetInfoStatus($"{Members.Count} membre(s)");
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
Members = new ObservableCollection<CircleMemberDto>();
}
finally
@ -166,7 +170,7 @@ public partial class CirclesPageViewModel : ViewModelBase
SelectedCircle = null;
DraftName = string.Empty;
DraftPublic = false;
StatusMessage = "Nouveau cercle";
this.SetInfoStatus("Nouveau cercle");
}
[RelayCommand]
@ -176,7 +180,7 @@ public partial class CirclesPageViewModel : ViewModelBase
SelectedCircle = circle;
DraftName = circle.Name;
DraftPublic = circle.Public;
StatusMessage = $"Édition de « {circle.Name} »";
this.SetInfoStatus($"Édition de « {circle.Name} »");
}
[RelayCommand]
@ -184,7 +188,7 @@ public partial class CirclesPageViewModel : ViewModelBase
{
if (string.IsNullOrWhiteSpace(DraftName))
{
StatusMessage = "Le nom est obligatoire";
this.SetWarningStatus("Le nom est obligatoire");
return;
}
@ -198,22 +202,22 @@ public partial class CirclesPageViewModel : ViewModelBase
Name = DraftName.Trim(),
Public = DraftPublic,
});
StatusMessage = created is null
? "Création échouée"
: $"Cercle « {created.Name} » créé";
this.SetStatus(
created is null ? "Création échouée" : $"Cercle « {created.Name} » créé",
created is null ? StatusSeverity.Warning : StatusSeverity.Info);
}
else
{
SelectedCircle.Name = DraftName.Trim();
SelectedCircle.Public = DraftPublic;
await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle);
StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour";
this.SetInfoStatus($"Cercle « {SelectedCircle.Name} » mis à jour");
}
await RefreshAsync();
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
@ -229,7 +233,7 @@ public partial class CirclesPageViewModel : ViewModelBase
try
{
await _client.DeleteCircleAsync(circle.Id);
StatusMessage = $"Cercle « {circle.Name} » supprimé";
this.SetInfoStatus($"Cercle « {circle.Name} » supprimé");
// If the deleted circle was the selected one,
// clear the selection so the Members view goes
// empty too (the partial setter on
@ -240,7 +244,7 @@ public partial class CirclesPageViewModel : ViewModelBase
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
@ -261,7 +265,7 @@ public partial class CirclesPageViewModel : ViewModelBase
try
{
await _client.AddMemberAsync(SelectedCircle.Id, picked.Id);
StatusMessage = $"« {picked.DisplayName} » ajouté au cercle";
this.SetInfoStatus($"« {picked.DisplayName} » ajouté au cercle");
await LoadMembersAsync(SelectedCircle.Id);
}
catch (Exception ex)
@ -276,7 +280,7 @@ public partial class CirclesPageViewModel : ViewModelBase
var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict")
? "Déjà membre du cercle"
: $"Erreur: {ex.Message}";
StatusMessage = msg;
this.SetStatus(msg, msg == "Déjà membre du cercle" ? StatusSeverity.Warning : StatusSeverity.Error);
}
finally
{
@ -297,11 +301,11 @@ public partial class CirclesPageViewModel : ViewModelBase
{
await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id);
Members.Remove(member);
StatusMessage = $"« {member.UserName} » retiré du cercle";
this.SetInfoStatus($"« {member.UserName} » retiré du cercle");
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{

View file

@ -37,7 +37,7 @@ public sealed class PostAclEntry
/// any 403 / 404 will surface as an exception caught by the
/// command and routed to <see cref="StatusMessage"/>.</para>
/// </summary>
public partial class PostAclDialogViewModel : ViewModelBase
public partial class PostAclDialogViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly BlogAclApiClient _aclClient;
private readonly CircleApiClient _circleClient;
@ -61,7 +61,10 @@ public partial class PostAclDialogViewModel : ViewModelBase
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
public partial string StatusMessage { get; set; } = "Pret.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
/// <summary>
/// Idempotency gate for <see cref="LoadAsync"/>: the dialog
@ -115,12 +118,12 @@ public partial class PostAclDialogViewModel : ViewModelBase
AclEntries = new ObservableCollection<PostAclEntry>(AclEntries.Select(a => ToAclEntry(a.CircleId)));
StatusMessage = $"{AclEntries.Count} autorisation(s)";
this.SetInfoStatus($"{AclEntries.Count} autorisation(s)");
_loaded = true;
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
@ -133,7 +136,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
{
if (SelectedCircleToAdd is null)
{
StatusMessage = "Sélectionnez un cercle à ajouter";
this.SetWarningStatus("Sélectionnez un cercle à ajouter");
return;
}
@ -142,7 +145,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
{
if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id))
{
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
this.SetWarningStatus($"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé");
return;
}
@ -154,11 +157,11 @@ public partial class PostAclDialogViewModel : ViewModelBase
if (created is not null)
{
AclEntries.Add(ToAclEntry(created.CircleId));
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
this.SetInfoStatus($"Cercle « {SelectedCircleToAdd.Name} » autorisé");
}
else
{
StatusMessage = "Autorisation refusée par le serveur";
this.SetWarningStatus("Autorisation refusée par le serveur");
}
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
@ -166,11 +169,11 @@ public partial class PostAclDialogViewModel : ViewModelBase
// Conflict means the link already exists in backend. Resync
// from the dedicated ACL API so the UI reflects server truth.
await ReloadAclEntriesFromServerAsync();
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
this.SetWarningStatus($"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé");
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
@ -189,11 +192,11 @@ public partial class PostAclDialogViewModel : ViewModelBase
var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId);
if (existing is not null)
AclEntries.Remove(existing);
StatusMessage = "Autorisation révoquée";
this.SetInfoStatus("Autorisation révoquée");
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{

View file

@ -0,0 +1,278 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ActivitiesPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly ActivityApiClient _client;
private readonly BillingApiClient _billingClient;
private bool _syncingSelection;
[ObservableProperty]
public partial ObservableCollection<ActivityInfo> Activities { get; set; } = new();
[ObservableProperty]
public partial ActivityInfo? SelectedActivity { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityInfo> Specializations { get; set; } = new();
[ObservableProperty]
public partial ActivityInfo? SelectedSpecialization { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityUserDisplayItem> Performers { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenCommandFormsCommand))]
public partial ActivityUserDisplayItem? SelectedPerformer { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Choisissez une activité.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Choisissez une activité.");
public ActivityInfo? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)";
public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)";
public int CurrentFormCount => CurrentActivity?.Forms?.Count ?? 0;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public ActivitiesPageViewModel(ActivityApiClient client, BillingApiClient billingClient)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
}
partial void OnSelectedActivityChanged(ActivityInfo? value)
{
if (_syncingSelection) return;
_ = ShowActivitySafeAsync(value);
}
partial void OnSelectedSpecializationChanged(ActivityInfo? value)
{
if (_syncingSelection) return;
_ = ShowSpecializationSafeAsync(value);
}
private async Task ShowActivitySafeAsync(ActivityInfo? value)
{
try
{
await ShowActivityAsync(value);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur: {ex.Message}");
}
}
private async Task ShowSpecializationSafeAsync(ActivityInfo? value)
{
try
{
await ShowSpecializationAsync(value);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur: {ex.Message}");
}
}
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _client.GetCatalogAsync();
Activities = new ObservableCollection<ActivityInfo>(list ?? new());
var first = Activities.FirstOrDefault();
await ShowActivityAsync(first);
if (first is null)
{
this.SetInfoStatus("Aucune activité disponible.");
}
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Activities = new ObservableCollection<ActivityInfo>();
Specializations = new ObservableCollection<ActivityInfo>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
this.SetWarningStatus("Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
Activities = new ObservableCollection<ActivityInfo>();
Specializations = new ObservableCollection<ActivityInfo>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
public async Task ShowActivityAsync(ActivityInfo? activity)
{
_syncingSelection = true;
try
{
SelectedActivity = activity;
SelectedSpecialization = null;
}
finally
{
_syncingSelection = false;
}
OnPropertyChanged(nameof(CurrentActivity));
OnPropertyChanged(nameof(SelectedActivityLabel));
OnPropertyChanged(nameof(CurrentActivityLabel));
OnPropertyChanged(nameof(CurrentFormCount));
Specializations = new ObservableCollection<ActivityInfo>(activity?.Children ?? new());
if (activity is null)
{
Performers = new ObservableCollection<ActivityUserDisplayItem>();
SelectedPerformer = null;
return;
}
await LoadPerformersAsync(activity);
}
public async Task ShowSpecializationAsync(ActivityInfo? specialization)
{
_syncingSelection = true;
try
{
SelectedSpecialization = specialization;
}
finally
{
_syncingSelection = false;
}
OnPropertyChanged(nameof(CurrentActivity));
OnPropertyChanged(nameof(CurrentActivityLabel));
OnPropertyChanged(nameof(CurrentFormCount));
if (specialization is null)
{
if (SelectedActivity is not null)
{
await LoadPerformersAsync(SelectedActivity);
}
return;
}
await LoadPerformersAsync(specialization);
}
private async Task LoadPerformersAsync(ActivityInfo activity)
{
IsBusy = true;
try
{
var list = await _client.GetUsersAsync(activity.Code);
var items = (list ?? new())
.Select(dto => ActivityUserDisplayItem.FromDto(dto, _client.BuildAvatarXsUrl(dto.UserName)))
.ToList();
await Task.WhenAll(items.Select(async item =>
{
if (string.IsNullOrWhiteSpace(item.AvatarXsUrl))
{
return;
}
if (!Uri.TryCreate(item.AvatarXsUrl, UriKind.Absolute, out var avatarUri))
{
return;
}
item.AvatarImage = await ImageHelper.LoadFromWeb(avatarUri);
}));
Performers = new ObservableCollection<ActivityUserDisplayItem>(items);
SelectedPerformer = null;
this.SetInfoStatus($"{activity.Name} · {Performers.Count} utilisateur(s)");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Performers = new ObservableCollection<ActivityUserDisplayItem>();
SelectedPerformer = null;
this.SetWarningStatus("Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
Performers = new ObservableCollection<ActivityUserDisplayItem>();
SelectedPerformer = null;
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
OpenCommandFormsCommand.NotifyCanExecuteChanged();
}
}
private bool CanOpenCommandForms()
=> SelectedPerformer is not null && CurrentActivity?.Forms?.Count > 0;
[RelayCommand(CanExecute = nameof(CanOpenCommandForms))]
private async Task OpenCommandFormsAsync()
{
if (SelectedPerformer is null || CurrentActivity is null)
{
this.SetWarningStatus("Sélectionnez un utilisateur et une activité avec formulaire.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new CommandFormsPageViewModel(CurrentActivity, SelectedPerformer, _billingClient);
await app.PushPageAsync(vm);
}
}

View file

@ -0,0 +1,50 @@
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
namespace PostIt.ViewModels;
public sealed partial class ActivityUserDisplayItem : ObservableObject
{
public string PerformerId { get; init; } = string.Empty;
public string AvatarXsUrl { get; init; } = string.Empty;
public bool HasPerformerProfile { get; init; }
public string PerformerBadgeLabel { get; init; } = "Profil pro";
public bool IsPerformerActive { get; init; }
public string PerformerStatusBadgeLabel { get; init; } = "Inactif";
public string PerformerStatusBadgeBackground { get; init; } = "#FDECEA";
public string PerformerStatusBadgeBorder { get; init; } = "#C62828";
public string PerformerStatusBadgeForeground { get; init; } = "#8E0000";
public string UserName { get; init; } = string.Empty;
public string AvatarFallbackLabel { get; init; } = "?";
public string WebSite { get; init; } = string.Empty;
public int ExtraActivityCount { get; init; }
public string ExtraActivityLabel { get; init; } = "Pas d'autre activité";
[ObservableProperty]
public partial Bitmap? AvatarImage { get; set; }
public static ActivityUserDisplayItem FromDto(PerformerActivity dto, string avatarXsUrl)
{
return new ActivityUserDisplayItem
{
PerformerId = dto.PerformerId,
AvatarXsUrl = avatarXsUrl,
HasPerformerProfile = dto.HasPerformerProfile,
UserName = dto.UserName,
AvatarFallbackLabel = string.IsNullOrWhiteSpace(dto.UserName)
? "?"
: dto.UserName.Trim()[0].ToString().ToUpperInvariant(),
WebSite = dto.WebSite,
IsPerformerActive = dto.Active,
PerformerStatusBadgeLabel = dto.Active ? "Actif" : "Inactif",
PerformerStatusBadgeBackground = dto.Active ? "#E6F7EC" : "#FDECEA",
PerformerStatusBadgeBorder = dto.Active ? "#2E7D32" : "#C62828",
PerformerStatusBadgeForeground = dto.Active ? "#1B5E20" : "#8E0000",
ExtraActivityCount = dto.ExtraActivityCount,
ExtraActivityLabel = dto.ExtraActivityCount == 0
? "Pas d'autre activité"
: $"Autres spécialisations: {dto.ExtraActivityCount}"
};
}
}

View file

@ -0,0 +1,175 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc;
using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow;
namespace PostIt.ViewModels;
public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly BillingApiClient _billingClient;
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; }
public bool IsReadOnly { get; }
public bool OngoingOnly { get; }
[ObservableProperty]
public partial ObservableCollection<BillingQueryDisplayItem> Queries { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
public partial BillingQueryDisplayItem? SelectedQuery { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement des commandes...";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des commandes...");
public string Title => IsReadOnly
? $"Demandes en cours ({Form.Title})"
: $"Commandes {Form.Title}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
public bool CanOpenDetails => true;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public BillingQueriesPageViewModel(
ActivityInfo activity,
ActivityUserDisplayItem performer,
CommandFormSummary form,
BillingApiClient billingClient,
bool isReadOnly = false,
bool ongoingOnly = false)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
IsReadOnly = isReadOnly;
OngoingOnly = ongoingOnly;
}
public Task InitializeAsync() => RefreshAsync();
private bool CanOpenSelectedQuery() => SelectedQuery is not null;
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _billingClient.GetQuerySummariesAsync(Form.ActionName).ConfigureAwait(true);
var filtered = (list ?? new())
.Where(q => q.ActivityCode == Activity.Code && q.PerformerId == Performer.PerformerId)
.Where(q => !OngoingOnly || IsOngoingStatus(q.Status))
.OrderByDescending(q => q.EventDate ?? DateTime.MinValue)
.ThenByDescending(q => q.Id)
.Select(BillingQueryDisplayItem.FromDto)
.ToList();
Queries = new ObservableCollection<BillingQueryDisplayItem>(filtered);
this.SetInfoStatus(BuildLoadedStatusMessage(filtered.Count));
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Queries = new ObservableCollection<BillingQueryDisplayItem>();
this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
Queries = new ObservableCollection<BillingQueryDisplayItem>();
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))]
public async Task OpenSelectedQueryAsync()
{
if (SelectedQuery is null)
{
this.SetWarningStatus("Sélectionnez une commande.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
IsBusy = true;
try
{
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var vm = new BillingQueryDetailsPageViewModel(
Activity,
Performer,
Form,
_billingClient,
details,
IsReadOnly);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'ouverture: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private string BuildLoadedStatusMessage(int count)
{
if (count == 0)
{
return OngoingOnly
? "Aucune demande en cours pour ce formulaire."
: "Aucune commande trouvée pour ce formulaire.";
}
if (OngoingOnly)
{
return $"{count} demande(s) en cours chargée(s) (lecture seule).";
}
return $"{count} commande(s) chargée(s).";
}
private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
}

View file

@ -0,0 +1,208 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly BillingApiClient _billingClient;
private readonly BillingQueryDetailsDto _details;
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; }
public bool IsReadOnly { get; }
public long Id => _details.Id;
public string Title => $"Detail commande #{_details.Id}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name} · {Form.Title}";
public string StatusLabel => _details.Status.ToString();
public string StatusGlyph => GetStatusGlyph(_details.Status);
public string StatusBadgeBackground => GetStatusBadgeBackground(_details.Status);
public string StatusBadgeBorder => GetStatusBadgeBorder(_details.Status);
public string StatusBadgeForeground => GetStatusBadgeForeground(_details.Status);
public string TitleForeground => StatusBadgeForeground;
public string BillingCode => _details.BillingCode;
public string Description => EmptyAsPlaceholder(_details.Description, "(sans description)");
public string Reason => EmptyAsPlaceholder(_details.Reason, "(aucun motif)");
public string AdditionalInfo => EmptyAsPlaceholder(_details.AdditionalInfo, "(aucune info complementaire)");
public string ClientId => EmptyAsPlaceholder(_details.ClientId, "(non renseigne)");
public string EventDateLabel => _details.EventDate?.ToLocalTime().ToString("f") ?? "Date non precisee";
public string ConsentLabel => _details.Consent ? "Oui" : "Non";
public string ProvisionalLabel => _details.Provisional.HasValue ? _details.Provisional.Value.ToString("0.00") : "(non renseigne)";
public string LocationLabel => BuildLocationLabel(_details.Location);
public string PrestationsLabel => BuildPrestationsLabel(_details);
public bool CanEdit => !IsReadOnly;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Pret.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public BillingQueryDetailsPageViewModel(
ActivityInfo activity,
ActivityUserDisplayItem performer,
CommandFormSummary form,
BillingApiClient billingClient,
BillingQueryDetailsDto details,
bool isReadOnly)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly;
this.SetInfoStatus("Details de commande charges.");
}
[RelayCommand]
private async Task OpenEditorAsync()
{
if (IsReadOnly)
{
this.SetWarningStatus("Mode lecture seule: edition desactivee.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
IsBusy = true;
try
{
var vm = Form.CreateCommandPageViewModel(Activity, Performer, _billingClient);
if (vm is null)
{
this.SetWarningStatus("Ce formulaire n'est pas encore pris en charge en edition.");
return;
}
await vm.InitializeAsync(_details).ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'ouverture en edition: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.GoBackAsync().ConfigureAwait(true);
}
private static string EmptyAsPlaceholder(string? value, string placeholder)
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
private static string BuildLocationLabel(BillingLocationDto? location)
{
if (location is null)
{
return "(non renseignee)";
}
var text = EmptyAsPlaceholder(location.Address, "adresse vide");
if (location.Latitude.HasValue && location.Longitude.HasValue)
{
text += $" ({location.Latitude.Value:0.####}, {location.Longitude.Value:0.####})";
}
return text;
}
private static string BuildPrestationsLabel(BillingQueryDetailsDto details)
{
if (details.PrestationIds.Count > 0)
{
return string.Join(", ", details.PrestationIds.Select(static id => id.ToString()));
}
return details.PrestationId.HasValue
? details.PrestationId.Value.ToString()
: "(aucune)";
}
private static string GetStatusBadgeBackground(QueryStatus status)
=> status switch
{
QueryStatus.Accepted => "#E6F7EC",
QueryStatus.InProgress => "#FFF4D6",
QueryStatus.Rejected => "#FDECEA",
QueryStatus.Failed => "#ECEFF1",
QueryStatus.Success => "#E8F8EF",
_ => "#EAF3FF",
};
private static string GetStatusBadgeBorder(QueryStatus status)
=> status switch
{
QueryStatus.Accepted => "#2E7D32",
QueryStatus.InProgress => "#B26A00",
QueryStatus.Rejected => "#C62828",
QueryStatus.Failed => "#607D8B",
QueryStatus.Success => "#1E8E3E",
_ => "#2A5EA8",
};
private static string GetStatusBadgeForeground(QueryStatus status)
=> status switch
{
QueryStatus.Accepted => "#1B5E20",
QueryStatus.InProgress => "#7A4A00",
QueryStatus.Rejected => "#8E0000",
QueryStatus.Failed => "#37474F",
QueryStatus.Success => "#145A2A",
_ => "#1A4178",
};
private static string GetStatusGlyph(QueryStatus status)
=> status switch
{
QueryStatus.Accepted => "OK",
QueryStatus.InProgress => "~",
QueryStatus.Rejected => "!",
QueryStatus.Failed => "X",
QueryStatus.Success => "V",
_ => "i",
};
}

View file

@ -0,0 +1,35 @@
using System;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public sealed class BillingQueryDisplayItem
{
public long Id { get; init; }
public string Description { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty;
public string StatusLabel { get; init; } = string.Empty;
public string EventDateLabel { get; init; } = string.Empty;
public string BillingCode { get; init; } = string.Empty;
public static BillingQueryDisplayItem FromDto(BillingQuerySummaryDto dto)
{
var summary = !string.IsNullOrWhiteSpace(dto.Reason)
? dto.Reason
: !string.IsNullOrWhiteSpace(dto.AdditionalInfo)
? dto.AdditionalInfo
: dto.Description;
return new BillingQueryDisplayItem
{
Id = dto.Id,
Description = string.IsNullOrWhiteSpace(dto.Description)
? $"Commande #{dto.Id}"
: dto.Description,
Summary = summary,
StatusLabel = dto.Status.ToString(),
EventDateLabel = dto.EventDate?.ToLocalTime().ToString("g") ?? "Date non précisée",
BillingCode = dto.BillingCode,
};
}
}

View file

@ -0,0 +1,140 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class CommandFormsPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly BillingApiClient _billingClient;
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
[ObservableProperty]
public partial ObservableCollection<CommandFormSummary> Forms { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand)), NotifyCanExecuteChangedFor(nameof(OpenOngoingQueriesCommand))]
public partial CommandFormSummary? SelectedForm { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Pret.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
public string Title => $"Formulaires pour {Performer.UserName}";
public string ContextLabel => $"{Activity.Name} · {Forms.Count} formulaire(s)";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public CommandFormsPageViewModel(
ActivityInfo activity,
ActivityUserDisplayItem performer,
BillingApiClient billingClient)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
Forms = new ObservableCollection<CommandFormSummary>((activity.Forms ?? new())
.OrderBy(f => f.Title)
.ThenBy(f => f.ActionName));
SelectedForm = Forms.FirstOrDefault();
this.SetStatus(
Forms.Count == 0
? "Aucun formulaire n'est disponible pour cette activité."
: "Choisissez le formulaire à utiliser.",
Forms.Count == 0 ? StatusSeverity.Warning : StatusSeverity.Info);
}
private bool CanOpenSelectedForm() => SelectedForm is not null;
private bool CanOpenQueries() => SelectedForm is not null;
private bool CanOpenOngoingQueries() => SelectedForm is not null;
[RelayCommand(CanExecute = nameof(CanOpenSelectedForm))]
private async Task OpenSelectedFormAsync()
{
if (SelectedForm is null)
{
this.SetWarningStatus("Sélectionnez un formulaire.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = SelectedForm.CreateCommandPageViewModel(
Activity, Performer, _billingClient);
await vm!.InitializeAsync();
await app.PushPageAsync(vm);
}
[RelayCommand(CanExecute = nameof(CanOpenQueries))]
private async Task OpenQueriesAsync()
{
if (SelectedForm is null)
{
this.SetWarningStatus("Sélectionnez un formulaire.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new BillingQueriesPageViewModel(Activity, Performer, SelectedForm, _billingClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
[RelayCommand(CanExecute = nameof(CanOpenOngoingQueries))]
private async Task OpenOngoingQueriesAsync()
{
if (SelectedForm is null)
{
this.SetWarningStatus("Sélectionnez un formulaire.");
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new BillingQueriesPageViewModel(
Activity,
Performer,
SelectedForm,
_billingClient,
isReadOnly: true,
ongoingOnly: true);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
}

View file

@ -0,0 +1,22 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Models.Haircut;
namespace PostIt.ViewModels;
public partial class SelectableHairPrestationItem : ObservableObject
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Details { get; init; } = string.Empty;
[ObservableProperty]
public partial bool IsSelected { get; set; }
public static SelectableHairPrestationItem FromDto(HairPrestationDto dto)
=> new()
{
Id = dto.Id,
Title = dto.Title,
Details = dto.Details,
};
}

View file

@ -0,0 +1,111 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels;
public abstract partial class BillingCommandPageViewModel : RemoteViewModelBase, IActionStatusViewModel
{
protected readonly BillingApiClient _billingClient;
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Pret.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
[ObservableProperty]
public partial string Reason { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Consent { get; set; } = true;
[ObservableProperty]
public partial string AdditionalInfo { get; set; } = string.Empty;
[ObservableProperty]
public partial long? ExistingQueryId { get; set; }
[ObservableProperty]
public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted;
public bool CanUseCurrentLocation => IsSupported && !IsBusy;
public string Title => Form.Title;
public string PerformerLabel => Performer.UserName;
public string ActivityLabel => Activity.Name;
public virtual bool IsSupported => true;
public string BillingRoute => $"/billing/{Form.ActionName}";
public bool IsEditingExisting => ExistingQueryId.HasValue;
public string SubmitLabel => IsEditingExisting ? "Mettre à jour la commande" : "Poster la commande";
public virtual string SupportMessage => $"Le formulaire {Form.ActionName} n'est pas encore pris en charge dans PostIt.";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public BillingCommandPageViewModel(
ActivityInfo activity,
ActivityUserDisplayItem performer,
CommandFormSummary form,
BillingApiClient billingClient)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
this.SetInfoStatus(SupportMessage);
}
partial void OnExistingQueryIdChanged(long? value)
{
OnPropertyChanged(nameof(IsEditingExisting));
OnPropertyChanged(nameof(SubmitLabel));
}
partial void OnIsBusyChanged(bool value)
{
OnPropertyChanged(nameof(CanUseCurrentLocation));
}
public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null)
{
await LoadAsync();
if (existingQuery is not null)
{
ApplyExistingQuery(existingQuery);
return;
}
}
protected abstract void ApplyExistingQuery(BillingQueryDetailsDto existingQuery);
[RelayCommand]
protected abstract Task SubmitAsync();
}

View file

@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
namespace PostIt.ViewModels.Commands;
public partial class BrushViewModel : RdvViewModel
{
public override string SupportMessage => "Choisissez une prestation coiffure puis postez la commande.";
[ObservableProperty]
public partial ObservableCollection<HairPrestationDto> AvailablePrestations { get; set; } = new();
[ObservableProperty]
public partial HairPrestationDto? SelectedPrestation { get; set; }
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
}
public override async Task LoadAsync()
{
var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName);
AvailablePrestations = new ObservableCollection<HairPrestationDto>
(prestations ?? new List<HairPrestationDto>());
if (SelectedPrestation is null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault();
}
}
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
base.ApplyExistingQuery(existingQuery);
if (existingQuery.PrestationId is not null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value);
}
IsBusy = true;
try
{
if (SelectedPrestation is null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault();
}
this.SetStatus(
AvailablePrestations.Count == 0
? "Aucune prestation coiffure disponible."
: SupportMessage,
AvailablePrestations.Count == 0 ? StatusSeverity.Warning : StatusSeverity.Info);
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé au catalogue de prestations (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors du chargement des prestations: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
protected override async Task SubmitAsync()
{
if (!Consent)
{
this.SetWarningStatus("Le consentement est requis pour poster la commande.");
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
this.SetWarningStatus("L'adresse du rendez-vous est requise.");
return;
}
if (SelectedPrestation is null)
{
this.SetWarningStatus("Sélectionnez une prestation coiffure.");
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
}
};
payload.PrestationId = SelectedPrestation.Id;
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = (DateTime?)EventDate,
Location = locationPayload,
PrestationId = SelectedPrestation.Id,
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(),
Status = payload.Status,
}).ConfigureAwait(true);
}
this.SetInfoStatus(IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.");
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'envoi de la commande: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
}

View file

@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels.Commands;
public partial class MBrushViewModel : BrushViewModel
{
public override string SupportMessage => "Choisissez une ou plusieurs prestations coiffure puis postez la commande.";
[ObservableProperty]
public partial ObservableCollection<SelectableHairPrestationItem> MultiPrestations { get; set; } = new();
public MBrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
}
public override async Task LoadAsync()
{
await base.LoadAsync().ConfigureAwait(true);
MultiPrestations = new ObservableCollection<SelectableHairPrestationItem>(
AvailablePrestations.Select(SelectableHairPrestationItem.FromDto));
}
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
base.ApplyExistingQuery(existingQuery);
var selectedIds = existingQuery.PrestationIds is null
? new HashSet<long>()
: new HashSet<long>(existingQuery.PrestationIds);
foreach (var item in MultiPrestations)
{
item.IsSelected = selectedIds.Contains(item.Id);
}
}
protected override async Task SubmitAsync()
{
if (!Consent)
{
this.SetWarningStatus("Le consentement est requis pour poster la commande.");
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
this.SetWarningStatus("L'adresse du rendez-vous est requise.");
return;
}
var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList();
if (selectedPrestations.Count == 0)
{
this.SetWarningStatus("Sélectionnez au moins une prestation coiffure.");
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
},
PrestationIds = selectedPrestations.Select(x => x.Id).ToList(),
};
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = EventDate,
Location = locationPayload,
Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(),
Status = payload.Status,
}).ConfigureAwait(true);
}
this.SetInfoStatus(IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.");
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'envoi de la commande: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
}

View file

@ -0,0 +1,321 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels.Commands;
public partial class RdvViewModel : BillingCommandPageViewModel
{
public override string SupportMessage => "Complétez les informations du rendez-vous puis postez la commande.";
[ObservableProperty]
public partial string Address { get; set; } = string.Empty;
[ObservableProperty]
public partial string SuggestedAddress { get; set; } = string.Empty;
[ObservableProperty]
public partial bool IsResolvingAddress { get; set; }
[ObservableProperty]
public partial double? Latitude { get; set; }
[ObservableProperty]
public partial double? Longitude { get; set; }
[ObservableProperty]
public partial DateTime EventDate { get; set; }
public DateTimeOffset? EventDateSelection
{
get => new(EventDate);
set
{
if (!value.HasValue)
return;
EventDate = value.Value.LocalDateTime;
}
}
public RdvViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
EventDate = DateTime.Now.AddDays(1);
}
public bool HasSuggestedAddress => !string.IsNullOrWhiteSpace(SuggestedAddress);
public bool HasSuggestedAddressPanel => HasSuggestedAddress || IsResolvingAddress;
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
ExistingQueryId = existingQuery.Id;
CommandStatus = existingQuery.Status;
Consent = existingQuery.Consent;
Reason = existingQuery.Reason ?? string.Empty;
AdditionalInfo = existingQuery.AdditionalInfo ?? string.Empty;
if (existingQuery.EventDate is not null)
{
EventDate = existingQuery.EventDate.Value
.ToLocalTime();
}
if (existingQuery.Location is not null)
{
Address = existingQuery.Location.Address ?? string.Empty;
SuggestedAddress = string.Empty;
Latitude = existingQuery.Location.Latitude;
Longitude = existingQuery.Location.Longitude;
}
this.SetInfoStatus($"Commande #{existingQuery.Id} chargée.");
}
[RelayCommand(CanExecute = nameof(CanUseCurrentLocation))]
private async Task UseCurrentLocationAsync()
{
if (!CanUseCurrentLocation)
{
return;
}
IsBusy = true;
try
{
var result = await Platform.TryGetCurrentLocationAsync(default).ConfigureAwait(true);
if (!result.IsSuccess || !result.Latitude.HasValue || !result.Longitude.HasValue)
{
this.SetWarningStatus(result.Message);
return;
}
Latitude = result.Latitude.Value;
Longitude = result.Longitude.Value;
this.SetInfoStatus(string.IsNullOrWhiteSpace(Address)
? "Position récupérée. Complétez l'adresse puis envoyez la commande."
: result.Message);
}
catch (OperationCanceledException)
{
this.SetWarningStatus("La récupération de la position a été annulée.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Impossible de récupérer la position: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
protected static object BuildLocationPayload(string address, double? latitude, double? longitude)
{
if (latitude.HasValue && longitude.HasValue)
{
return new
{
Address = address,
Latitude = latitude.Value,
Longitude = longitude.Value,
};
}
return new
{
Address = address,
};
}
public void ApplyLocationFromMap(double latitude, double longitude)
{
Latitude = Math.Round(latitude, 6);
Longitude = Math.Round(longitude, 6);
if (string.IsNullOrWhiteSpace(Address))
{
this.SetInfoStatus("Position sélectionnée sur la carte. Complétez l'adresse puis envoyez la commande.");
return;
}
this.SetInfoStatus("Position sélectionnée sur la carte.");
}
public void NotifyReverseGeocodingStarted()
{
IsResolvingAddress = true;
this.SetInfoStatus(string.IsNullOrWhiteSpace(Address)
? "Recherche de l'adresse depuis la carte..."
: "Recherche d'une adresse suggérée..."
);
}
public void NotifyReverseGeocodingUnavailable()
{
IsResolvingAddress = false;
if (HasSuggestedAddress || !string.IsNullOrWhiteSpace(Address))
return;
this.SetInfoStatus("Position sélectionnée sur la carte. Complétez l'adresse puis envoyez la commande.");
}
public void ApplyResolvedAddress(string address)
{
if (string.IsNullOrWhiteSpace(address))
return;
var trimmedAddress = address.Trim();
if (string.IsNullOrWhiteSpace(Address))
{
Address = trimmedAddress;
SuggestedAddress = string.Empty;
IsResolvingAddress = false;
this.SetInfoStatus("Adresse mise à jour depuis la carte.");
return;
}
if (string.Equals(Address.Trim(), trimmedAddress, StringComparison.Ordinal))
{
SuggestedAddress = string.Empty;
IsResolvingAddress = false;
return;
}
SuggestedAddress = trimmedAddress;
IsResolvingAddress = false;
this.SetInfoStatus("Adresse suggérée depuis la carte. Appliquez-la si besoin.");
}
[RelayCommand(CanExecute = nameof(HasSuggestedAddress))]
private void ApplySuggestedAddress()
{
if (string.IsNullOrWhiteSpace(SuggestedAddress))
return;
Address = SuggestedAddress.Trim();
SuggestedAddress = string.Empty;
IsResolvingAddress = false;
this.SetInfoStatus("Adresse suggérée appliquée.");
}
partial void OnSuggestedAddressChanged(string value)
{
OnPropertyChanged(nameof(HasSuggestedAddress));
OnPropertyChanged(nameof(HasSuggestedAddressPanel));
ApplySuggestedAddressCommand.NotifyCanExecuteChanged();
}
partial void OnIsResolvingAddressChanged(bool value)
{
OnPropertyChanged(nameof(HasSuggestedAddressPanel));
}
partial void OnEventDateChanged(DateTime value)
{
OnPropertyChanged(nameof(EventDateSelection));
}
protected override async Task SubmitAsync()
{
if (!IsSupported)
{
this.SetWarningStatus(SupportMessage);
return;
}
if (!Consent)
{
this.SetWarningStatus("Le consentement est requis pour poster la commande.");
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
this.SetWarningStatus("L'adresse du rendez-vous est requise.");
return;
}
if (string.IsNullOrWhiteSpace(Reason))
{
this.SetWarningStatus("Le motif du rendez-vous est requis.");
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
}
};
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = EventDate,
Location = locationPayload,
Reason = payload.Reason,
Status = payload.Status,
}).ConfigureAwait(true);
}
this.SetInfoStatus(IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.");
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur lors de l'envoi: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
public override Task LoadAsync()
{
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,32 @@
namespace PostIt.ViewModels;
public interface IActionStatusViewModel
{
string StatusMessage { get; set; }
StatusNotice ActionStatus { get; set; }
}
public static class ActionStatusViewModelExtensions
{
public static void SetInfoStatus(this IActionStatusViewModel viewModel, string message)
=> viewModel.SetStatus(message, StatusSeverity.Info);
public static void SetWarningStatus(this IActionStatusViewModel viewModel, string message)
=> viewModel.SetStatus(message, StatusSeverity.Warning);
public static void SetErrorStatus(this IActionStatusViewModel viewModel, string message)
=> viewModel.SetStatus(message, StatusSeverity.Error);
public static void SetStatus(this IActionStatusViewModel viewModel, string message, StatusSeverity severity)
{
var normalizedMessage = string.IsNullOrWhiteSpace(message) ? "Pret." : message.Trim();
viewModel.StatusMessage = normalizedMessage;
viewModel.ActionStatus = severity switch
{
StatusSeverity.Error => StatusNotice.Error(normalizedMessage),
StatusSeverity.Warning => StatusNotice.Warning(normalizedMessage),
_ => StatusNotice.Info(normalizedMessage),
};
}
}

View file

@ -1,4 +1,9 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
namespace PostIt.ViewModels;
@ -24,8 +29,25 @@ public class HomePageViewModel : ViewModelBase
Settings = settings;
SessionStatus = sessionStatus;
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
}
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushBlogsPageAsync);
public IAsyncRelayCommand OpenActivities { get; }
private async Task OpenActivitiesAsync()
{
var app = (App?)Application.Current;
var vm = app?.ServiceProvider?.GetRequiredService<ActivitiesPageViewModel>();
if (app is null || vm is null)
{
throw new InvalidOperationException("Activities page is not available.");
}
await vm.RefreshAsync();
await app.PushPageAsync(vm);
}
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can

View file

@ -12,7 +12,7 @@ using PostIt.Helpers;
namespace PostIt.ViewModels;
public partial class MainViewModel : ViewModelBase
public partial class MainViewModel : ViewModelBase, IActionStatusViewModel
{
/// <summary>Window/tab title. Cosmetic — bound by
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
@ -54,6 +54,9 @@ public partial class MainViewModel : ViewModelBase
[ObservableProperty]
public partial string StatusMessage { get; set; }
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
[ObservableProperty]
public partial string SearchText { get; set; }
@ -84,7 +87,7 @@ public partial class MainViewModel : ViewModelBase
Posts.Add(post);
}
ApplyFilter();
StatusMessage = $"Loaded {Posts.Count} posts.";
this.SetInfoStatus($"{Posts.Count} billet(s) chargé(s).");
});
}
@ -104,7 +107,7 @@ public partial class MainViewModel : ViewModelBase
// than to send a request the server will reject.
if (string.IsNullOrWhiteSpace(DraftTitle))
{
StatusMessage = "Title is required.";
this.SetWarningStatus("Le titre est obligatoire.");
return;
}
@ -134,7 +137,7 @@ public partial class MainViewModel : ViewModelBase
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
this.SetInfoStatus($"Billet {created.Id} créé.");
}
}
else
@ -150,7 +153,7 @@ public partial class MainViewModel : ViewModelBase
DateModified = DateTime.UtcNow,
};
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}.";
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
}
await RefreshPostsAsync();
@ -162,14 +165,14 @@ public partial class MainViewModel : ViewModelBase
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Select an existing post before deleting.";
this.SetWarningStatus("Sélectionnez un billet existant avant suppression.");
return;
}
await ExecuteAsync(async () =>
{
await BlogClient!.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
this.SetInfoStatus($"Billet {SelectedPost.Id} supprimé.");
SelectedPost = null;
await RefreshPostsAsync();
});
@ -194,7 +197,7 @@ public partial class MainViewModel : ViewModelBase
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
this.SetWarningStatus("Sélectionnez un billet existant pour changer sa publication.");
return;
}
@ -211,9 +214,9 @@ public partial class MainViewModel : ViewModelBase
// locally flipped state until the round-trip
// re-hydrates it.
SelectedPost.IsPublished = publish;
StatusMessage = publish
this.SetInfoStatus(publish
? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon.";
: $"Billet {SelectedPost.Id} remis en brouillon.");
});
}
@ -247,7 +250,7 @@ public partial class MainViewModel : ViewModelBase
{
if (SelectedPost is null)
{
StatusMessage = "Select an existing post before managing ACL.";
this.SetWarningStatus("Sélectionnez un billet existant avant de gérer l'ACL.");
return;
}
@ -346,7 +349,7 @@ public partial class MainViewModel : ViewModelBase
FilteredPosts = new ObservableCollection<BlogPostDto>();
SelectedPost = null;
IsBusy = false;
StatusMessage = "Ready";
this.SetInfoStatus("Prêt.");
Settings = settings ?? new Settings();
SearchText = Settings.SearchText;
WindowTitle = "PostIt";
@ -476,12 +479,12 @@ public partial class MainViewModel : ViewModelBase
try
{
IsBusy = true;
StatusMessage = "Working...";
this.SetInfoStatus("Traitement en cours...");
await action();
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{

View file

@ -0,0 +1,35 @@
namespace PostIt.ViewModels;
public enum StatusSeverity
{
Info,
Warning,
Error
}
public sealed class StatusNotice
{
public string Message { get; }
public StatusSeverity Severity { get; }
public string Glyph { get; }
public string Background { get; }
public string BorderBrush { get; }
public string Foreground { get; }
private StatusNotice(string message, StatusSeverity severity)
{
Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
Severity = severity;
(Glyph, Background, BorderBrush, Foreground) = severity switch
{
StatusSeverity.Error => ("!", "#FDECEA", "#C62828", "#7F1D1D"),
StatusSeverity.Warning => ("~", "#FFF8E1", "#E6A700", "#7C4A03"),
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
};
}
public static StatusNotice Info(string message) => new(message, StatusSeverity.Info);
public static StatusNotice Warning(string message) => new(message, StatusSeverity.Warning);
public static StatusNotice Error(string message) => new(message, StatusSeverity.Error);
}

View file

@ -0,0 +1,10 @@
using System.Threading.Tasks;
namespace PostIt.ViewModels;
public abstract class RemoteViewModelBase : ViewModelBase
{
public abstract Task LoadAsync();
}

View file

@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
[assembly: InternalsVisibleTo("PostIt.Tests")]
@ -26,11 +27,25 @@ public partial class Settings : ViewModelBase
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
[ObservableProperty]
[JsonIgnore]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
public void SetActionStatus(string message, StatusSeverity severity = StatusSeverity.Info)
{
ActionStatus = severity switch
{
StatusSeverity.Error => StatusNotice.Error(message),
StatusSeverity.Warning => StatusNotice.Warning(message),
_ => StatusNotice.Info(message),
};
}
/// <summary>
/// Catch top-level mutations: the four ObservableProperty
/// setters above all funnel through here, and we flip
@ -45,7 +60,7 @@ public partial class Settings : ViewModelBase
partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
partial void OnApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
/// <summary>
@ -110,6 +125,8 @@ public partial class Settings : ViewModelBase
// build options from a torn read.
lock (_mutationGate)
{
EnsureAuthenticationDefaultsLocked();
var options = new OidcClientOptions
{
Authority = Authentication.Authority,
@ -138,6 +155,25 @@ public partial class Settings : ViewModelBase
}
}
private void EnsureAuthenticationDefaultsLocked()
{
Authentication ??= new AuthenticationSettings();
if (string.IsNullOrWhiteSpace(Authentication.Authority))
Authentication.Authority = AuthenticationSettings.DefaultAuthority;
if (string.IsNullOrWhiteSpace(Authentication.ClientId))
Authentication.ClientId = AuthenticationSettings.DefaultClientId;
if (string.IsNullOrWhiteSpace(Authentication.RedirectUri))
Authentication.RedirectUri = AuthenticationSettings.DesktopRedirectUri;
if (Authentication.Scopes is null || Authentication.Scopes.Length == 0)
Authentication.Scopes = AuthenticationSettings.DefaultScopes;
Authentication.RefreshScopeListText();
}
/// <summary>
/// Scopes the PostIt client always requires from the OIDC provider,
/// regardless of what the user has in their settings file.
@ -154,7 +190,10 @@ public partial class Settings : ViewModelBase
{
"openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims
"offline_access" // OIDC: required to receive a refresh_token
"offline_access", // OIDC: required to receive a refresh_token
"blogs",
"api"
};
/// <summary>
@ -297,8 +336,15 @@ public partial class Settings : ViewModelBase
// → our overridden dispatcher-safe marshaller below.
else lock (_mutationGate)
{
var legacyApiUrl = TryReadLegacyApiUrl(json);
this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode;
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
? settings.BlogsApiUrl
: legacyApiUrl ?? this.BlogsApiUrl;
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
? settings.ApiUrl
: this.ApiUrl;
this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null))
{
@ -311,11 +357,13 @@ public partial class Settings : ViewModelBase
AuthenticationSettings.DesktopRedirectUri : settings.Authentication.RedirectUri;
if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0)
{
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
this.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
}
else
this.Authentication.Scopes = settings.Authentication.Scopes;
}
EnsureAuthenticationDefaultsLocked();
}
// A disk load (or an embedded-resource fallback) is the
// baseline, not a user edit. Clear the dirty flag last
@ -343,6 +391,26 @@ public partial class Settings : ViewModelBase
}
}
private static string? TryReadLegacyApiUrl(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("ApiUrl", out var apiUrl)
&& apiUrl.ValueKind == JsonValueKind.String)
{
return apiUrl.GetString();
}
}
catch
{
// Ignore legacy payload parse errors: normal deserialization
// already reports actionable diagnostics to the caller.
}
return null;
}
private void UseDefaultSettings()
{
this.Authentication = new AuthenticationSettings
@ -353,6 +421,8 @@ public partial class Settings : ViewModelBase
Scopes = AuthenticationSettings.DefaultScopes
};
this.DarkMode = false;
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty;
}
@ -374,6 +444,8 @@ public partial class Settings : ViewModelBase
[RelayCommand(CanExecute = nameof(CanSave))]
public void Save()
{
SetActionStatus("Enregistrement des parametres...", StatusSeverity.Info);
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
@ -393,10 +465,13 @@ public partial class Settings : ViewModelBase
File.SetUnixFileMode(configPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite);
IsDirty = false;
SetActionStatus("Parametres sauvegardes.", StatusSeverity.Info);
Console.WriteLine($"💾 Settings saved to {configPath}");
}
catch (Exception ex)
{
SetActionStatus($"Echec sauvegarde parametres: {ex.Message}", StatusSeverity.Error);
Console.Error.WriteLine($"🩎 Error saving settings to {configPath}: {ex.Message}");
throw;
}

View file

@ -30,7 +30,7 @@ namespace PostIt.ViewModels;
/// until the Yavsc.Org endpoint exists; the contract there will
/// be <c>POST /api/signature/{devisId}</c> with this same payload.
/// </summary>
public partial class SignaturePageViewModel : ViewModelBase
public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewModel
{
/// <summary>
/// Default capture surface, in DIPs. 3:1 ratio matches a
@ -42,6 +42,9 @@ public partial class SignaturePageViewModel : ViewModelBase
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
[ObservableProperty]
public partial int StrokeCount { get; set; }
@ -107,7 +110,7 @@ public partial class SignaturePageViewModel : ViewModelBase
private void OnStrokeCompleted(object? sender, SignaturePadData data)
{
StatusMessage = $"Trait terminé. {data.StrokeCount} trait(s).";
this.SetInfoStatus($"Trait terminé. {data.StrokeCount} trait(s).");
RefreshCounts();
}
@ -125,7 +128,7 @@ public partial class SignaturePageViewModel : ViewModelBase
public void Clear()
{
_control?.Clear();
StatusMessage = "Effacé.";
this.SetInfoStatus("Effacé.");
RefreshCounts();
}
@ -134,14 +137,14 @@ public partial class SignaturePageViewModel : ViewModelBase
{
if (_control is null)
{
StatusMessage = "Contrôle non attaché.";
this.SetWarningStatus("Contrôle non attaché.");
return;
}
var data = _control.Snapshot();
if (data.IsEmpty)
{
StatusMessage = "Rien à capturer.";
this.SetWarningStatus("Rien à capturer.");
return;
}
@ -149,11 +152,11 @@ public partial class SignaturePageViewModel : ViewModelBase
{
var path = WriteCapture(data);
LastCapturedPath = path;
StatusMessage = $"Capture enregistrée: {path}";
this.SetInfoStatus($"Capture enregistrée: {path}");
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
this.SetErrorStatus($"Erreur: {ex.Message}");
}
await Task.CompletedTask;
}

View file

@ -1,8 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels;
public abstract partial class ViewModelBase : ObservableObject
public abstract class ViewModelBase : ObservableObject
{
/// <summary>
/// Gets if the user can navigate to the next page

View file

@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.AddCircleMemberDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
xmlns:services="using:PostIt.Services"
x:DataType="vm:AddCircleMemberDialogViewModel"
>
@ -47,8 +48,8 @@
<!-- Action buttons -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" Margin="0,8,0,0">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<Button Grid.Column="1" Content="Ajouter"
x:Name="AddButton"
Command="{Binding AddAsync}"

View file

@ -1,10 +1,8 @@
using Avalonia.Markup.Xaml;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
using System.Threading.Tasks;
namespace PostIt.Views;
@ -41,10 +39,4 @@ public partial class AddCircleMemberDialog : Avalonia.Controls.ContentPage
/// </summary>
public AddCircleMemberDialogViewModel? ViewModel
=> DataContext as AddCircleMemberDialogViewModel;
private async Task OnCloseClicked(object? sender, RoutedEventArgs e)
{
App app = App.Current! as App;
await app!.GoBackAsync();
}
}

View file

@ -1,6 +1,7 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.CirclesPage"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
@ -101,8 +102,8 @@
<!-- Status bar -->
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1" IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Width="120"/>

View file

@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:PostAclDialogViewModel"
>
@ -53,8 +54,8 @@
<!-- Status bar -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1" IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Width="120"/>

View file

@ -0,0 +1,140 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
xmlns:wf="using:Yavsc.Abstract.Workflow"
x:Class="PostIt.Views.ActivitiesPage"
x:DataType="vm:ActivitiesPageViewModel"
Header="Activités">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<Grid.Styles>
<Style Selector="Border.user-badge">
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="5,0" />
<Setter Property="MinHeight" Value="16" />
</Style>
<Style Selector="TextBlock.user-badge-text">
<Setter Property="FontSize" Value="9" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</Grid.Styles>
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Button Content="🗘 Rafraîchir" Command="{Binding RefreshCommand}" />
<TextBlock Text="📑 Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
<Grid Grid.Row="1" Margin="0,12,0,12" ColumnDefinitions="*,16,*,16,*">
<Grid Grid.Column="0" RowDefinitions="Auto,*">
<TextBlock Grid.Row="0" Text="Activités" FontWeight="Bold" Margin="0,0,0,8" />
<ListBox Grid.Row="1"
ItemsSource="{Binding Activities}"
SelectedItem="{Binding SelectedActivity, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityInfo">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding PerformerCount, StringFormat='Utilisateurs: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,*">
<TextBlock Grid.Row="0" Text="Spécialisations" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="1"
Text="{Binding SelectedActivityLabel, StringFormat='Pour : {0}'}"
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
<ListBox Grid.Row="2"
ItemsSource="{Binding Specializations}"
SelectedItem="{Binding SelectedSpecialization, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityInfo">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding PerformerCount, StringFormat='Utilisateurs: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Grid Grid.Column="4" RowDefinitions="Auto,Auto,*,Auto">
<TextBlock Grid.Row="0" Text="Utilisateurs" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="1"
Text="{Binding CurrentActivityLabel, StringFormat='Activité affichée : {0}'}"
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
<ListBox Grid.Row="2"
ItemsSource="{Binding Performers}"
SelectedItem="{Binding SelectedPerformer, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityUserDisplayItem">
<StackPanel Spacing="2" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Border Width="24"
Height="24"
CornerRadius="12"
ClipToBounds="True"
Background="#E9ECEF">
<Grid>
<TextBlock Text="{Binding AvatarFallbackLabel}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="#495057" />
<Image Source="{Binding AvatarImage}"
Width="24"
Height="24"
Stretch="UniformToFill" />
</Grid>
</Border>
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
<Border IsVisible="{Binding HasPerformerProfile}"
Classes="user-badge"
Background="#D9F5E5"
BorderBrush="#2E7D32"
>
<TextBlock Text="{Binding PerformerBadgeLabel}"
Classes="user-badge-text"
Foreground="#1B5E20"
/>
</Border>
<Border IsVisible="{Binding HasPerformerProfile}"
Classes="user-badge"
Background="{Binding PerformerStatusBadgeBackground}"
BorderBrush="{Binding PerformerStatusBadgeBorder}"
>
<TextBlock Text="{Binding PerformerStatusBadgeLabel}"
Classes="user-badge-text"
Foreground="{Binding PerformerStatusBadgeForeground}"
/>
</Border>
</StackPanel>
<TextBlock Text="{Binding WebSite}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding ExtraActivityLabel}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Grid.Row="3"
Margin="0,8,0,0"
Content="Voir les formulaires"
Command="{Binding OpenCommandFormsCommand}" />
</Grid>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class ActivitiesPage : ContentPage
{
public ActivitiesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -0,0 +1,90 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.BillingQueriesPage"
x:DataType="vm:BillingQueriesPageViewModel"
Header="Commandes billing">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,8,*">
<Border Grid.Column="0" Background="#EAF3FF" BorderBrush="#2A5EA8" BorderThickness="1" CornerRadius="6" Padding="8,3">
<TextBlock Text="{Binding IsReadOnly, StringFormat='Lecture seule : {0}'}" FontSize="11" />
</Border>
<Border Grid.Column="2" Background="#F2F7F2" BorderBrush="#2F6D2F" BorderThickness="1" CornerRadius="6" Padding="8,3">
<TextBlock Text="{Binding OngoingOnly, StringFormat='En cours uniquement : {0}'}" FontSize="11" />
</Border>
<TextBlock Grid.Column="4"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Text="{Binding Queries.Count, StringFormat='Résultats : {0}'}"
Opacity="0.7" />
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="Auto,8,Auto,*" Margin="0,12,0,12">
<Button Grid.Column="0" Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<Button Content="Voir le detail"
Grid.Column="2"
Command="{Binding OpenSelectedQueryCommand}"
IsVisible="{Binding CanOpenDetails}" />
</Grid>
<ListBox Grid.Row="3"
ItemsSource="{Binding Queries}"
SelectedItem="{Binding SelectedQuery, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:BillingQueryDisplayItem">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
Margin="0,0,0,8">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="*,Auto">
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Description}"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{Binding StatusLabel}"
FontSize="11"
Opacity="0.75"
HorizontalAlignment="Right" />
<TextBlock Grid.Row="1"
Grid.ColumnSpan="2"
Margin="0,4,0,0"
Text="{Binding Summary}"
TextWrapping="Wrap"
FontSize="12"
Opacity="0.85" />
<Grid Grid.Row="2" Grid.ColumnSpan="2" ColumnDefinitions="*,Auto" Margin="0,8,0,0">
<TextBlock Grid.Column="0"
Text="{Binding EventDateLabel, StringFormat='Date : {0}'}"
FontSize="11"
Opacity="0.7" />
<TextBlock Grid.Column="1"
Text="{Binding BillingCode, StringFormat='Code : {0}'}"
FontSize="11"
Opacity="0.65"
HorizontalAlignment="Right" />
</Grid>
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class BillingQueriesPage : ContentPage
{
public BillingQueriesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -0,0 +1,131 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.BillingQueryDetailsPage"
x:DataType="vm:BillingQueryDetailsPageViewModel"
Header="Detail commande">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold"
Foreground="{Binding TitleForeground}" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,*">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
<Button Grid.Column="2"
Content="Ouvrir en edition"
Command="{Binding OpenEditorCommand}"
IsVisible="{Binding CanEdit}" />
</Grid>
<ScrollViewer Grid.Row="2" Margin="0,12,0,12">
<StackPanel Spacing="10">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Statut" FontWeight="SemiBold" />
<Border Grid.Row="0"
Grid.Column="2"
Background="{Binding StatusBadgeBackground}"
BorderBrush="{Binding StatusBadgeBorder}"
BorderThickness="1"
CornerRadius="6"
Padding="8,3"
HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding StatusGlyph}"
Foreground="{Binding StatusBadgeForeground}"
FontWeight="Bold" />
<TextBlock Text="{Binding StatusLabel}"
Foreground="{Binding StatusBadgeForeground}"
FontWeight="SemiBold" />
</StackPanel>
</Border>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Date" FontWeight="SemiBold" />
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding EventDateLabel}" />
</Grid>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Description" FontWeight="SemiBold" />
<TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding Description}" TextWrapping="Wrap" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Motif" FontWeight="SemiBold" />
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding Reason}" TextWrapping="Wrap" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Infos" FontWeight="SemiBold" />
<TextBlock Grid.Row="2" Grid.Column="2" Text="{Binding AdditionalInfo}" TextWrapping="Wrap" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Consentement" FontWeight="SemiBold" />
<TextBlock Grid.Row="3" Grid.Column="2" Text="{Binding ConsentLabel}" />
</Grid>
</Border>
<Expander Header="Metadonnees techniques"
IsExpanded="False">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
Margin="0,6,0,0">
<Grid ColumnDefinitions="Auto,12,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Code" FontWeight="SemiBold" />
<TextBlock Grid.Row="0"
Grid.Column="2"
Text="{Binding BillingCode}"
FontFamily="Consolas, Courier New, monospace" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Client" FontWeight="SemiBold" />
<TextBlock Grid.Row="1"
Grid.Column="2"
Text="{Binding ClientId}"
FontFamily="Consolas, Courier New, monospace" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Provision" FontWeight="SemiBold" />
<TextBlock Grid.Row="2"
Grid.Column="2"
Text="{Binding ProvisionalLabel}"
FontFamily="Consolas, Courier New, monospace" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Lieu" FontWeight="SemiBold" />
<TextBlock Grid.Row="3"
Grid.Column="2"
Text="{Binding LocationLabel}"
TextWrapping="Wrap"
FontFamily="Consolas, Courier New, monospace" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Prestations" FontWeight="SemiBold" />
<TextBlock Grid.Row="4"
Grid.Column="2"
Text="{Binding PrestationsLabel}"
TextWrapping="Wrap"
FontFamily="Consolas, Courier New, monospace" />
</Grid>
</Border>
</Expander>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,8,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="1"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class BillingQueryDetailsPage : ContentPage
{
public BillingQueryDetailsPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -0,0 +1,49 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
xmlns:wf="using:Yavsc.Abstract.Workflow"
x:Class="PostIt.Views.CommandFormsPage"
x:DataType="vm:CommandFormsPageViewModel"
Header="Formulaires">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<TextBlock Grid.Row="0"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1"
Margin="0,4,0,12"
Text="{Binding ContextLabel}"
Opacity="0.75" />
<ListBox Grid.Row="2"
ItemsSource="{Binding Forms}"
SelectedItem="{Binding SelectedForm, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:CommandFormSummary">
<StackPanel Spacing="2" Margin="0,0,0,10">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding ActionName, StringFormat='Route billing : /billing/{0}'}"
FontSize="11"
Opacity="0.7" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="3" Margin="0,12,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,12,*">
<Button Grid.Column="0"
Content="Ouvrir le formulaire"
Command="{Binding OpenSelectedFormCommand}" />
<Button Grid.Column="2"
Content="Voir les commandes"
Command="{Binding OpenQueriesCommand}" />
<Button Grid.Column="4"
Content="Demandes en cours (lecture seule)"
Command="{Binding OpenOngoingQueriesCommand}" />
<postitControls:StatusBar Grid.Column="6"
DataContext="{Binding ActionStatus}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class CommandFormsPage : ContentPage
{
public CommandFormsPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls"
xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
@ -24,13 +25,13 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Spacing="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding RefreshAsync}" Content="Refresh" />
<Button Command="{Binding SearchAsync}" Content="Filter" />
<Border Grid.Row="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Padding="0">
<WrapPanel Orientation="Horizontal">
<Button Command="{Binding RefreshAsync}" Content="🗘 Refresh" />
<Button Command="{Binding SearchAsync}" Content="🔍 Filter" />
<Button Command="{Binding SaveAsync}" Content="Save" />
<Button Command="{Binding DeleteAsync}" Content="Delete" />
<Button x:Name="ManageAclButton"
@ -62,8 +63,8 @@
Command="{Binding OpenSignatureDevAsync}"
Content="[DEV] Signature"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel>
</StackPanel>
</WrapPanel>
</Border>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
@ -104,7 +105,8 @@
VerticalAlignment="Stretch">
</TextBox>
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
<postitControls:StatusBar Grid.Row="3"
DataContext="{Binding ActionStatus}" />
</Grid>
</Border>

View file

@ -0,0 +1,106 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:cvm="using:PostIt.ViewModels.Commands"
xmlns:postitControls="using:PostIt.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.Commands.BrushPage"
x:DataType="cvm:BrushViewModel"
Header="Commande billing">
<ScrollViewer>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
Margin="0,4,0,12"
Text="{Binding SupportMessage}"
TextWrapping="Wrap"
Opacity="0.8" />
<TextBlock Grid.Row="2" Text="Utilisateur" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PerformerLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="3" Text="Activité" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
<DatePicker Grid.Row="4" Grid.Column="1" DayFormat="ddd dd" SelectedDate="{Binding EventDate, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
<Button Grid.Row="7"
Grid.ColumnSpan="2"
HorizontalAlignment="Left"
Margin="0,0,0,8"
Content="Utiliser ma position"
Command="{Binding UseCurrentLocationCommand}" />
<TextBlock Grid.Row="8" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding Latitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="9" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Longitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="10"
Text="Prestation"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<ComboBox Grid.Row="10"
Grid.Column="1"
ItemsSource="{Binding AvailablePrestations}"
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
Margin="0,0,0,8">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="12"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="12"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"
IsChecked="{Binding Consent, Mode=TwoWay}"
Margin="0,4,0,12" />
<Grid Grid.Row="14" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}"
IsEnabled="{Binding IsSupported}" />
<postitControls:StatusBar Grid.Column="2"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,13 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views.Commands;
public partial class BrushPage : ContentPage
{
public BrushPage()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,125 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cvm="using:PostIt.ViewModels.Commands"
xmlns:mapsui="using:Mapsui.UI.Avalonia"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.Commands.RdvPage"
x:DataType="cvm:RdvViewModel"
Header="Commande billing">
<ScrollViewer>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
Margin="0,4,0,12"
Text="{Binding SupportMessage}"
TextWrapping="Wrap"
Opacity="0.8" />
<TextBlock Grid.Row="2" Text="Utilisateur" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PerformerLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="3" Text="Activité" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
<DatePicker Grid.Row="4" Grid.Column="1" DayFormat="ddd dd" SelectedDate="{Binding EventDateSelection, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
<Grid Grid.Row="7"
x:Name="SuggestedAddressPanel"
Grid.ColumnSpan="2"
ColumnDefinitions="*,12,Auto,12,Auto"
Margin="0,0,0,8"
IsVisible="{Binding HasSuggestedAddressPanel}">
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Text="{Binding SuggestedAddress, StringFormat='Adresse suggérée: {0}'}"
TextWrapping="Wrap"
Opacity="0.8" />
<Button Grid.Column="2"
x:Name="ApplySuggestedAddressButton"
Content="Utiliser"
IsVisible="{Binding HasSuggestedAddress}"
Command="{Binding ApplySuggestedAddressCommand}" />
<ProgressBar Grid.Column="4"
x:Name="SuggestedAddressProgress"
Width="80"
IsIndeterminate="True"
IsVisible="{Binding IsResolvingAddress}" />
</Grid>
<Grid Grid.Row="8"
Grid.ColumnSpan="2"
ColumnDefinitions="Auto,12,Auto"
Margin="0,0,0,8">
<Button Grid.Column="0"
HorizontalAlignment="Left"
Content="Utiliser ma position"
Command="{Binding UseCurrentLocationCommand}" />
<Button Grid.Column="2"
HorizontalAlignment="Left"
Content="Me localiser sur la carte"
Click="OnCenterCurrentLocationClicked"
IsEnabled="{Binding CanUseCurrentLocation}" />
</Grid>
<TextBlock Grid.Row="9" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Latitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="10" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="10" Grid.Column="1" Text="{Binding Longitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="11" Grid.ColumnSpan="2"
Text="Carte: cliquez pour positionner le rendez-vous"
Margin="0,0,0,8"
Opacity="0.85" />
<mapsui:MapControl Grid.Row="12"
Grid.ColumnSpan="2"
x:Name="LocationMap"
Height="260"
Margin="0,0,0,8" />
<TextBlock Grid.Row="13"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="13"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8" />
<CheckBox Grid.Row="14" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"
IsChecked="{Binding Consent, Mode=TwoWay}"
Margin="0,4,0,12" />
<Grid Grid.Row="15" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}"
IsEnabled="{Binding IsSupported}" />
<postitControls:StatusBar Grid.Column="2"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,236 @@
using System;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Mapsui;
using Mapsui.Layers;
using Mapsui.Projections;
using Mapsui.Styles;
using Mapsui.Tiling;
using Mapsui.UI.Avalonia;
using PostIt.Services;
using PostIt.ViewModels.Commands;
namespace PostIt.Views.Commands;
public partial class RdvPage : ContentPage
{
private const double DefaultLatitude = 48.8566;
private const double DefaultLongitude = 2.3522;
private const int DefaultZoomLevel = 4;
private const int SelectedZoomLevel = 13;
internal const double ReverseGeocodingCacheToleranceDegrees = 0.0001;
private static readonly TimeSpan ReverseGeocodingDebounce = TimeSpan.FromMilliseconds(350);
private MapControl? _locationMap;
private MemoryLayer? _selectionLayer;
private RdvViewModel? _currentViewModel;
private readonly IReverseGeocodingService _reverseGeocodingService;
private CancellationTokenSource? _reverseGeocodeCts;
private double? _lastResolvedLatitude;
private double? _lastResolvedLongitude;
private string? _lastResolvedAddress;
public RdvPage()
: this(null)
{
}
public RdvPage(IReverseGeocodingService? reverseGeocodingService)
{
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
InitializeComponent();
InitializeMap();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void InitializeMap()
{
_locationMap = this.FindControl<MapControl>("LocationMap");
if (_locationMap is null)
return;
var map = new Map();
map.Layers.Add(OpenStreetMap.CreateTileLayer());
_selectionLayer = CreateSelectionLayer();
map.Layers.Add(_selectionLayer);
_locationMap.Map = map;
_locationMap.MapTapped += OnMapTapped;
DataContextChanged += OnDataContextChanged;
AttachViewModel(DataContext as RdvViewModel);
}
private void OnDataContextChanged(object? sender, System.EventArgs e)
{
AttachViewModel(DataContext as RdvViewModel);
CenterFromViewModel();
}
private async void OnMapTapped(object? sender, MapEventArgs e)
{
if (DataContext is not RdvViewModel vm)
return;
var (longitude, latitude) = SphericalMercator.ToLonLat(e.WorldPosition.X, e.WorldPosition.Y);
vm.ApplyLocationFromMap(latitude, longitude);
UpdateMarkerFromViewModel();
CenterMap(latitude, longitude, zoomLevel: SelectedZoomLevel);
await TryResolveAddressAsync(vm, latitude, longitude);
}
private async void OnCenterCurrentLocationClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not RdvViewModel vm || !vm.CanUseCurrentLocation)
return;
await vm.UseCurrentLocationCommand.ExecuteAsync(null);
UpdateMarkerFromViewModel();
CenterFromViewModel();
if (vm.Latitude.HasValue && vm.Longitude.HasValue)
await TryResolveAddressAsync(vm, vm.Latitude.Value, vm.Longitude.Value);
}
private void AttachViewModel(RdvViewModel? vm)
{
if (_currentViewModel is not null)
_currentViewModel.PropertyChanged -= OnViewModelPropertyChanged;
_currentViewModel = vm;
if (_currentViewModel is not null)
_currentViewModel.PropertyChanged += OnViewModelPropertyChanged;
UpdateMarkerFromViewModel();
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(RdvViewModel.Latitude)
|| e.PropertyName == nameof(RdvViewModel.Longitude))
{
UpdateMarkerFromViewModel();
}
}
private void CenterFromViewModel()
{
if (DataContext is not RdvViewModel vm)
{
CenterMap(DefaultLatitude, DefaultLongitude, zoomLevel: DefaultZoomLevel);
return;
}
if (vm.Latitude.HasValue && vm.Longitude.HasValue)
{
CenterMap(vm.Latitude.Value, vm.Longitude.Value, zoomLevel: SelectedZoomLevel);
return;
}
CenterMap(DefaultLatitude, DefaultLongitude, zoomLevel: DefaultZoomLevel);
}
private void CenterMap(double latitude, double longitude, int zoomLevel)
{
if (_locationMap?.Map is null)
return;
var (x, y) = SphericalMercator.FromLonLat(longitude, latitude);
_locationMap.Map.Navigator.CenterOn(x, y);
_locationMap.Map.Navigator.ZoomToLevel(zoomLevel);
}
private void UpdateMarkerFromViewModel()
{
if (_selectionLayer is null)
return;
if (DataContext is not RdvViewModel vm
|| !vm.Latitude.HasValue
|| !vm.Longitude.HasValue)
{
_selectionLayer.Features = Enumerable.Empty<IFeature>();
_locationMap?.RefreshData(ChangeType.Discrete);
return;
}
var (x, y) = SphericalMercator.FromLonLat(vm.Longitude.Value, vm.Latitude.Value);
_selectionLayer.Features = new IFeature[] { new PointFeature(x, y) };
_locationMap?.RefreshData(ChangeType.Discrete);
}
private static MemoryLayer CreateSelectionLayer()
{
return new MemoryLayer("selected-location")
{
Style = new SymbolStyle
{
SymbolType = SymbolType.Ellipse,
Fill = new Brush(Color.Crimson),
Outline = new Pen(Color.White, 2),
SymbolScale = 0.9,
},
Features = Enumerable.Empty<IFeature>(),
};
}
private async Task TryResolveAddressAsync(RdvViewModel vm, double latitude, double longitude)
{
if (_lastResolvedLatitude.HasValue
&& _lastResolvedLongitude.HasValue
&& AreCoordinatesClose(_lastResolvedLatitude.Value, _lastResolvedLongitude.Value, latitude, longitude)
&& !string.IsNullOrWhiteSpace(_lastResolvedAddress))
{
vm.ApplyResolvedAddress(_lastResolvedAddress);
return;
}
_reverseGeocodeCts?.Cancel();
_reverseGeocodeCts?.Dispose();
_reverseGeocodeCts = new CancellationTokenSource();
var cancellationToken = _reverseGeocodeCts.Token;
try
{
vm.NotifyReverseGeocodingStarted();
await Task.Delay(ReverseGeocodingDebounce, cancellationToken).ConfigureAwait(true);
var resolved = await _reverseGeocodingService
.TryResolveAddressAsync(latitude, longitude, cancellationToken)
.ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(resolved))
{
_lastResolvedLatitude = latitude;
_lastResolvedLongitude = longitude;
_lastResolvedAddress = resolved;
vm.ApplyResolvedAddress(resolved);
return;
}
vm.NotifyReverseGeocodingUnavailable();
}
catch (OperationCanceledException)
{
vm.IsResolvingAddress = false;
}
}
internal static bool AreCoordinatesClose(
double latitudeA,
double longitudeA,
double latitudeB,
double longitudeB)
{
return Math.Abs(latitudeA - latitudeB) <= ReverseGeocodingCacheToleranceDegrees
&& Math.Abs(longitudeA - longitudeB) <= ReverseGeocodingCacheToleranceDegrees;
}
}

View file

@ -18,5 +18,9 @@
Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Parcourir les activités"
Command="{Binding OpenActivities}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel>
</ContentPage>

View file

@ -2,6 +2,7 @@
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="cl:avalonia.Controls"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.SettingsPage"
xmlns:vm="using:PostIt.ViewModels"
x:DataType="vm:Settings"
@ -39,25 +40,34 @@
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="Business API URL"/>
<TextBlock Grid.Row="8" Text="API URL"/>
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
Text="{Binding ApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="10" Text="Dark mode"/>
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<Button Grid.Row="12" Content="Sauver"
HorizontalAlignment="Right"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
<StackPanel Grid.Row="12" Spacing="8">
<TextBlock Text="Avatar: PNG, JPG/JPEG, WEBP, GIF. Taille max 2 MB."
Opacity="0.8"
TextWrapping="Wrap" />
<postitControls:StatusBar DataContext="{Binding ActionStatus}"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="ChooseAvatarButton"
Content="Choisir l'avatar"
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- Sauver: bind to the RelayCommand-generated property
(SaveCommand). IsEnabled tracks IsDirty so the button
auto-disables when there's nothing to persist. -->
<Button Content="Sauver"
Command="{Binding SaveCommand}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</StackPanel>
</Grid>
</ContentPage>

View file

@ -0,0 +1,116 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class SettingsPage: ContentPage
{
private const int MaxAvatarSizeMegabytes = 2;
private const string AcceptedAvatarFormats = "PNG, JPG/JPEG, WEBP, GIF";
private int _avatarStatusVersion;
public SettingsPage()
{
InitializeComponent();
}
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var statusVersion = Interlocked.Increment(ref _avatarStatusVersion);
ChooseAvatarButton.IsEnabled = false;
SetAvatarStatus("Selection d'un fichier avatar...", StatusSeverity.Info);
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
SetAvatarStatus("Impossible d'acceder a la fenetre active.", StatusSeverity.Error);
ChooseAvatarButton.IsEnabled = true;
return;
}
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir un avatar",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Images")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif" }
}
}
});
var file = files.FirstOrDefault();
if (file is null)
{
SetAvatarStatus("Upload annule.", StatusSeverity.Warning);
ChooseAvatarButton.IsEnabled = true;
return;
}
try
{
SetAvatarStatus("Upload avatar en cours...", StatusSeverity.Info);
await using var stream = await file.OpenReadAsync();
var api = ((App)App.Current!).ServiceProvider!.GetRequiredService<YavscApiClient>();
var message = await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
SetAvatarStatus(string.IsNullOrWhiteSpace(message)
? "Avatar mis a jour."
: message,
StatusSeverity.Info);
_ = ClearSuccessStatusLaterAsync(statusVersion);
}
catch (Exception ex)
{
SetAvatarStatus($"Echec upload avatar: {ex.Message}", StatusSeverity.Error);
Console.Error.WriteLine($"🩎 Avatar upload failed: {ex.Message}");
}
finally
{
ChooseAvatarButton.IsEnabled = true;
}
}
private async Task ClearSuccessStatusLaterAsync(int statusVersion)
{
await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(true);
if (statusVersion != _avatarStatusVersion)
return;
SetAvatarStatus($"Avatar pret. Formats supportes: {AcceptedAvatarFormats}. Taille max: {MaxAvatarSizeMegabytes} MB.", StatusSeverity.Info);
}
private void SetAvatarStatus(string message, StatusSeverity severity)
{
if (DataContext is Settings settings)
{
settings.SetActionStatus(message, severity);
}
}
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",
_ => "application/octet-stream"
};
}
}

View file

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

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