Compare commits

..

No commits in common. "main" and "1.0.8-rc3" have entirely different histories.

203 changed files with 1500 additions and 13287 deletions

View file

@ -44,8 +44,4 @@ jobs:
- name: Test
run: |
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \
--verbosity normal \
--filter="Category!=Platform-Android" \
--logger "xunit;LogFileName=test-results.xml" \
&& echo "✅ Success !" || echo "❌ Fail ($?)!"
cd /src/_src && dotnet test --verbosity normal && echo "✅ Success !"

View file

@ -68,8 +68,10 @@ jobs:
# WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile).
cd /src
# Clone unshallow pour que GitVersion.MsBuild ait l'historique
# et les tags (sinon MSB3073 sur la cible Android cf. PR #21).
if [[ ! -d _src/.git ]]; then
git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
fi
cd _src
@ -171,22 +173,37 @@ jobs:
echo "EOF" >> "$GITHUB_ENV"
echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV"
- name: Restore
- name: Build des projets .NET (sans docker)
# L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le
# workload Android, mais PAS le binaire `docker` ni de daemon
# Docker. On exécute donc les commandes dotnet directement
# au lieu de passer par `docker build`.
# Equivalent des stages build-env du Dockerfile (lignes
# restore + build Yavsc.Org + build Yavsc.Api + build
# Yavsc.Blogs + build PostIt.Android -r android-arm64).
run: |
cd /src/_src
dotnet restore
dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \
-c Release --no-restore -clp:ErrorsOnly -r android-arm64
- name: Build de PostIt.Android ARM64
- name: Copier l'APK signé vers un emplacement connu
# Le build Android avec -r android-arm64 produit l'APK dans
# bin/Release/net10.0-android/android-arm64/. On le copie à
# la racine du checkout pour que l'étape d'upload le trouve.
run: |
cd /src/_src
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \
-c Release -r android-arm64 --no-restore -clp:ErrorsOnly
- name: Build de PostIt.Android x64
run: |
cd /src/_src
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \
-c Release -r android-x64 --no-restore -clp:ErrorsOnly
APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.postit-Signed.apk
if [[ ! -f "$APK" ]]; then
echo "::error::APK not found at $APK"
ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64 2>/dev/null || true
exit 1
fi
cp "$APK" /src/_src/PostIt.Android.apk
ls -la /src/_src/PostIt.Android.apk
- name: Publier la release Forgejo via l'API REST
# Pas d'action tierce (pas de Node dans l'image runner).
@ -295,22 +312,21 @@ jobs:
# sinon curl l'interprète comme un second fichier d'input
# (un fichier nommé '?name=PostIt.Android.apk') et l'API
# Forgejo renvoie 400 "Missing 'name' parameter".
echo "::group::Upload PostIt APK assets"
for MARCH in arm64 x64; do
HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
-H "Accept: application/json" \
--data-binary "@/src/_src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-$MARCH/fr.pschneider.postit-Signed.apk" \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android-$MARCH.apk")
echo "POST asset -> HTTP $HTTP"
if [[ "$HTTP" != "201" ]]; then
echo "::error::Asset upload failed (HTTP $HTTP):"
cat /tmp/asset.json
exit 1
fi
done
echo "::group::Upload APK asset"
HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
-H "Accept: application/json" \
--data-binary "@/src/_src/PostIt.Android.apk" \
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=PostIt.Android.apk")
echo "POST asset -> HTTP $HTTP"
echo "::endgroup::"
echo "✅ Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG"
if [[ "$HTTP" != "201" ]]; then
echo "::error::Asset upload failed (HTTP $HTTP):"
cat /tmp/asset.json
exit 1
fi
echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG"

View file

@ -0,0 +1,183 @@
name: Build and Push Yavsc Apk
on:
push:
branches:
- main
tags:
- '*'
workflow_dispatch:
inputs:
force_unstable:
description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.'
required: false
type: boolean
default: false
# softprops/action-gh-release a besoin de contents: write
# pour publier une release + uploader un asset.
permissions:
contents: write
jobs:
apk-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout du code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
# 1. Votre étape de build actuelle (on nomme l'image "postit-android")
# --target build-env : on ne veut que le stage de build (qui
# contient les artefacts .apk). Sans --target, Docker ciblerait
# le DERNIER stage du Dockerfile (blogs-runtime, qui est une
# image ASP.NET runtime sans aucun APK à extraire).
- name: Build de l'image Docker
run: docker build --build-arg ANDROID_TARGET_RID=android-arm64 --target build-env -t postit-android .
# 2. EXTRACTION : Créer un conteneur éphémère pour copier l'APK vers l'hôte GitHub
- name: Extraire l'APK du conteneur Docker
run: |
docker create --name extractor postit-android
docker cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.postit-Signed.apk ./PostIt.Android.apk
docker rm extractor
- name: Téléverser l'APK en tant qu'Artéfact GitHub
uses: actions/upload-artifact@v7
with:
name: application-apk-release
path: ./PostIt.Android.apk
retention-days: 7
# Job de validation : parse le tag, vérifie le format, applique la règle
# de parité du patch (pair=stable / impair=preview / suffixe=instable),
# et s'assure que CHANGELOG.md contient une section cohérente.
# Sans ce job, le job publish-release peut être bypassé (un attaquant
# qui contrôle un tag ne peut pas publier de release sans une section
# changelog cohérente).
validate-release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout du code
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Valider le tag et la section CHANGELOG
env:
FORCE_UNSTABLE: ${{ inputs.force_unstable || github.event.inputs.force_unstable || 'false' }}
run: |
TAG="${GITHUB_REF_NAME}"
# Parse semver : MAJOR.MINOR.PATCH[-SUFFIX]
if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then
echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format."
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
SUFFIX="${BASH_REMATCH[4]}"
# Classification du canal par parité du patch.
# Patch pair + pas de suffixe -> stable.
# Patch impair + pas de suffixe -> preview.
# Suffixe présent -> instable.
if [[ -n "$SUFFIX" ]]; then
CHANNEL="unstable"
elif (( PATCH % 2 == 0 )); then
CHANNEL="stable"
else
CHANNEL="preview"
fi
echo "Tag $TAG classifié comme channel=$CHANNEL"
# Fail-fast sur instable sauf opt-in explicite via workflow_dispatch.
if [[ "$CHANNEL" == "unstable" && "$FORCE_UNSTABLE" != "true" ]]; then
echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish."
echo "Set force_unstable=true via workflow_dispatch to override."
exit 1
fi
# Lecture du CHANGELOG.md (doit exister à la racine du repo).
if [[ ! -f CHANGELOG.md ]]; then
echo "::error::CHANGELOG.md not found at repo root."
exit 1
fi
# Extraction de la section [TAG]. On cherche la première ligne
# commençant par '## [' qui contient '[TAG]' (entre '## [' et
# la prochaine ligne '## [' ou fin de fichier). awk en mode
# paragraphe suffit et reste POSIX.
BODY=$(awk -v tag="[$TAG]" '
/^## \[/ {
if (in_section) exit
if (index($0, tag) > 0) in_section=1
next
}
in_section { print }
' CHANGELOG.md)
if [[ -z "$BODY" ]]; then
echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md."
echo "Add a '## [$TAG] - $CHANNEL' section before tagging."
exit 1
fi
# Vérification cohérence du canal déclaré dans le titre de section.
# Format attendu : "## [TAG] - stable" / "- preview" / "- unstable".
HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md)
if [[ "$HEADER" != *" - $CHANNEL"* ]]; then
echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity."
echo "Current section header: $HEADER"
exit 1
fi
echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL"
# Exposition aux étapes suivantes via $GITHUB_ENV.
# heredoc <<EOF pour le body multi-lignes (pattern GitHub Actions).
{
echo "RELEASE_BODY<<EOF"
echo "$BODY"
echo "EOF"
echo "RELEASE_CHANNEL=$CHANNEL"
if [[ "$CHANNEL" == "stable" ]]; then
echo "IS_PRERELEASE=false"
else
echo "IS_PRERELEASE=true"
fi
} >> "$GITHUB_ENV"
publish-release:
# Déclenché uniquement par un push de tag. Le job apk-deploy produit
# l'artefact ; validate-release garantit la cohérence du tag et du
# changelog avant publication.
if: startsWith(github.ref, 'refs/tags/')
needs: [apk-deploy, validate-release]
runs-on: ubuntu-latest
steps:
- name: Récupérer l'APK depuis l'artefact
uses: actions/download-artifact@v7
with:
name: application-apk-release
path: ./
- name: Publier la release GitHub et uploader l'APK
uses: softprops/action-gh-release@v2
with:
# Le nom de fichier final dans la release. C'est ce qui
# apparaîtra dans l'asset et donc dans le permalink :
# https://github.com/<owner>/<repo>/releases/latest/download/PostIt.Android.apk
files: ./PostIt.Android.apk
# Le body est extrait de la section CHANGELOG.md correspondant
# au tag, exposée par validate-release via $GITHUB_ENV.
body: ${{ env.RELEASE_BODY }}
# stable -> false (marque comme Latest).
# preview / unstable -> true (visible mais pas Latest).
prerelease: ${{ env.IS_PRERELEASE }}

View file

@ -26,7 +26,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Test
run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android"
run: dotnet test --no-build --verbosity normal
# 4. Build et Push de l'image de production finale
- name: Build and push production image
uses: docker/build-push-action@v7

51
.vscode/settings.json vendored
View file

@ -2,31 +2,29 @@
"dotnet-test-explorer.testProjectPath": "test/**/*Tests.csproj",
"cSpell.words": [
"appsettings",
"asciidoctor",
"ASPNETCORE",
"Avalonia",
"blogspot",
"Configurabilité",
"Cratie",
"DESTDIR",
"dotnet",
"DOTNET",
"ecdsa",
"envsubst",
"Forgejo",
"Hsts",
"Newtonsoft",
"Npgsql",
"Oidc",
"PKCE",
"postit",
"pschneider",
"SLNDIR",
"validable",
"www-data",
"yavsc",
"Yavsc"
"appsettings",
"asciidoctor",
"ASPNETCORE",
"Avalonia",
"Configurabilité",
"Cratie",
"DESTDIR",
"dotnet",
"DOTNET",
"ecdsa",
"envsubst",
"Hsts",
"Newtonsoft",
"Npgsql",
"Oidc",
"PKCE",
"postit",
"pschneider",
"SLNDIR",
"validable",
"www-data",
"yavsc",
"Yavsc"
],
"cSpell.reportUnknownWords": true,
"cSpell.language": "fr,en",
@ -44,6 +42,5 @@
"copilotcli/gpt-5.3-codex"
]
}
},
"dotnet.defaultSolution": "yavsc.sln"
}
}

33
.vscode/tasks.json vendored
View file

@ -1,22 +1,5 @@
{
"version": "2.0.0",
"isRoot": true,
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"code": 5,
"message": 6
}
}
],
"tasks": [
{
"label": "run-debug-android",
@ -37,6 +20,21 @@
"-p:AndroidAttachDebugger=true",
"-p:AndroidSdbHostPort=55555",
"-p:AndroidSdbTargetPort=55555"
],
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(error):\\s+(.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"message": 5
}
}
]
},
{
@ -47,6 +45,7 @@
"group": "build",
"isBuildCommand": true,
"isTestCommand": false,
"problemMatcher": ["$msCompile"],
"isBackground": true
},
{

View file

@ -1,107 +1,22 @@
# Changelog
## [1.0.8-rc12] - unstable
Toutes les modifications notables de PostIt et de la plateforme Yavsc
sont documentées dans ce fichier.
### Added
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* [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.
À noter : la **parité du numéro de patch** porte une signification de canal :
### Changed
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
* [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] 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`.
Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`.
### Fixed
néant
## [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
nothing
### Changed
masquage non-owner côté backend de l'ACL du billet
### Fixed
On a maintenant le comportement attendu bout en bout:
ACL chargée depuis le BlogPostDto
noms de cercles affichés dans le dialogue ACL côté PostIt
## [1.0.8-rc8] - unstable
### Added
nothing
### Changed
nothing
### Fixed
The PostIt publish toggle button
## [1.0.8-rc7] - unstable
### Added
* [PostIt] The search pattern now persists
### Changed
* The blog spot path is now `/api/v1/blogspot` (yet in last release)
### Fixed
* [Yavsc.Org] (Ticket #45) La forme de l'email de l'utilisateur est maintenant validée avant l'envoi du formulaire d'enregistrement
## [1.0.8-rc6] - unstable
## [1.0.8-rc3] - unstable
### Added
@ -116,9 +31,8 @@ L'identifiant de l'application client Android a changé, il passe en minuscules
### Fixed
a bug posting and retrieving ACL from the backend,
the ACL now comes along with the article,
[TODO][PostIt] keep ACL along with the article
nothing, [TODO] a bug persists laoding a blog post from PostIt, ACL sould come along with and should not need any "Refresh" button.
Today, assigning ACL succeeds the first time, the following times result in a 409
## [1.0.8-rc1] - unstable
@ -273,10 +187,10 @@ the ACL now comes along with the article,
migration, reverted in this release. The publish toggle covers
the same user-visible switch without a schema change.
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6
[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD
[1.0.8-rc1]: https://github.com/pazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6
## [1.0.6] - stable
@ -310,4 +224,4 @@ the ACL now comes along with the article,
actual release id. Switched to `jq` for both body construction and
field extraction.
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6
[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6

View file

@ -11,7 +11,7 @@
## Premier build
```bash
git clone https://forgejo.pschneider.fr/notazof/yavsc.git
git clone https://github.com/pazof/yavsc.git
cd yavsc
dotnet restore
dotnet build
@ -49,39 +49,6 @@ 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
Toutes les modifications notables de PostIt et de la plateforme Yavsc
sont documentées dans ce fichier.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
À noter : la **parité du numéro de patch** porte une signification de canal :
- **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
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`.
## Navigation (PostIt)
La navigation est centralisée dans

View file

@ -1,6 +1,16 @@
<Project>
<PropertyGroup>
<RootNamespace>Yavsc</RootNamespace>
<!--
GitVersion.MsBuild is referenced as a build-time package from
every csproj under src/. Setting UseProjectNamespaceForGitVersionInformation
here (in Directory.Build.props) means each assembly exposes a
GitVersionInformation type under its own namespace, e.g.
PostIt.GitVersionInformation, Yavsc.GitVersionInformation, so
the assembly metadata reflects the source tree it was built
from without conflicting names.
-->
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
<NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn>
</PropertyGroup>
</Project>

View file

@ -1,31 +1,16 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
</PropertyGroup>
<ItemGroup>
<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="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.8.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="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" />
@ -33,24 +18,14 @@
<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.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<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" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup>
</Project>
</Project>

View file

@ -77,6 +77,13 @@ release:
echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \
exit 1; \
fi
@CURRENT=$$(git branch --show-current); \
if [ "$$CURRENT" != "main" ]; then \
echo "Refus : la cible doit être lancée depuis main."; \
echo " Branche courante : $$CURRENT"; \
echo " Fais : git checkout main && git pull --ff-only origin main"; \
exit 1; \
fi
@if [ -n "$$(git status --porcelain)" ]; then \
echo "Working tree sale, refus de créer une branche release."; \
git status --short; \

View file

@ -16,10 +16,11 @@ https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml
# Statut actuel des actions GitHub
* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml)
* [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml)
* [![Build and Push Yavsc Production Image](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-backend.yml)
* [![CodeQL Advanced](https://github.com/pazof/yavsc/actions/workflows/codeql.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/codeql.yml)
# Documentation
@ -28,10 +29,6 @@ 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
### Jalon 0 — Fondations techniques *(en cours)*
> 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 *(en cours)*
### Jalon 1 — Prestation signée de bout en bout
> Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications.

View file

@ -1,24 +0,0 @@
# 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 Api
APP_PROJECT_NAMES=Org Blogs
SLNDIR=..
include $(SLNDIR)/.env
@ -9,11 +9,9 @@ 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)" \
@ -35,12 +33,11 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@
copy-services: copy-service-Org copy-service-Blogs copy-service-Api
copy-services: copy-service-Org copy-service-Blogs
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 build_publish_Api stop-services
copy-binaries: build_publish_Org build_publish_Blogs stop-services
@for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \
@ -63,8 +60,6 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
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
@ -89,7 +84,6 @@ 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)
@ -98,3 +92,4 @@ showConfig:
clean:
@rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean

View file

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

View file

@ -1,37 +0,0 @@
[Unit]
Description=yavsc-Blogs
After=syslog.target
After=network.target
Wants=postgresql.service
After=postgresql.service
[Service]
RestartSec=5s
Type=simple
User=yavsc
Group=yavsc
WorkingDirectory=/srv/www/yavsc
ExecStart=/srv/www/yavsc/Yavsc.Blogs
Restart=always
Environment="HOME="
Environment="ANTHROPIC_API_KEY=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"
Environment="ANTHROPIC_MAX_TOKENS=255"
Environment="ASPNETCORE_Environment="
Environment="ASPNETCORE_Kestrel__Endpoints__Http=http://localhost:Blogs_"
Environment="ASPNETCORE_ConnectionStrings__YavscConnection=Server=localhost;Port=5432;Database=yavsc;Username=yavsc;Password=4T/X+fOnE;"
Environment="ASPNETCORE_Smtp__Host=\"mercure.pschneider.f\""
Environment="ASPNETCORE_Smtp__Port=465"
Environment="ASPNETCORE_Smtp__SenderName=\"Paul Schneider\""
Environment="ASPNETCORE_Smtp__SenderEmail=\"paul@pschneider.fr\""
Environment="ASPNETCORE_Smtp__UserName=\"paul\""
Environment="ASPNETCORE_Smtp__Password=\"j\0Dsn5=t\""
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=yavscBlogs
[Install]
WantedBy=multi-user.target

View file

@ -18,9 +18,6 @@ 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

@ -1,20 +0,0 @@
# 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.

View file

@ -1,101 +0,0 @@
# 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.

View file

@ -1,73 +0,0 @@
# 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

@ -8,6 +8,8 @@
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia packages -->
<!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
@ -19,12 +21,16 @@
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />

View file

@ -1,5 +1,4 @@
using Android.App;
using Android;
using Android.Runtime;
using Avalonia;
using Avalonia.Android;
@ -10,9 +9,6 @@ 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,17 +57,6 @@ 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,12 +14,11 @@ 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

@ -9,22 +9,23 @@
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Icon.png">
<Link>Resources\drawable\Icon.png</Link>
</AndroidResource>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Android" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
<PackageReference Include="Xamarin.AndroidX.Browser" />
</ItemGroup>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
</Project>
</Project>

View file

@ -1,130 +0,0 @@
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

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+49.Branch.release-1.0.8-rc3.Sha.5274d7bdf5d14406748a2063b5ae15191aac4359</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -15,4 +15,4 @@
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
</Project>
</Project>

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+49.Branch.release-1.0.8-rc3.Sha.5274d7bdf5d14406748a2063b5ae15191aac4359</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<PropertyGroup>
@ -24,4 +24,4 @@
<ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
</Project>
</Project>

View file

@ -1,151 +0,0 @@
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 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

@ -12,7 +12,6 @@ namespace PostIt.Tests;
/// Skip conditions: the package is not installed on the connected device,
/// or no device is connected via adb.
/// </summary>
[Trait("Category", "Platform-Android")]
public class AndroidAppLaunchTests
{
private const string PackageName = "fr.pschneider.postit";
@ -24,8 +23,8 @@ public class AndroidAppLaunchTests
_output = output;
}
// TODO https://twosixtech.com/blog/integrating-docker-and-adb/
[Fact]
// https://twosixtech.com/blog/integrating-docker-and-adb/
// FIXME ala hosted shared resource adb server - [Fact]
public void PostIt_starts_and_draws_a_first_frame_on_the_emulator()
{
if (!IsPackageInstalledOnAnyDevice())

View file

@ -67,7 +67,7 @@ public class BearerScopeTests
Scopes = userScopes,
RedirectUri = "postit://callback",
},
ApiUrl = "https://example.invalid/api/v1/",
BusinessApiUrl = "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.ApiUrl), path);
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);

View file

@ -1,274 +0,0 @@
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 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

@ -1,134 +0,0 @@
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.False(vm.CanOpenDetails);
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null));
}
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

@ -166,51 +166,4 @@ public class BlogPostAuthorDtoTests
Assert.True(root.TryGetProperty("userName", out _));
Assert.True(root.TryGetProperty("avatar", out _));
}
[Fact]
public void BlogPostDto_deserialises_acl_from_detail_payload()
{
// Detail payload shape emitted by BlogApiController.GetBlog:
// ACL entries are included under "acl"/"ACL".
var json = """
{
"id": 99,
"title": "ACL test",
"authorId": "u-alice",
"acl": [
{ "circleId": 12, "blogPostId": 99 },
{ "circleId": 34, "blogPostId": 99 }
]
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
var acl = post!.GetACL();
Assert.Equal(2, acl.Length);
Assert.Contains(acl, a => a.CircleId == 12);
Assert.Contains(acl, a => a.CircleId == 34);
}
[Fact]
public void BlogPostDto_does_not_emit_acl_when_serialized_for_write()
{
var post = new BlogPostDto
{
Id = 77,
Title = "Write payload"
};
post.AuthorizeCircle(11);
// The client should not send ACL through POST/PUT blog payloads.
// ACL mutations have their own dedicated /blogacl endpoint.
var json = JsonSerializer.Serialize(post,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.False(root.TryGetProperty("acl", out _));
Assert.False(root.TryGetProperty("wireAcl", out _));
}
}

View file

@ -79,9 +79,9 @@ public class MainPageSaveTests
// whose Title is exactly what the user typed. The bug
// fails this assertion with Title == string.Empty.
Assert.NotEmpty(recorder.Calls);
var (method, path, body) = recorder.Calls[1];
var (method, path, body) = recorder.FirstCall;
Assert.Equal(HttpMethod.Post, method);
Assert.Equal("blogspot", path);
Assert.Equal("blog", path);
var sent = Assert.IsType<BlogPostDto>(body);
Assert.Equal(typed, sent.Title);
}

View file

@ -9,7 +9,6 @@ using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Blogspot;
namespace PostIt.Tests;
@ -95,7 +94,7 @@ public class PostAclDialogTests
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
@ -123,8 +122,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.ApiUrl);
var circleClient = new CircleApiClient(api, settings.ApiUrl);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);
@ -191,8 +190,9 @@ public class PostAclDialogTests
await Task.Delay(20);
}
// Assert: one GET went out (for /circle) from LoadAsync.
Assert.Equal(1, handler.RequestCount);
// Assert: exactly two GETs went out (one to /blogacl,
// one to /circle), both from the LoadAsync call.
Assert.Equal(2, handler.RequestCount);
// And the VM's idempotency gate has flipped.
Assert.True(vm.Loaded);
@ -218,58 +218,7 @@ public class PostAclDialogTests
await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded.
Assert.Equal(1, handler.RequestCount);
Assert.Equal(2, handler.RequestCount);
Assert.True(vm.Loaded);
}
[Fact]
public async Task LoadAsync_keeps_acl_from_blogpostdto_and_only_loads_circles()
{
var post = new BlogPostDto { Id = 42, Title = "ACL hydration" };
post.AuthorizeCircle(12);
post.AuthorizeCircle(34);
var api = new StubAclApiClient();
var aclClient = new BlogAclApiClient(api, "http://localhost/");
var circleClient = new CircleApiClient(api, "http://localhost/");
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
await vm.LoadAsync();
Assert.Equal(1, api.CallCount);
Assert.Equal(2, vm.AclEntries.Count);
Assert.Contains(vm.AclEntries, a => a.CircleId == 12);
Assert.Contains(vm.AclEntries, a => a.CircleId == 34);
}
private sealed class StubAclApiClient : IYavscApiClient
{
public HttpClient Http { get; } = new();
public int CallCount { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
CallCount++;
if (typeof(T) == typeof(List<CircleDto>))
{
var circles = new List<CircleDto>
{
new() { Id = 12, Name = "A", OwnerId = "owner", Public = false },
new() { Id = 34, Name = "B", OwnerId = "owner", Public = false },
};
return Task.FromResult((T)(object)circles);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
CallCount++;
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<InformationalVersion>1.1.0-beta.1+49.Branch.release-1.0.8-rc3.Sha.5274d7bdf5d14406748a2063b5ae15191aac4359</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
@ -16,7 +16,9 @@
<PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup>
@ -26,5 +28,6 @@
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup></ItemGroup>
</Project>
<ItemGroup>
</ItemGroup>
</Project>

View file

@ -55,21 +55,6 @@ public class PostItViewModelTests
Assert.Equal("Hello", posts[0].Title);
}
[Fact]
public async Task TogglePublishCommand_uses_the_current_checked_state_without_inverting_it()
{
var api = new RecordingPublishApi();
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
await viewModel.SetPublishStateAsync(true);
Assert.True(api.LastPublishValue);
Assert.True(viewModel.DraftIsPublished);
}
/// <summary>Test fake that always throws if the API is invoked.</summary>
private sealed class ThrowingYavscApiClient : YavscApiClient
{
@ -118,34 +103,4 @@ public class PostItViewModelTests
return Task.FromResult(default(T)!);
}
}
private sealed class RecordingPublishApi : IYavscApiClient
{
public bool LastPublishValue { get; private set; }
public HttpClient Http { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (method == HttpMethod.Put && path.Contains("/publish", StringComparison.OrdinalIgnoreCase))
{
var publish = body?.GetType().GetProperty("publish")?.GetValue(body) is bool value && value;
LastPublishValue = publish;
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (method == HttpMethod.Put && path.Contains("/publish", StringComparison.OrdinalIgnoreCase))
{
var publish = body?.GetType().GetProperty("publish")?.GetValue(body) is bool value && value;
LastPublishValue = publish;
}
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,5 +1,3 @@
using System.Text.Json;
namespace PostIt.Tests;
public class SettingsLoadTests
@ -89,7 +87,7 @@ public class SettingsLoadTests
settings.Authentication.RedirectUri =
global::AuthenticationSettings.DesktopRedirectUri;
settings.ApiUrl = flip
settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/";
@ -151,26 +149,4 @@ public class SettingsLoadTests
Assert.True(settings.Loaded);
}
[Fact]
public void SearchText_is_serialized_in_settings_and_round_trips()
{
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://example.test/",
ClientId = "postit-tests",
Scopes = new[] { "openid" }
}
};
settings.SearchText = "bonjour";
var json = JsonSerializer.Serialize(settings);
var roundTrip = JsonSerializer.Deserialize<PostIt.ViewModels.Settings>(json);
Assert.NotNull(roundTrip);
Assert.Equal("bonjour", roundTrip.SearchText);
}
}

View file

@ -94,26 +94,6 @@ 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.ApiUrl);
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
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" },
},
ApiUrl = "https://127.0.0.1:5003/api/v1",
BusinessApiUrl = "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" }
},
ApiUrl = apiBaseUrl
BusinessApiUrl = 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.ApiUrl);
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
// Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper.

View file

@ -1,5 +1,4 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
@ -16,8 +15,6 @@ namespace PostIt;
public partial class App : Application
{
private int _bootStarted;
/// <summary>
/// DI container the platform entry points hand to ViewModels so
/// they can resolve the canonical <see cref="Settings"/> singleton
@ -48,6 +45,8 @@ public partial class App : Application
{
var window = ServiceProvider.GetRequiredService<MainWindow>();
desktop.MainWindow = window;
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
desktop.MainWindow.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
View = window.MainView;
this.ConfigureRootView(window.MainView);
@ -76,8 +75,10 @@ public partial class App : Application
private void ConfigureRootView(MainView rootView)
{
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
// Déclencher le Boot lors du chargement du contrôle à l'écran
rootView.AttachedToVisualTree += async (_, _) => await BootAsync(this.ServiceProvider!, api);
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
@ -86,20 +87,14 @@ private void ConfigureRootView(MainView rootView)
rootView.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += async () =>
{
await PushMainPageAsync();
};
rootView.SessionBanner.DataContext = sessionStatus;
}
private async Task BootOnceAsync()
{
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
{
return;
}
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
await BootAsync(this.ServiceProvider!, api);
}
/// <summary>
/// Test-only hook: bind a concrete <see cref="MainView"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can
@ -135,6 +130,9 @@ 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>
@ -145,11 +143,10 @@ 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 PushBlogsPageAsync()
public static async Task PushMainPageAsync()
{
var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
await mainVm.InitializeAsync();
await app.PushPageAsync(mainVm);
}

View file

@ -68,38 +68,21 @@ 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();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == CaptureAreaProperty)
{
RewireCaptureArea();
}
}
private void RewireCaptureArea()
{
if (_wiredCaptureArea is { } previous)
if (CaptureArea is { } previous)
{
previous.PointerPressed -= OnCapturePressed;
previous.PointerMoved -= OnCaptureMoved;
previous.PointerReleased -= OnCaptureReleased;
}
_wiredCaptureArea = CaptureArea;
if (_wiredCaptureArea is { } area)
if (CaptureArea is { } area)
{
area.PointerPressed += OnCapturePressed;
area.PointerMoved += OnCaptureMoved;
@ -114,14 +97,12 @@ 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)
@ -188,16 +169,6 @@ 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>
@ -212,17 +183,6 @@ 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

@ -1,50 +0,0 @@
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

@ -1,34 +0,0 @@
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,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using PostIt.Views.Commands;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
@ -24,11 +23,6 @@ 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);
// Vues
@ -48,30 +42,9 @@ public static class ServiceCollectionHelpers
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>();
services.AddTransient<RdvPage>();
services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>();
services.AddTransient<SelectableHairPrestationItem>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
@ -80,13 +53,26 @@ public static class ServiceCollectionHelpers
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddSingleton<SessionStatusBanner>();
services.AddSingleton<MainViewModel>();
services.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
}

View file

@ -1,16 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<Content Include="postit-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@ -33,8 +31,9 @@
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup>
</Project>
</Project>

View file

@ -1,28 +0,0 @@
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

@ -1,7 +1,4 @@
using System;
using IdentityModel.OidcClient.Browser;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
@ -40,12 +37,4 @@ 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

@ -0,0 +1,72 @@
using System;
using System.Threading.Tasks;
using Avalonia.Threading;
namespace PostIt.Services;
/// <summary>
/// Tiny marshalling helper around <see cref="Dispatcher.UIThread"/> so
/// the rest of the codebase does not have to import Avalonia.Threading
/// directly. We want exactly one place that decides "is the current
/// thread the Avalonia UI thread, and if not, post there" so that
/// <see cref="ObservableObject"/>-derived types (Settings, the various
/// ViewModels) can fire <c>PropertyChanged</c> safely from background
/// work — which is exactly the cross-thread case that previously blew
/// up inside <c>DataValidationErrors.SetErrors</c> on Avalonia 11.
///
/// The helper is intentionally tiny: a sync post when we are off the
/// UI thread, a no-op when we are already on it, and an async fire-
/// and-forget variant for places where awaiting would deadlock the
/// caller (e.g. <c>Settings.Load</c> continuation paths).
/// </summary>
public static class UiDispatcher
{
/// <summary>
/// True when the calling thread is the Avalonia UI thread. Property
/// setters that touch bindings should check this before mutating
/// state; the safe path is <see cref="InvokeIfNeeded"/>.
/// </summary>
public static bool IsOnUiThread => Dispatcher.UIThread.CheckAccess();
/// <summary>
/// Run <paramref name="action"/> on the UI thread. If the caller is
/// already on the UI thread, run synchronously to preserve stack
/// traces and ordering; otherwise post to the dispatcher and wait.
/// Never throws on shutdown — a missing dispatcher is treated as
/// "best-effort skipped", matching Avalonia's own behaviour when
/// the application lifetime has been torn down.
/// </summary>
public static void InvokeIfNeeded(Action action)
{
if (action is null) return;
if (IsOnUiThread) { action(); return; }
try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
catch (InvalidOperationException) { /* dispatcher gone, nothing to do */ }
}
/// <summary>
/// Fire-and-forget variant: schedules <paramref name="action"/> on
/// the UI thread but does not block the caller. Use this from
/// background workers (OIDC discovery, HTTP callbacks, file I/O)
/// where awaiting the dispatcher would deadlock the calling sync
/// context.
/// </summary>
public static void Post(Action action)
{
if (action is null) return;
try { Dispatcher.UIThread.Post(action, DispatcherPriority.Normal); }
catch (InvalidOperationException) { /* dispatcher gone */ }
}
/// <summary>
/// Awaitable variant. Useful inside <c>async</c> ViewModel methods
/// that must touch bindings only after the dispatcher has processed
/// a queued update (e.g. "load file then refresh observable state").
/// </summary>
public static Task InvokeAsync(Action action)
{
if (action is null) return Task.CompletedTask;
if (IsOnUiThread) { action(); return Task.CompletedTask; }
return Dispatcher.UIThread.InvokeAsync(action, DispatcherPriority.Normal).GetTask();
}
}

View file

@ -1,5 +1,4 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@ -352,77 +351,6 @@ 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

@ -18,11 +18,11 @@ public partial class AuthenticationSettings : ObservableObject
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
public const string DefaultAuthority = "https://yavsc.pschneider.fr";
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
public const string DefaultClientId = "postit";
public static string DefaultClientId { get; internal set; } = "postit";
public static readonly string[] DefaultScopes = { "blogs" };
public static string[] DefaultScopes { get; set; } = { "blogs"} ;
[ObservableProperty]
public partial string Authority { get; set; }

View file

@ -4,9 +4,7 @@ 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;
@ -41,10 +39,6 @@ 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>(),

View file

@ -1,275 +0,0 @@
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
{
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é.";
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)
{
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"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)
{
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"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)
{
StatusMessage = "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>();
StatusMessage = "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>();
StatusMessage = $"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;
StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Performers = new ObservableCollection<ActivityUserDisplayItem>();
SelectedPerformer = null;
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Performers = new ObservableCollection<ActivityUserDisplayItem>();
SelectedPerformer = null;
StatusMessage = $"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)
{
StatusMessage = "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

@ -1,50 +0,0 @@
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

@ -1,173 +0,0 @@
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
{
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...";
public string Title => IsReadOnly
? $"Demandes en cours ({Form.Title})"
: $"Commandes {Form.Title}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
public bool CanOpenDetails => !IsReadOnly;
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() => !IsReadOnly && 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);
StatusMessage = BuildLoadedStatusMessage(filtered.Count);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Queries = new ObservableCollection<BillingQueryDisplayItem>();
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Queries = new ObservableCollection<BillingQueryDisplayItem>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))]
public async Task OpenSelectedQueryAsync()
{
if (IsReadOnly)
{
StatusMessage = "Mode lecture seule: l'ouverture en modification est désactivée.";
return;
}
if (SelectedQuery is null)
{
StatusMessage = "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 = Form.CreateCommandPageViewModel(Activity, Performer, _billingClient);
await vm!.InitializeAsync(details).ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"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

@ -1,35 +0,0 @@
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

@ -1,135 +0,0 @@
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
{
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; }
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();
StatusMessage = Forms.Count == 0
? "Aucun formulaire n'est disponible pour cette activité."
: "Choisissez le formulaire à utiliser.";
}
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)
{
StatusMessage = "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)
{
StatusMessage = "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)
{
StatusMessage = "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

@ -1,108 +0,0 @@
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
{
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; }
[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));
StatusMessage = 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

@ -1,165 +0,0 @@
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();
}
StatusMessage = AvailablePrestations.Count == 0
? "Aucune prestation coiffure disponible."
: SupportMessage;
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au catalogue de prestations (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors du chargement des prestations: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
protected override async Task SubmitAsync()
{
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
if (SelectedPrestation is null)
{
StatusMessage = "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);
}
StatusMessage = 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)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi de la commande: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,130 +0,0 @@
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)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList();
if (selectedPrestations.Count == 0)
{
StatusMessage = "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);
}
StatusMessage = 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)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi de la commande: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,211 +0,0 @@
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 double? Latitude { get; set; }
[ObservableProperty]
public partial double? Longitude { get; set; }
[ObservableProperty]
public partial DateTime EventDate { get; set; }
public RdvViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
EventDate = DateTime.Now.AddDays(1);
}
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;
Latitude = existingQuery.Location.Latitude;
Longitude = existingQuery.Location.Longitude;
}
StatusMessage = $"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)
{
StatusMessage = result.Message;
return;
}
Latitude = result.Latitude.Value;
Longitude = result.Longitude.Value;
StatusMessage = string.IsNullOrWhiteSpace(Address)
? "Position récupérée. Complétez l'adresse puis envoyez la commande."
: result.Message;
}
catch (OperationCanceledException)
{
StatusMessage = "La récupération de la position a été annulée.";
}
catch (Exception ex)
{
StatusMessage = $"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,
};
}
protected override async Task SubmitAsync()
{
if (!IsSupported)
{
StatusMessage = SupportMessage;
return;
}
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
if (string.IsNullOrWhiteSpace(Reason))
{
StatusMessage = "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);
}
StatusMessage = 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)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public override Task LoadAsync()
{
return Task.CompletedTask;
}
}

View file

@ -1,9 +1,4 @@
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;
@ -29,25 +24,8 @@ public class HomePageViewModel : ViewModelBase
Settings = settings;
SessionStatus = sessionStatus;
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
}
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);
}
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
/// <summary>
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can

View file

@ -48,7 +48,7 @@ public partial class MainViewModel : ViewModelBase
/// mutable field. Toggling is its own action.</summary>
[ObservableProperty]
public partial bool DraftIsPublished { get; set; }
public bool IsLoaded { get; private set; }
public Settings SettingsModel { get; }
[ObservableProperty]
@ -72,218 +72,6 @@ public partial class MainViewModel : ViewModelBase
[ObservableProperty]
public partial Settings Settings { get; private set; }
[RelayCommand]
internal async Task RefreshAsync()
{
await ExecuteAsync(async () =>
{
var posts = await BlogClient!.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
StatusMessage = $"Loaded {Posts.Count} posts.";
});
}
[RelayCommand]
internal async Task SearchAsync() {
await RefreshAsync();
ApplyFilter();
}
[RelayCommand]
internal async Task SaveAsync()
{
// The button is already disabled when the title is empty
// (see CanSave), but the test path (and any programmatic
// ICommand.Execute) bypasses CanExecute, so we still
// guard here. Better to no-op with a status message
// than to send a request the server will reject.
if (string.IsNullOrWhiteSpace(DraftTitle))
{
StatusMessage = "Title is required.";
return;
}
await ExecuteAsync(async () =>
{
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0)
{
var draft = new BlogPostDto
{
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
IsPublished = DraftIsPublished
};
var created = await BlogClient!.CreatePostAsync(draft);
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
}
}
else
{
var update = new BlogPostDto
{
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
Photo = SelectedPost.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}.";
}
await RefreshPostsAsync();
});
}
[RelayCommand]
internal async Task DeleteAsync()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Select an existing post before deleting.";
return;
}
await ExecuteAsync(async () =>
{
await BlogClient!.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null;
await RefreshPostsAsync();
});
}
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
public async Task SetPublishStateAsync(bool publish)
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
return;
}
await ExecuteAsync(async () =>
{
// The checkbox updates DraftIsPublished before the command is
// executed. Using the current bound value avoids the
// double-toggle bug in which the UI has already flipped the
// state and the command flips it again.
await BlogClient!.SetPublishAsync(SelectedPost.Id, publish);
DraftIsPublished = publish;
// Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the
// locally flipped state until the round-trip
// re-hydrates it.
SelectedPost.IsPublished = publish;
StatusMessage = publish
? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon.";
});
}
[RelayCommand]
internal async Task TogglePublishAsync()
{
await SetPublishStateAsync(DraftIsPublished);
}
/// <summary>
/// DEV ONLY: open the signature capture page. The production
/// entry point is a SignalR push from Yavsc.Org ("devis
/// received, sign here"); this command is the dev-time
/// shortcut to reach the page without that infrastructure.
/// Aligned on the same VM-first navigation pattern as
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
/// access from the view layer.
/// </summary>
[RelayCommand]
internal async Task OpenSignatureDevAsync()
{
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
}
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public async Task ManageAclAsync()
{
if (SelectedPost is null)
{
StatusMessage = "Select an existing post before managing ACL.";
return;
}
var postForAcl = SelectedPost;
try
{
var detailed = await BlogClient!.GetPostAsync(SelectedPost.Id).ConfigureAwait(true);
if (detailed is not null)
{
postForAcl = detailed;
SelectedPost = detailed;
}
}
catch
{
// Keep the dialog usable even if the detail refresh fails.
}
await ((App)App.Current!).PushPageAsync(GetACLViewModel(postForAcl)).ConfigureAwait(true);
}
[RelayCommand]
public async Task OpenCirclesAsync()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{
var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
}
/// <summary>
/// API surface that hits the Yavsc.Blogs deployment at
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by
@ -342,18 +130,17 @@ public partial class MainViewModel : ViewModelBase
private void Init(Settings? settings)
{
SearchText = string.Empty;
Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
SelectedPost = null;
IsBusy = false;
StatusMessage = "Ready";
Settings = settings ?? new Settings();
SearchText = Settings.SearchText;
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftIsPublished = false;
IsLoaded = false;
// Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor
// without a Settings argument; we fall back to a fresh
@ -365,15 +152,6 @@ public partial class MainViewModel : ViewModelBase
// (thread-safe dispatcher marshalling) so the duplicate
// instance is now merely wasteful, not dangerous.
Settings.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(Settings.SearchText))
{
SearchText = Settings.SearchText;
ApplyFilter();
}
};
}
/// <summary>Save is enabled as soon as the user has typed
@ -401,14 +179,7 @@ public partial class MainViewModel : ViewModelBase
Init(settings);
}
partial void OnSearchTextChanged(string value)
{
if (Settings is not null && Settings.SearchText != value)
{
Settings.SearchText = value;
}
ApplyFilter();
}
partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSelectedPostChanged(BlogPostDto? value)
{
@ -435,6 +206,170 @@ public partial class MainViewModel : ViewModelBase
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
[RelayCommand]
internal async Task LoadPosts()
{
await ExecuteAsync(async () =>
{
var posts = await BlogClient!.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
StatusMessage = $"Loaded {Posts.Count} posts.";
});
}
[RelayCommand]
internal void Search() => ApplyFilter();
[RelayCommand]
internal async Task Save()
{
// The button is already disabled when the title is empty
// (see CanSave), but the test path (and any programmatic
// ICommand.Execute) bypasses CanExecute, so we still
// guard here. Better to no-op with a status message
// than to send a request the server will reject.
if (string.IsNullOrWhiteSpace(DraftTitle))
{
StatusMessage = "Title is required.";
return;
}
await ExecuteAsync(async () =>
{
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0)
{
var draft = new BlogPostDto
{
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
var created = await BlogClient!.CreatePostAsync(draft);
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
}
}
else
{
var update = new BlogPostDto
{
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
Photo = SelectedPost.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}.";
}
await RefreshPostsAsync();
});
}
[RelayCommand]
internal async Task Delete()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Select an existing post before deleting.";
return;
}
await ExecuteAsync(async () =>
{
await BlogClient!.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null;
await RefreshPostsAsync();
});
}
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
[RelayCommand]
internal async Task TogglePublish()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
return;
}
await ExecuteAsync(async () =>
{
var desired = !DraftIsPublished;
await BlogClient!.SetPublishAsync(SelectedPost.Id, desired);
DraftIsPublished = desired;
// Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the
// locally flipped state until the round-trip
// re-hydrates it.
SelectedPost.IsPublished = desired;
StatusMessage = desired
? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon.";
});
}
/// <summary>
/// DEV ONLY: open the signature capture page. The production
/// entry point is a SignalR push from Yavsc.Org ("devis
/// received, sign here"); this command is the dev-time
/// shortcut to reach the page without that infrastructure.
/// Aligned on the same VM-first navigation pattern as
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
/// access from the view layer.
/// </summary>
[RelayCommand]
internal async Task OpenSignatureDev()
{
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
}
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{
var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
}
private async Task RefreshPostsAsync()
{
@ -491,18 +426,28 @@ public partial class MainViewModel : ViewModelBase
private void UpdateCommandStates()
{
RefreshCommand.NotifyCanExecuteChanged();
LoadPostsCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
}
internal async Task InitializeAsync()
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public async Task ManageAcl()
{
if (!IsLoaded)
if (SelectedPost is null)
{
await RefreshAsync();
IsLoaded = true;
StatusMessage = "Select an existing post before managing ACL.";
return;
}
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
}
[RelayCommand]
public async Task OpenCircles()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
}

View file

@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@ -10,17 +8,9 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using System.Net.Http;
namespace PostIt.ViewModels;
public sealed class PostAclEntry
{
public long CircleId { get; init; }
public string CircleName { get; init; } = string.Empty;
}
/// <summary>
/// View model for the "Gérer l'ACL" modal of a single blog post.
///
@ -51,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
MyCircles { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<PostAclEntry>
public partial ObservableCollection<PostAccessControlRulePayload>
AclEntries { get; set; } = new();
[ObservableProperty]
@ -87,9 +77,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
Post = post ?? throw new ArgumentNullException(nameof(post));
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
AclEntries = new ObservableCollection<PostAclEntry>(post.GetACL().Select(a => ToAclEntry(a.CircleId)));
SelectedCircleToAdd = null;
}
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
@ -103,17 +90,16 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true;
try
{
// Load circles for the picker. ACL entries come from the
// BlogPostDto detail payload (source of truth for initial state).
// Load circles and ACL entries in parallel — both are
// independent reads on the same host. The caller's uid
// is implicit in both endpoints.
var circlesTask = _circleClient.GetMyCirclesAsync();
await Task.WhenAll(circlesTask);
var aclTask = _aclClient.GetMyAclAsync();
await Task.WhenAll(circlesTask, aclTask);
var circles = circlesTask.Result ?? new List<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles);
// Resolve labels now that circles are available.
AclEntries = new ObservableCollection<PostAclEntry>(AclEntries.Select(a => ToAclEntry(a.CircleId)));
StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true;
@ -140,20 +126,14 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true;
try
{
if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id))
{
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
return;
}
var created = await _aclClient.GrantAsync(new PostAccessControlRulePayload
var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
{
CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id
});
if (created is not null)
{
AclEntries.Add(ToAclEntry(created.CircleId));
AclEntries.Add(created);
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
}
else
@ -161,13 +141,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
StatusMessage = "Autorisation refusée par le serveur";
}
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
{
// 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é";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
@ -179,16 +152,14 @@ public partial class PostAclDialogViewModel : ViewModelBase
}
[RelayCommand]
public async Task RevokeAsync(PostAclEntry? acl)
public async Task RevokeAsync(PostAccessControlRulePayload? acl)
{
if (acl is null) return;
IsBusy = true;
try
{
await _aclClient.RevokeAsync(acl.CircleId);
var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId);
if (existing is not null)
AclEntries.Remove(existing);
AclEntries.Remove(acl);
StatusMessage = "Autorisation révoquée";
}
catch (Exception ex)
@ -200,26 +171,4 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = false;
}
}
private async Task ReloadAclEntriesFromServerAsync()
{
var allAcl = await _aclClient.GetMyAclAsync();
var currentPostAcl = (allAcl ?? new List<PostAccessControlRulePayload>())
.Where(a => a.BlogPostId == Post.Id)
.Select(a => ToAclEntry(a.CircleId))
.GroupBy(a => a.CircleId)
.Select(g => g.First())
.ToList();
AclEntries = new ObservableCollection<PostAclEntry>(currentPostAcl);
}
private PostAclEntry ToAclEntry(long circleId)
{
var circleName = MyCircles.FirstOrDefault(c => c.Id == circleId)?.Name;
return new PostAclEntry
{
CircleId = circleId,
CircleName = string.IsNullOrWhiteSpace(circleName) ? $"Cercle #{circleId}" : circleName
};
}
}

View file

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

View file

@ -1,22 +0,0 @@
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

@ -26,10 +26,7 @@ public partial class Settings : ViewModelBase
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
/// <summary>
/// Catch top-level mutations: the four ObservableProperty
@ -45,8 +42,7 @@ public partial class Settings : ViewModelBase
partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
@ -154,10 +150,7 @@ 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
"blogs",
"api"
"offline_access" // OIDC: required to receive a refresh_token
};
/// <summary>
@ -300,16 +293,8 @@ 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))
{
this.Authentication = new AuthenticationSettings();
@ -324,7 +309,7 @@ public partial class Settings : ViewModelBase
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
}
else
this.Authentication.Scopes = settings.Authentication.Scopes;
this.Authentication.Scopes = settings.Authentication.Scopes;
}
}
// A disk load (or an embedded-resource fallback) is the
@ -353,26 +338,6 @@ 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
@ -383,9 +348,6 @@ 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;
}
/// <summary>

View file

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

View file

@ -1,147 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
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">
<TextBox Grid.Column="0"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -1,17 +0,0 @@
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

@ -1,52 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.BillingQueriesPage"
x:DataType="vm:BillingQueriesPageViewModel"
Header="Commandes billing">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="Bold" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" Margin="0,12,0,12">
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<Button Content="Ouvrir la commande"
Command="{Binding OpenSelectedQueryCommand}"
IsVisible="{Binding CanOpenDetails}" />
</StackPanel>
<ListBox Grid.Row="2" ItemsSource="{Binding Queries}" SelectedItem="{Binding SelectedQuery, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:BillingQueryDisplayItem">
<Border BorderThickness="0,0,0,1" BorderBrush="#22000000" Padding="0,0,0,10" Margin="0,0,0,10">
<StackPanel Spacing="3">
<TextBlock Text="{Binding Description}" FontWeight="Bold" />
<TextBlock Text="{Binding Summary}" TextWrapping="Wrap" FontSize="12" Opacity="0.8" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding EventDateLabel}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding BillingCode}" FontSize="11" Opacity="0.6" />
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<TextBox Grid.Column="0"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -1,17 +0,0 @@
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

@ -1,56 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
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}" />
<TextBox Grid.Column="6"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
</Grid>
</Grid>
</ContentPage>

View file

@ -1,17 +0,0 @@
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

@ -1,113 +0,0 @@
<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"
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}" />
<TextBox Grid.Column="2"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

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

View file

@ -1,91 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cvm="using:PostIt.ViewModels.Commands"
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" 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="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}" />
<TextBox Grid.Column="2"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

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

View file

@ -18,9 +18,5 @@
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

@ -24,20 +24,20 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<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" />
<StackPanel Grid.Row="0" Spacing="12"
HorizontalAlignment="Stretch"
VerticalAlignment="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding LoadPosts}" Content="Load posts" />
<Button Command="{Binding Search}" Content="Filter" />
<Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" />
<Button x:Name="ManageAclButton"
Command="{Binding ManageAclAsync}"
Command="{Binding ManageAcl}"
Content="ACL" />
<Button x:Name="OpenCirclesButton"
Command="{Binding OpenCirclesAsync}"
Command="{Binding OpenCircles}"
Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires
@ -49,7 +49,7 @@
and the buffer in sync. -->
<CheckBox Content="Publié"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Command="{Binding TogglePublishAsync}"
Command="{Binding TogglePublishCommand}"
VerticalAlignment="Center"/>
<!--
DEV ONLY: temporary shortcut to open the signature
@ -59,11 +59,11 @@
MainPage.axaml.cs once the SignalR handler lands.
-->
<Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDevAsync}"
Command="{Binding OpenSignatureDev}"
Content="[DEV] Signature"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</WrapPanel>
</Border>
</StackPanel>
</StackPanel>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"

View file

@ -1,6 +1,4 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
namespace PostIt.Views;
@ -10,16 +8,4 @@ public partial class MainPage : ContentPage
{
InitializeComponent();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (DataContext is ViewModels.MainViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
}
}

View file

@ -1,4 +1,3 @@
using System;
using Avalonia.Controls;
namespace PostIt.Views;
@ -9,16 +8,4 @@ public partial class MainView : UserControl
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ViewModels.MainViewModel vm)
{
if (!vm.IsLoaded)
{
vm.RefreshAsync().Wait();
}
}
}
}

View file

@ -3,7 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
x:DataType="vm:PostAclDialogViewModel"
>
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
@ -31,10 +32,10 @@
<ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:PostAclEntry">
<DataTemplate x:DataType="yabst:CircleAuthorization">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleName}"
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/>
</StackPanel>
<Button Grid.Column="1" Content="Révoquer"

View file

@ -39,41 +39,25 @@
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="API URL"/>
<TextBlock Grid.Row="8" Text="Business API URL"/>
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
Text="{Binding ApiUrl, Mode=TwoWay}"/>
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="10" Text="Dark mode"/>
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<StackPanel Grid.Row="12" Spacing="8">
<TextBlock Text="Avatar: PNG, JPG/JPEG, WEBP, GIF. Taille max 2 MB."
Opacity="0.8"
TextWrapping="Wrap" />
<TextBlock x:Name="AvatarUploadStatusText"
Text=""
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="ChooseAvatarButton"
Content="Choisir l'avatar"
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- 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 Content="Sauver"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</StackPanel>
<!-- 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}"/>
</Grid>
</ContentPage>

View file

@ -1,116 +1,12 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
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("Sélection d'un fichier avatar...", Brushes.Gray);
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
SetAvatarStatus("Impossible d'accéder à la fenêtre active.", Brushes.IndianRed);
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 annulé.", Brushes.Gray);
ChooseAvatarButton.IsEnabled = true;
return;
}
try
{
SetAvatarStatus("Upload avatar en cours...", Brushes.Gray);
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 à jour."
: message, Brushes.ForestGreen);
_ = ClearSuccessStatusLaterAsync(statusVersion);
}
catch (Exception ex)
{
SetAvatarStatus($"Échec upload avatar: {ex.Message}", Brushes.IndianRed);
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;
if (AvatarUploadStatusText.Foreground == Brushes.ForestGreen)
{
SetAvatarStatus($"Avatar prêt. Formats supportés: {AcceptedAvatarFormats}. Taille max: {MaxAvatarSizeMegabytes} MB.", Brushes.Gray);
}
}
private void SetAvatarStatus(string message, IBrush color)
{
AvatarUploadStatusText.Foreground = color;
AvatarUploadStatusText.Text = message;
}
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

@ -62,16 +62,9 @@ public partial class SignaturePage : ContentPage
var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return;
var pending = _control.PendingStroke;
var strokes = _control.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0;
while (i < sealedCount)
while (i < strokes.Count)
{
int k = strokes[i];
if (k <= 0) break;
@ -94,27 +87,5 @@ public partial class SignaturePage : ContentPage
poly.Points = pts;
InkLayer.Children.Add(poly);
}
if (pending.Count > 0)
{
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(pending.Count / 2);
for (int p = 0; p < pending.Count; p += 2)
{
int nx = pending[p];
int ny = pending[p + 1];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
}
}

View file

@ -12,8 +12,8 @@ namespace Yavsc.ViewModels.Account
public string UserName { get; set; }
[Required()]
[StringLength(maximumLength: 102, MinimumLength = 5)]
[EmailAddress(ErrorMessage = "L'adresse e-mail n'est pas valide.")]
[StringLength( maximumLength:102, MinimumLength = 5)]
// [EmailAddress]
[Display(Name = "Email", Description = "E-Mail")]
public string Email { get; set; }

View file

@ -1,5 +1,4 @@
using Yavsc.Abstract.Identity.Security;
using System.Text.Json.Serialization;
namespace Yavsc.Blogspot;
@ -36,26 +35,11 @@ public class BlogPostDto : IBlogPost
return true;
}
public ICollection<CircleAuthorization> ACL = new List<CircleAuthorization>();
/// <summary>
/// Wire-only ACL bridge for System.Text.Json: accepts the
/// <c>acl</c>/<c>ACL</c> payload from GET detail responses,
/// but is never emitted on POST/PUT from the client.
/// </summary>
[JsonPropertyName("acl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public List<CircleAuthorization>? WireAcl
{
get => null;
set => ACL = value ?? new List<CircleAuthorization>();
}
private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
public string[] Tags { get; set; }
ICollection<CircleAuthorization> ICircleAuthorized.ACL => this.ACL;
public string[] GetTags() => Tags;
public CircleAuthorization[] GetACL() => ACL.ToArray();
public ICircleAuthorization[] GetACL() => ACL.ToArray();
}

View file

@ -3,7 +3,8 @@ using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Abstract.BlogSpot;
public class PostAccessControlRulePayload : CircleAuthorization
public class PostAccessControlRulePayload : ICircleAuthorization
{
public long CircleId { get; set; }
public long BlogPostId { get; set; }
}

View file

@ -7,13 +7,6 @@ namespace Yavsc
{
public const string APIPrefix = "api/v1";
public const string BlogSpotPath = "blogspot";
public const string BlogAclPath = "blogacl";
public const string BlogTagPath = "blogtag";
public const string CirclePath = "circle";
public const string CommentsPath = "blogcomments";
public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" },
new Scope { Id = "book" , Description ="Your booking interface"},

View file

@ -1,11 +0,0 @@
namespace Yavsc.Models.Haircut;
/// <summary>
/// Lightweight hair-prestation description exposed to API clients.
/// </summary>
public sealed class HairPrestationDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
}

View file

@ -11,7 +11,7 @@ namespace Yavsc.Abstract.Identity.Security;
/// UI already has the post, and the circles are looked up by id
/// against the list returned by <c>GET /api/circle</c>.</para>
/// </summary>
public class CircleAuthorization
public sealed class CircleAuthorization : ICircleAuthorization
{
public long CircleId { get; set; }
}

View file

@ -0,0 +1,8 @@
namespace Yavsc.Abstract.Identity.Security
{
public interface ICircleAuthorization
{
long CircleId { get; set; }
}
}

View file

@ -9,7 +9,7 @@ namespace Yavsc.Abstract.Identity.Security
bool AuthorizeCircle(long circleId);
ICollection<CircleAuthorization> ACL { get; } //ICircleAuthorization [] GetACL();
ICircleAuthorization [] GetACL();
}
}

View file

@ -1,17 +0,0 @@
namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Activity node returned by the browsing API.
/// </summary>
public sealed class ActivityInfo
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ParentCode { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Photo { get; set; } = string.Empty;
public int Rate { get; set; }
public int PerformerCount { get; set; }
public List<CommandFormSummary> Forms { get; set; } = new();
public List<ActivityInfo> Children { get; set; } = new();
}

View file

@ -1,11 +0,0 @@
namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Lightweight command-form description exposed to API clients.
/// </summary>
public sealed class CommandFormSummary
{
public long Id { get; set; }
public string ActionName { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
}

View file

@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Yavsc.Models.Workflow
{
/// <summary>
/// Supported country of exercise for performer profile validations.
/// </summary>
public class Country
{
[Key]
[MaxLength(2)]
[MinLength(2)]
public string Code { get; set; } = string.Empty;
[Required]
[MaxLength(64)]
public string DisplayName { get; set; } = string.Empty;
}
}

View file

@ -3,7 +3,6 @@ namespace Yavsc.Workflow
public interface IPerformerProfile
{
string PerformerId { get; set; }
string ExerciseCountryCode { get; set; }
string SIREN { get; set; }
bool AcceptNotifications { get; set; }
long OrganizationAddressId { get; set; }

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