Compare commits

..

No commits in common. "main" and "1.0.7" have entirely different histories.

411 changed files with 2216 additions and 8135 deletions

View file

@ -21,13 +21,24 @@ on:
push: push:
branches: [ "main" ] branches: [ "main" ]
pull_request: pull_request:
branches: [ "main", "release/*" ] branches: [ "main" ]
jobs: jobs:
log-the-inputs:
runs-on: debian-latest
steps:
- run: |
echo "Log level: $LEVEL"
echo "Tags: $TAGS"
echo "Environment: $ENVIRONMENT"
env:
LEVEL: ${{ inputs.logLevel }}
TAGS: ${{ inputs.tags }}
build: build:
runs-on: docker runs-on: docker
container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
steps: steps:
- name: Clone yavsc - name: Clone yavsc
run: | run: |
@ -39,9 +50,10 @@ jobs:
git checkout FETCH_HEAD git checkout FETCH_HEAD
fi fi
git submodule update --init --recursive git submodule update --init --recursive
echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" echo "Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
- name: Restore dependencies
run: cd /src/_src && dotnet restore
- name: Build
run: cd /src/_src && dotnet build --no-restore
- name: Test - name: Test
run: | run: cd /src/_src && dotnet test --no-build --verbosity normal
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test --verbosity normal && echo "✅ Success !"

View file

@ -42,6 +42,11 @@ on:
description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).' description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).'
required: true required: true
type: string type: string
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
permissions: permissions:
contents: write contents: write
@ -51,14 +56,13 @@ jobs:
# via l'API REST Forgejo (pas d'actions tierces Node). # via l'API REST Forgejo (pas d'actions tierces Node).
release: release:
runs-on: docker runs-on: docker
container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
steps: steps:
- name: Clone du repo au tag demandé - name: Clone du repo au tag demandé
env: env:
# En push tag : github.ref_name est le tag. # En push tag : github.ref_name est le tag.
# En workflow_dispatch : on lit l'input 'tag'. # En workflow_dispatch : on lit l'input 'tag'.
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
FORCE_UNSTABLE: ${{ inputs.force_unstable || 'false' }}
run: | run: |
if [[ -z "$TAG" ]]; then if [[ -z "$TAG" ]]; then
echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input."
@ -111,17 +115,12 @@ jobs:
echo "Tag $TAG classifié comme channel=$CHANNEL" echo "Tag $TAG classifié comme channel=$CHANNEL"
# Seuls les suffixes explicitement autorisés déclenchent un # Fail-fast sur instable sauf opt-in explicite.
# release : -rcN et -betaN. Les autres suffixes (-alpha*, if [[ "$CHANNEL" == "unstable" && "${FORCE_UNSTABLE:-false}" != "true" ]]; then
# -dev*, -preview*, etc.) restent refusés — ils sont echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish."
# utilisables localement pour itérer, mais ne doivent pas echo "Set force_unstable=true via workflow_dispatch to override."
# être publiés comme release publique.
if [[ "$CHANNEL" == "unstable" ]]; then
if [[ ! "$SUFFIX" =~ ^-(rc|beta)([0-9]+)?$ ]]; then
echo "::error::Tag '$TAG' has suffix '$SUFFIX' which is not in the allowed release suffixes (-rcN, -betaN). Refusing to publish."
exit 1 exit 1
fi fi
fi
# Lecture du CHANGELOG.md (doit exister à la racine du repo). # Lecture du CHANGELOG.md (doit exister à la racine du repo).
if [[ ! -f CHANGELOG.md ]]; then if [[ ! -f CHANGELOG.md ]]; then
@ -196,10 +195,10 @@ jobs:
# la racine du checkout pour que l'étape d'upload le trouve. # la racine du checkout pour que l'étape d'upload le trouve.
run: | run: |
cd /src/_src cd /src/_src
APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/fr.pschneider.postit-Signed.apk APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk
if [[ ! -f "$APK" ]]; then if [[ ! -f "$APK" ]]; then
echo "::error::APK not found at $APK" echo "::error::APK not found at $APK"
ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64 2>/dev/null || true ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true
exit 1 exit 1
fi fi
cp "$APK" /src/_src/PostIt.Android.apk cp "$APK" /src/_src/PostIt.Android.apk

View file

@ -40,7 +40,7 @@ jobs:
- name: Extraire l'APK du conteneur Docker - name: Extraire l'APK du conteneur Docker
run: | run: |
docker create --name extractor postit-android 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 cp extractor:/src/src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk ./PostIt.Android.apk
docker rm extractor docker rm extractor
- name: Téléverser l'APK en tant qu'Artéfact GitHub - name: Téléverser l'APK en tant qu'Artéfact GitHub

3
.gitignore vendored
View file

@ -39,6 +39,3 @@ DataDir/
*.tests.trx *.tests.trx
*.tests.html *.tests.html
*.log

32
.vscode/launch.json vendored
View file

@ -4,21 +4,6 @@
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "Android Debug",
"type": "mono",
"preLaunchTask": "run-debug-android",
"request": "attach",
"address": "localhost",
"port": 55555
},
{
"name": "Android Attach - Debug",
"type": "mono",
"request": "attach",
"address": "localhost",
"port": 55555
},
{ {
"name": "API", "name": "API",
"type": "dotnet", "type": "dotnet",
@ -26,32 +11,23 @@
"projectPath": "${workspaceFolder}/src/Api/Api.csproj" "projectPath": "${workspaceFolder}/src/Api/Api.csproj"
}, },
{ {
"name": "Yavsc Org", "name": "Yavsc.Org",
"type": "dotnet", "type": "dotnet",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
}, },
{ {
"name": "Yavsc Blogs", "name": "Yavsc.Blogs",
"type": "dotnet", "type": "dotnet",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj" "projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
}, },
{ {
"name": "PostIt Desktop", "name": "PostIt",
"type": "dotnet", "type": "dotnet",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj", "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",
},
{
"name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll",
"args": [],
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests",
"console": "integratedTerminal",
"stopAtEntry": false
} }
] ]
} }

11
.vscode/mcp.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"servers": {
"openclaw": {
"type": "stdio",
"command": "/home/paul/.nvm/versions/node/v22.23.0/bin/node",
"args": [
"/home/paul/Workspace/tools/openclaw-mcp-server.js"
]
}
}
}

View file

@ -5,7 +5,6 @@
"appsettings", "appsettings",
"asciidoctor", "asciidoctor",
"ASPNETCORE", "ASPNETCORE",
"Avalonia",
"Configurabilité", "Configurabilité",
"Cratie", "Cratie",
"DESTDIR", "DESTDIR",
@ -16,7 +15,6 @@
"Hsts", "Hsts",
"Newtonsoft", "Newtonsoft",
"Npgsql", "Npgsql",
"Oidc",
"PKCE", "PKCE",
"postit", "postit",
"pschneider", "pschneider",

89
.vscode/tasks.json vendored
View file

@ -1,42 +1,6 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{
"label": "run-debug-android",
"command": "dotnet",
"type": "shell",
"options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
"env": {
"DOTNET_HOST_PATH": "/usr/share/dotnet",
"ANDROID_HOME": "/opt/android-sdk",
"JAVA_HOME": "/usr/lib/jvm/java-1.25.0-openjdk-amd64"
}
},
"args": [
"run",
"-p:TargetFramework=net10.0-android",
"-p:Configuration=Debug",
"-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
}
}
]
},
{ {
"label": "build", "label": "build",
"command": "dotnet", "command": "dotnet",
@ -75,6 +39,59 @@
"kind": "build" "kind": "build"
}, },
"isBackground": true "isBackground": true
},
{
"label": "test blogs",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["test"],
"runOptions": {
"instanceLimit": 1
},
"options": {
"cwd": "src/Yavsc.Blogs",
"env": {
"DOTNET_CLI_UI_LANGUAGE": "en-US",
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"group": {
"kind": "test"
},
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
}
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": ["watch", "--project",
"src/Yavsc.Org/Yavsc.Org.csproj"
],
"problemMatcher": "$msCompile",
"runOptions": {
}
} }
] ]
} }

View file

@ -16,75 +16,6 @@ Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) [`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`. pour la production des paquets `.deb`.
## [1.0.8-rc3] - unstable
### Added
* a code cleanup,
* a first Xamarin.UITest is successful, but disabled, because breaking the actual CI process,
* Android app starts, the login process succeeds
### Changed
L'identifiant de l'application client Android a changé, il passe en minuscules :
`fr.pschneider.postit`
### Fixed
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
### Added
- `BlogAclApiTests.PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test`
: test de non-régression qui épingle la forme exacte du payload
que PostIt envoie à `POST /api/v1/blogacl` (un objet
`PostAccessControlRulePayload` avec `CircleId` et `BlogPostId`).
C'est le verrou côté test du fix applicatif PostIt + serveur.
- `BlogAclApiTests.PostCircleAuthorization_never_returns_500` : une
`[Theory]` couvrant quatre shapes de payload (`{ circleId }`,
corps vide, `{ blogPostId }` seul, `{ circleId, blogPostId: 0 }`)
qui doivent tous retourner un statut différent de 500. Toute
réintroduction d'un chemin 500 dans le futur fera rougir ce test.
- `BlogAclApiTests.PostCircleAuthorization_dosent_return_500` et
`..._dosent_return_500_on_success` : entry points `[Fact]` qui
appellent la `[Theory]` ci-dessus avec un payload spécifique
chacun, pour pouvoir filtrer en isolation depuis la ligne de
commande ou le CI.
- Règle « Pas de `object` dans le code source applicatif » ajoutée
à `CONTRIBUTING.md` : types de retour, paramètres, champs,
propriétés, variables locales doivent être typés statiquement.
`dynamic` est interdit pour les mêmes raisons.
### Changed
- `BlogAclApiController.CheckOwner` devient `CheckOwnerAsync` et
utilise `FirstOrDefaultAsync` au lieu de `First`, supprimant
l'appel LINQ synchrone sur le fil de la requête et retournant
`false` sur cercle manquant (le contrôleur mappe déjà cela vers
`ChallengeResult`).
- `BlogsWebServerFixture` seed `alice`, son `Circle` et son
`BlogPost` une seule fois au démarrage du host, sur la
`SqliteConnection` partagée (`Cache=Shared`). Le précédent
`EnsureDeleted` au début de chaque test fermait la connexion
statique et détruisait le store `:memory:` pour tous les autres
`DbContext` ; il est retiré au profit d'un `EnsureCreated`
idempotent.
### Fixed
- `POST /api/v1/blogacl` ne retourne plus 500 sur les payloads
dont `BlogPostId` est absent ou à zéro. Le contrôleur rejette
`BlogPostId <= 0` avec `400 BadRequest` avant que la requête
n'atteigne `SaveChangesAsync`. L'incident de prod du 2026-08-21
sur mercure (PostIt envoyant seulement `circleId`, le serveur
voyant `BlogPostId = default(long) = 0` et EF Core levant
`InvalidOperationException` sur l'INSERT) n'est plus atteignable.
- PostIt `PostAclDialogViewModel.AddAsync` envoie désormais le
payload explicite `PostAccessControlRulePayload { CircleId,
BlogPostId }` au lieu de l'ancien `CircleAuthorization {
CircleId }`. Le DTO serveur `PostAccessControlRulePayload` est
introduit dans `Yavsc.Abstract` pour porter le contrat.
## [1.0.7] - preview ## [1.0.7] - preview
### Added ### Added
@ -188,7 +119,6 @@ Today, assigning ACL succeeds the first time, the following times result in a 40
the same user-visible switch without a schema change. the same user-visible switch without a schema change.
[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD [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.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]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6

View file

@ -49,57 +49,6 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ». item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
## Navigation (PostIt)
La navigation est centralisée dans
`App.PushPageAsync(ViewModelBase vm)` (`src/PostIt/PostIt/App.axaml.cs`).
Pour ouvrir un écran, un ViewModel (généralement dans une
commande `[RelayCommand]`) appelle
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`.
`PushPageAsync` résout la `Control` correspondante via le
`ViewLocator` (un `IDataTemplate` enregistré dans
`Application.DataTemplates` au boot), l'identifie comme
`Page`, lui assigne le VM comme `DataContext`, et appelle
`NavRoot.PushAsync(page)`. Une garde anti-empilement
compare par référence la nouvelle page au sommet courant
de la stack pour éviter un push doublon.
Pour qu'une nouvelle page soit navigable, il faut *deux*
enregistrements : la page dans le DI (`AddTransient<TPage>`
ou `AddSingleton<TPage>`) **et** une case dans le `switch`
de `ViewLocator.Build`. Si l'un manque, l'app affiche
"No view for X" sans crash.
Règles :
- On n'instancie jamais une `View` à la main depuis un
ViewModel, on ne récupère jamais une `View` depuis la DI
directement dans un ViewModel.
- Le ViewModel qui déclenche la nav ne pousse pas lui-même
la page ; il appelle `App.PushPageAsync(vm)` et laisse
`App` orchestrer le `PushAsync` physique.
- Le ViewModel qui déclenche la nav ne capture pas de
référence à `MainWindow` ou `NavigationPage`. Il passe
par `App.Current` (l'app Avalonia est un singleton).
Exemple canonique (depuis `MainPageViewModel`) :
```csharp
[RelayCommand]
internal async Task OpenSettings()
{
var settingsVm = ((App)App.Current!).ServiceProvider
.GetRequiredService<Settings>();
await ((App)App.Current!).PushPageAsync(settingsVm)
.ConfigureAwait(true);
}
```
Cf. [doc/architecture/postit.md](./doc/architecture/postit.md)
pour la topologie complète (host de navigation,
`SessionStatusViewModel`, signaux de cycle de vie vs nav
utilisateur).
## Conventions de code ## Conventions de code
Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en
@ -115,13 +64,6 @@ Quelques règles non capturées par `.editorconfig` :
- Préférer les types BCL (`int`, `string`) aux types framework - Préférer les types BCL (`int`, `string`) aux types framework
(`Int32`, `String`). (`Int32`, `String`).
- Préférer les expressions de pattern matching aux casts explicites. - Préférer les expressions de pattern matching aux casts explicites.
- **Pas de `object` dans le code source applicatif.** Types de retour,
paramètres, champs, propriétés, variables locales : tout doit être
typé statiquement. `dynamic` est interdit pour les mêmes raisons.
Un cast en `object` est presque toujours le symptôme d'un contrat
qu'on a laissé s'effriter (DTO, payload, handler) — refactore
le contrat (record typé, DTO dédié, méthode dédiée) au lieu de
shimer avec un cast.
## Branches & commits ## Branches & commits

View file

@ -11,6 +11,6 @@
from without conflicting names. from without conflicting names.
--> -->
<UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation> <UseProjectNamespaceForGitVersionInformation>true</UseProjectNamespaceForGitVersionInformation>
<NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn> <NoWarn>NU1701, NU1901, NU1902</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -18,7 +18,6 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" 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.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />

View file

@ -1,19 +1,20 @@
# Yavsc # Yavsc
[![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest)
C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé. C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé.
# Statut actuel des actions Forgejo # Statut actuel des actions Forgejo
[![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg)](https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=buildAndTest.yml)
* [![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg)](https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=buildAndTest.yml) [![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg)](
* [![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg)](
https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml
) )
[![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest)
# Statut actuel des actions GitHub # Statut actuel des actions GitHub
* [![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 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)

View file

@ -1,4 +1,4 @@
APP_PROJECT_NAMES=Org Blogs APP_PROJECT_NAMES=Api Org Blogs
SLNDIR=.. SLNDIR=..
include $(SLNDIR)/.env include $(SLNDIR)/.env
@ -7,6 +7,7 @@ include .env
generated/: generated/:
@mkdir -p $@ @mkdir -p $@
generated/yavscApi.service:
generated/yavscOrg.service: generated/yavscOrg.service:
generated/yavscBlogs.service: generated/yavscBlogs.service:
@ -33,11 +34,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@ @echo Created service file: $@
copy-services: copy-service-Org copy-service-Blogs copy-services: copy-service-Org copy-service-Api copy-service-Blogs
copy-service-Org: /etc/systemd/system/yavscOrg.service copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Api: /etc/systemd/system/yavscApi.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
copy-binaries: build_publish_Org build_publish_Blogs stop-services copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-services
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \ echo "$${project} -> $${LCAPI}" ; \
@ -84,6 +86,7 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(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.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig: showConfig:
@echo CONFIGURATION: $(CONFIGURATION) @echo CONFIGURATION: $(CONFIGURATION)
@ -92,4 +95,4 @@ showConfig:
clean: clean:
@rm -rf generated @rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean .PHONY: build_publish mep showConfig copy-service-Api copy-service-Org copy-service-Blogs reinstall clean

View file

@ -129,51 +129,30 @@ le DI est construit. Ordre, dans cet ordre :
## Navigation ## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"` Le host de navigation est un `NavigationPage x:Name="NavRoot"`
posé sur `MainWindow.axaml`. La pile est gérée par deux posé sur `MainWindow.axaml`. La pile est gérée par les
mécanismes distincts : événements du `SessionStatusViewModel` :
1. **Nav utilisateur (VM-first)** : un ViewModel (souvent dans
une commande `[RelayCommand]`) appelle
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`.
`App.PushPageAsync` (`src/PostIt/PostIt/App.axaml.cs`)
résout la `Control` correspondante via le `ViewLocator`
enregistré dans `Application.DataTemplates`, l'identifie
comme `Page`, lui assigne le VM comme `DataContext`, et
appelle `NavRoot.PushAsync(page)`. C'est le seul chemin
pour les boutons de la toolbar, les `OpenSettings` /
`OpenCircles` / `ManageAcl` / `OpenSignatureDev`, et
toute autre nav déclenchée par un ViewModel.
2. **Signaux de cycle de vie** : le `SessionStatusViewModel`
lève des événements consommés dans
`App.OnFrameworkInitializationCompleted` pour orchestrer
la nav de boot :
| Événement | Effet | | Événement | Effet |
|---------------------|------------------------------------------------------------------| |---------------------------------|------------------------------------------------------------------------|
| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage` (post-login). | | `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. |
| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). | | `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
| `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. |
Ces events ne sont **pas** un canal de nav utilisateur ; ils
portent une transition d'état applicatif (authentification
établie / perdue) et c'est `App` qui choisit d'en faire une
transition de pile.
### Garde anti-empilement ### Garde anti-empilement
`NavigationPage.PushAsync` n'est pas idempotent : pousser deux `NavigationPage.PushAsync` n'est pas idempotent : pousser deux
fois la même instance l'empile deux fois, et l'utilisateur doit fois la même instance l'empile deux fois, et l'utilisateur doit
taper **Retour** N fois pour sortir. La garde est implémentée taper **Retour** N fois pour sortir. Le handler
dans `App.PushPageAsync` (et consommée par tous les chemins `OpenSettingsRequested` est gardé pour bloquer ce cas :
de nav utilisateur) :
```csharp ```csharp
var stack = window.NavRoot.NavigationStack; var settingsPage = provider.GetRequiredService<SettingsPage>();
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{ {
return Task.CompletedTask; // déjà au sommet, no-op silencieux return; // déjà au sommet, no-op silencieux
} }
return window.NavRoot.PushAsync(page); _ = w.NavRoot.PushAsync(settingsPage);
``` ```
La comparaison est par référence, pas par type : on ne veut La comparaison est par référence, pas par type : on ne veut
@ -199,11 +178,9 @@ qui ne tiendrait plus).
- `SessionStatusViewModel` est le seul VM avec une durée de vie - `SessionStatusViewModel` est le seul VM avec une durée de vie
**process-entière** (singleton). Il survit à toutes les **process-entière** (singleton). Il survit à toutes les
navigations, expose `HasValidSession` en continu, et porte navigations, expose `HasValidSession` en continu, et porte
les événements de cycle de vie consommés par `App` pour les trois événements qui pilotent la navigation
orchestrer la nav de boot (`LoginSucceeded`, (`LoginSucceeded`, `LogoutCompleted`,
`LogoutCompleted`). La nav utilisateur déclenchée par `OpenSettingsRequested`).
l'utilisateur passe par `App.PushPageAsync(vm)`, pas par
un événement du `SessionStatusViewModel`.
- `MainPageViewModel` / `HomePageViewModel` / - `MainPageViewModel` / `HomePageViewModel` /
`SignaturePageViewModel` sont `Transient` — une nouvelle `SignaturePageViewModel` sont `Transient` — une nouvelle
@ -256,14 +233,10 @@ pour `[RelayCommand]`".
`ViewLocator.Build`. Oublier le `ViewLocator` est silencieux `ViewLocator.Build`. Oublier le `ViewLocator` est silencieux
(juste un TextBlock "No view for X"), pas une exception. (juste un TextBlock "No view for X"), pas une exception.
- **Ajouter un événement global de navigation** (par ex. - **Ajouter un événement global de navigation** (par ex.
"Push après payment success") : ne pas capturer `MainWindow` "Push après payment success") : passer par un événement sur
ni `NavigationPage` depuis le VM. La nav passe par un VM singleton (cf. `SessionStatusViewModel.OpenSettingsRequested`),
`App.PushPageAsync(vm)` dans tous les cas : soit le VM pas par une référence à `MainWindow` depuis le VM. Garder
appelle la méthode directement depuis une commande les VMs découplés du `IClassicDesktopStyleApplicationLifetime`.
(`[RelayCommand]`), soit un handler abonné à un événement
d'un singleton (cf. `SessionStatusViewModel`) l'appelle.
Garder les VMs découplés du
`IClassicDesktopStyleApplicationLifetime`.
- **Modifier l'OIDC** : la fiche à lire est - **Modifier l'OIDC** : la fiche à lire est
[postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche [postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche
ne ré-explique ni le flow, ni le pipe, ni le custom scheme. ne ré-explique ni le flow, ni le pipe, ni le custom scheme.

@ -0,0 +1 @@
Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d

View file

@ -1,8 +1,18 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.Services;
using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;

View file

@ -1,6 +1,7 @@
using Yavsc.Blogspot; using Yavsc.Blogspot;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc.Models;
namespace PostIt.Tests; namespace PostIt.Tests;

View file

@ -0,0 +1,10 @@
<Project>
<!-- Pull in shared package versions from the repository root. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- PostIt.Tests-specific versions -->
<ItemGroup>
<PackageVersion Include="Avalonia.Headless" Version="12.0.4" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="12.0.4" />
</ItemGroup>
</Project>

View file

@ -1,3 +1,6 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;
namespace PostIt.Tests; namespace PostIt.Tests;

View file

@ -1,8 +1,10 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -40,7 +42,7 @@ public class MainPageSaveTests
var recorder = new CallRecorder(); var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder); var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog); var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel }; var page = new MainPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it // MainPage is a ContentPage (a Page, not a Control), so it

View file

@ -1,8 +1,13 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Tests; namespace PostIt.Tests;

View file

@ -8,26 +8,24 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+49.Branch.release-1.0.8-rc3.Sha.5274d7bdf5d14406748a2063b5ae15191aac4359</InformationalVersion> <InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" /> <PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" /> <PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\..\src\PostIt\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -16,7 +16,7 @@ public class PostItViewModelTests
// throws on any call (we never call the API in this test). // throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient(); var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi, "http://localhost/"); var blog = new BlogApiClient(fakeApi, "http://localhost/");
var viewModel = new MainViewModel(blog); var viewModel = new MainPageViewModel(blog);
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
@ -49,7 +49,7 @@ public class PostItViewModelTests
var api = new StubYavscApiClient(expected); var api = new StubYavscApiClient(expected);
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api, "http://localhost/");
var posts = await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); var posts = await blog.GetPostsAsync();
Assert.Equal(2, posts.Count); Assert.Equal(2, posts.Count);
Assert.Equal("Hello", posts[0].Title); Assert.Equal("Hello", posts[0].Title);

View file

@ -1,4 +1,5 @@
using PostIt.Services; using PostIt.Services;
using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;

View file

@ -1,5 +1,7 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -9,7 +11,7 @@ namespace PostIt.Tests;
/// <summary> /// <summary>
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside /// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
/// a real <see cref="MainView"/> via the headless Avalonia /// a real <see cref="MainWindow"/> via the headless Avalonia
/// platform declared in <c>TestApp.cs</c>. /// platform declared in <c>TestApp.cs</c>.
/// ///
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c> /// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
@ -34,10 +36,11 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree() public void Banner_renders_three_buttons_in_the_visual_tree()
{ {
MainWindow window = new MainWindow(); var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show(); window.Show();
var buttons = window.GetVisualDescendants() var buttons = window.SessionBanner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.ToList(); .ToList();
@ -45,7 +48,7 @@ public class SessionStatusBannerTests
// déconnecter, Se connecter, Paramètres. If any one is // déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the // missing, the user has no way to trigger the
// corresponding navigation event. // corresponding navigation event.
Assert.Equal(4, buttons.Count); Assert.Equal(3, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter"); Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter"); Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres"); Assert.Contains(buttons, b => b.Content as string == "Paramètres");
@ -54,10 +57,13 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out() public void Banner_login_button_is_visible_when_logged_out()
{ {
MainWindow window = new MainWindow(); var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.True(vm.IsLoggedOut); // VM default
window.SessionBanner.DataContext = vm;
window.Show(); window.Show();
var login = window.GetVisualDescendants() var login = window.SessionBanner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se connecter"); .Single(b => b.Content as string == "Se connecter");
@ -69,13 +75,13 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out() public void Banner_logout_button_is_hidden_when_logged_out()
{ {
SessionStatusBanner banner = CreateBanner(); var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.False(vm.IsLoggedIn); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
Assert.False((banner.DataContext as SessionStatusViewModel)! var logout = window.SessionBanner.GetVisualDescendants()
.IsLoggedIn); // VM default
var logout = banner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter"); .Single(b => b.Content as string == "Se déconnecter");
@ -85,9 +91,11 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_settings_button_is_visible_regardless_of_session() public void Banner_settings_button_is_visible_regardless_of_session()
{ {
SessionStatusBanner banner = CreateBanner(); var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var settings = banner.GetVisualDescendants() var settings = window.SessionBanner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Paramètres"); .Single(b => b.Content as string == "Paramètres");
@ -97,21 +105,14 @@ public class SessionStatusBannerTests
Assert.True(settings.IsVisible); Assert.True(settings.IsVisible);
} }
private static SessionStatusBanner CreateBanner()
{
MainWindow window = new MainWindow();
window.Show();
var banner = window.MainView.SessionBanner;
var status = new SessionStatusViewModel();
banner.DataContext = status;
return banner;
}
[AvaloniaFact] [AvaloniaFact]
public void Banner_session_label_reflects_DataContext() public void Banner_session_label_reflects_DataContext()
{ {
SessionStatusBanner banner = CreateBanner(); var window = new MainWindow();
var label = banner.GetVisualDescendants() window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var label = window.SessionBanner.GetVisualDescendants()
.OfType<TextBlock>() .OfType<TextBlock>()
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté"); .First(t => t.Text == "Déconnecté" || t.Text == "Connecté");

View file

@ -1,3 +1,9 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;
public class SettingsLoadTests public class SettingsLoadTests
@ -85,7 +91,7 @@ public class SettingsLoadTests
bool flip = ((workerId + i) & 1) == 0; bool flip = ((workerId + i) & 1) == 0;
settings.DarkMode = flip; settings.DarkMode = flip;
settings.Authentication.RedirectUri = settings.Authentication.RedirectUri =
global::AuthenticationSettings.DesktopRedirectUri; global::AuthenticationSettings.DefaultDesktopRedirectUri;
settings.BusinessApiUrl = flip settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/" ? "https://a.example.test/api/v1/"
@ -103,7 +109,7 @@ public class SettingsLoadTests
{ {
failures.Add(ex); failures.Add(ex);
} }
}, TestContext.Current.CancellationToken); });
} }
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
@ -123,7 +129,7 @@ public class SettingsLoadTests
/// and checking that Loaded flips exactly once (no torn reads). /// and checking that Loaded flips exactly once (no torn reads).
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Load_is_idempotent_under_concurrent_calls() public void Load_is_idempotent_under_concurrent_calls()
{ {
var settings = new PostIt.ViewModels.Settings var settings = new PostIt.ViewModels.Settings
{ {
@ -143,9 +149,9 @@ public class SettingsLoadTests
{ {
barrier.SignalAndWait(); barrier.SignalAndWait();
settings.Load(); settings.Load();
}, TestContext.Current.CancellationToken); });
} }
await Task.WhenAll(tasks); Task.WaitAll(tasks);
Assert.True(settings.Loaded); Assert.True(settings.Loaded);
} }

View file

@ -1,5 +1,8 @@
using System;
using System.Linq;
using PostIt.Controls; using PostIt.Controls;
using PostIt.Models; using PostIt.Models;
using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;

View file

@ -1,6 +1,10 @@
using System;
using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks;
using PostIt.Controls; using PostIt.Controls;
using PostIt.ViewModels; using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;

View file

@ -1,4 +1,5 @@
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.Controls;
using PostIt.Views; using PostIt.Views;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -8,7 +9,8 @@ public class MainPageTests
[AvaloniaFact] [AvaloniaFact]
public void MainPage_Should_Load() public void MainPage_Should_Load()
{ {
var window = new MainView(); var window = new MainWindow();
window.Show();
Assert.NotNull(window); Assert.NotNull(window);
} }
} }

View file

@ -1,10 +1,22 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;
using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;

View file

@ -1,39 +1,21 @@
<Project> <Project>
<Import <!-- Pull in shared package versions from the repository root. -->
Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" /> <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- PostIt-product-specific versions --> <!-- PostIt-product-specific versions -->
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- Avalonia packages --> <PackageVersion Include="Avalonia" Version="12.0.4" />
<!-- Important: keep version in sync! --> <PackageVersion Include="Avalonia.Android" Version="12.0.4" />
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Browser" Version="12.0.4" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Desktop" Version="12.0.4" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.2" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<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="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" /> <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" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,178 +0,0 @@
# Cibles pour installer PostIt.Android en Debug sur l'AVD qemu.
#
# Usage typique :
# make qemu # lance l'AVD, attend le boot, build l'APK, l'installe
# make android-install # (re)build l'APK et l'installe (AVD doit tourner)
# make android-build # build l'APK seul (sans install)
# make qemu-run # démarre l'AVD en background
# make qemu-stop # arrête l'émulateur
# make qemu-wait-boot # attend que l'AVD ait fini de booter
#
# Variables surchargeables (make VAR=valeur) :
# AVD_NAME default: postit_test_avd
# (l'AVD doit être listé par `avdmanager list avd`)
# ADB_SERIAL default: emulator-5554
# (port standard du premier émulateur lancé)
# ANDROID_HOME default: /opt/android-sdk
# (le SDK Android local; doit contenir
# emulator/emulator et platform-tools/adb)
# POSTIT_RID default: android-x64
# (doit matcher l'ABI de l'AVD; `avdmanager list avd`
# affiche la ligne Tag/ABI)
# EMU_HEADLESS default: 0
# (1 = lancer l'émulateur sans fenêtre, pour scripter)
# CONFIG surcharge la variable CONFIG globale (Debug par
# défaut dans ce Makefile). Passer à Release pour
# un APK optimisé et signé release.
# LOGCAT_LINES default: 200
# (nombre de lignes dumpées par `make qemu-logcat`)
# LOGCAT_FOLLOW default: 0
# (1 = stream live via `make logcat`,
# sinon dump one-shot des N dernières lignes)
# LOGCAT_BOOT_WAIT default: 30
# (secondes d'attente entre le clear du buffer,
# le `am start`, et le dump final dans
# `make qemu-logcat-boot`)
AVD_NAME ?= postit_test_avd
ADB_SERIAL ?= emulator-5554
ANDROID_HOME ?= /opt/android-sdk
POSTIT_RID ?= android-x64
EMU_HEADLESS ?= 0
LOGCAT_LINES ?= 600
LOGCAT_FOLLOW ?= 0
LOGCAT_BOOT_WAIT ?= 30
ANDROID_PACKAGE_NAME = fr.pschneider.postit
POSTIT_ANDROID_CSPROJ := PostIt.Android/PostIt.Android.csproj
POSTIT_APK_DIR := PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID)
POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk
clean: clean-PostIt clean-PostIt.Android clean-PostIt.Desktop
clean-%:
rm -rf $*/obj $*/bin
qemu-run:
@echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..."
@mkdir -p /tmp/yavsc-emu
@EMU_ARGS=""; \
if [ "$(EMU_HEADLESS)" = "1" ]; then EMU_ARGS="-no-window -no-audio"; fi; \
$(ANDROID_HOME)/emulator/emulator -avd $(AVD_NAME) $$EMU_ARGS \
>/tmp/yavsc-emu/$(AVD_NAME).log 2>&1 & \
echo " ✅ Started emulator PID: $$!"
qemu-stop:
adb -s $(ADB_SERIAL) emu kill
echo " ✅ Stopped emulator"
qemu-wait-boot:
@echo " Waiting for $(ADB_SERIAL) to finish booting..."
adb -s $(ADB_SERIAL) wait-for-device
@for i in $$(seq 1 180); do \
BOOTED=$$(adb -s $(ADB_SERIAL) shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n'); \
if [ "$$BOOTED" = "1" ]; then \
echo " ✓ booted in $${i}s"; \
exit 0; \
fi; \
sleep 1; \
done; \
echo " 👿 ERROR: device did not boot within 180s." >&2; \
echo " Logs: /tmp/yavsc-emu/$(AVD_NAME).log" >&2; \
exit 1
android-build:
# EmbedAssembliesIntoApk=true: without this, the Debug APK ships
# without the managed assemblies in it (they are pushed at runtime
# via `adb push`, "Fast Deployment"). On the qemu emulator, the
# runtime cannot find them in `files/.__override__/<rid>/` and
# aborts at startup with "No assemblies found in '.__override__'"
# (monodroid-glue.cc:757, SIGABRT). Forcing this property on
# packages the .dlls into the APK as `assemblies/<rid>/` so the
# runtime reads them directly.
#
# The Xamarin.Android SDK property is `EmbedAssembliesIntoApk`,
# not `AndroidEnableFastDeployment` (which exists in older
# templates but is a no-op in the .NET 10 SDK).
dotnet build $(POSTIT_ANDROID_CSPROJ) \
-c $(CONFIG) \
-p:RuntimeIdentifier=$(POSTIT_RID) \
-p:EmbedAssembliesIntoApk=true \
--nologo
@if [ ! -f "$(POSTIT_APK)" ]; then \
echo " APK not found at $(POSTIT_APK)." >&2; \
echo " Files in $(POSTIT_APK_DIR):" >&2; \
ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \
exit 1; \
fi
android-install: android-build
@echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..."
adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r
@echo " ✅ PostIt.Android installed on $(ADB_SERIAL)"
qemu-uninstall:
adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME)
# Dump recent logcat output for the running PostIt.Android process.
# By default, prints the last $(LOGCAT_LINES) lines (one-shot, with
# `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead.
# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag,
# because Mono/Xamarin can emit logs under several tags
# (mono, PostIt.Android, Avalonia.Android) and tag-based filtering
# would miss the ones not matching. PID-based filtering is exact.
# If the app is not running, pidof returns empty and logcat exits
# silently with no output; that is the expected behaviour for
# "no logs yet".
logcat:
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
if [ -z "$$PID" ]; then \
echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \
echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \
exit 1; \
fi; \
echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \
if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \
adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \
else \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \
fi
# Clear logcat, launch PostIt.Android, then dump everything that was
# emitted during the startup window. Targets the "démarrage KO" case
# where the process starts but Avalonia never renders a frame — the
# logcat trace from process start to first frame is what diagnoses it.
#
# Override LOGCAT_BOOT_WAIT to extend the post-launch wait
# (default 15s; raise to 30+ if the device is slow to boot Avalonia).
LOGCAT_BOOT_WAIT ?= 15
android-start:
@echo " Clearing logcat buffer..."
adb -s $(ADB_SERIAL) logcat -c
@echo " Launching $(ANDROID_PACKAGE_NAME)..."
adb -s $(ADB_SERIAL) shell am start \
-n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity
@echo "$(ANDROID_PACKAGE_NAME) started on $(ADB_SERIAL)"
qemu-logcat-boot: android-start
@echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..."
@sleep $(LOGCAT_BOOT_WAIT)
@echo " Dumping logcat (PostIt PID + system buffer):"
@PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \
if [ -n "$$PID" ]; then \
echo " ✅ (PID $$PID at dump time)"; \
sleep 10; \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID; \
else \
echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \
adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \
exit 1; \
fi
qemu: qemu-run qemu-wait-boot android-install
.PHONY: clean qemu qemu-run qemu-stop qemu-wait-boot android-build android-install logcat qemu-logcat-boot

View file

@ -2,12 +2,6 @@
using Android.Runtime; using Android.Runtime;
using Avalonia; using Avalonia;
using Avalonia.Android; using Avalonia.Android;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Avalonia.Controls;
using Avalonia.Styling;
using Yavsc.Api.Client;
namespace PostIt.Android namespace PostIt.Android
{ {

View file

@ -1,11 +1,8 @@

using Android.App; using Android.App;
using Android.Content;
using Android.Content.PM; using Android.Content.PM;
using AndroidX.Core.Provider; using Android.Content;
using AndroidX.Emoji2.Text; using Avalonia;
using Avalonia.Android; using Avalonia.Android;
using PostIt.Droid.Services;
namespace PostIt.Android; namespace PostIt.Android;
@ -15,27 +12,25 @@ namespace PostIt.Android;
Theme = "@style/MyTheme.NoActionBar", Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon", Icon = "@drawable/icon",
MainLauncher = true, MainLauncher = true,
LaunchMode = LaunchMode.SingleTask,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity public class MainActivity : AvaloniaMainActivity
{ {
/// <summary> /// <summary>
/// The current MainActivity instance. /// Strongly-typed handle to the current MainActivity instance, set in
/// <see cref="OnCreate"/> and consumed by platform services such as
/// <see cref="Services.AndroidSystemBrowser"/> which need to launch
/// Chrome Custom Tabs.
/// </summary> /// </summary>
public static MainActivity? Current { get; private set; } public static MainActivity? Current { get; private set; }
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState) protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
{ {
FontRequest fontRequest = new FontRequest(
"com.google.android.gms.fonts",
"com.google.android.gms",
"Noto Color Emoji Compat",
Yavsc.Resource.Array.com_google_android_gms_fonts_certs); //com_google_android_gms_fonts_certs
EmojiCompat.Config config = new FontRequestEmojiCompatConfig(this, fontRequest);
EmojiCompat.Init(config);
PlatformBootstrap.InitPlatform();
base.OnCreate(savedInstanceState); base.OnCreate(savedInstanceState);
PlatformBootstrap.EnsureInitialized();
Current = this; Current = this;
} }
/// <summary> /// <summary>
/// Receives the deep-link Intent fired by the system browser after the /// Receives the deep-link Intent fired by the system browser after the
/// user completes the OIDC login on https://yavsc.pschneider.fr. The /// user completes the OIDC login on https://yavsc.pschneider.fr. The
@ -48,13 +43,7 @@ public class MainActivity : AvaloniaMainActivity
protected override void OnNewIntent(Intent? intent) protected override void OnNewIntent(Intent? intent)
{ {
base.OnNewIntent(intent); base.OnNewIntent(intent);
if (intent is not null) AndroidOidcCallbackSink.Handle(intent);
var url = intent?.DataString;
if (!string.IsNullOrEmpty(url) && url.StartsWith("postit://callback"))
{
OidcCallbackManager.SetResult(url);
}
} }
internal static class AndroidOidcCallbackSink internal static class AndroidOidcCallbackSink

View file

@ -12,9 +12,14 @@ namespace PostIt.Android;
/// </summary> /// </summary>
internal static class PlatformBootstrap internal static class PlatformBootstrap
{ {
internal static void InitPlatform() private static int _initialized;
{
internal static void EnsureInitialized()
{
if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0)
return;
Platform.DefaultRedirectUri = ViewModels.Settings.AndroidRedirectUri;
Platform.CreateBrowser = () => Platform.CreateBrowser = () =>
{ {
var activity = MainActivity.Current; var activity = MainActivity.Current;

View file

@ -2,30 +2,35 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0-android</TargetFramework> <TargetFramework>net10.0-android</TargetFramework>
<SupportedOSPlatformVersion>23</SupportedOSPlatformVersion> <!-- FORCE LES ARCHITECTURES ANDROID VALIDES -->
<RuntimeIdentifiers>android-arm64;android-x64</RuntimeIdentifiers>
<SupportedOSPlatformVersion>23.0.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationId>fr.pschneider.postit</ApplicationId> <ApplicationId>com.CompanyName.PostIt</ApplicationId>
<ApplicationVersion>1</ApplicationVersion> <ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">
<Link>Resources\drawable\Icon.png</Link> <Link>Resources\drawable\Icon.png</Link>
</AndroidResource> </AndroidResource>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Android" /> <PackageReference Include="Avalonia.Android" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" /> <PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
<PackageReference Include="Xamarin.AndroidX.Browser" /> <PackageReference Include="Xamarin.AndroidX.Browser" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project> </Project>

View file

@ -1,6 +1,32 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application android:label="PostIt" android:icon="@drawable/Icon"> <application android:label="PostIt" android:icon="@drawable/Icon">
<!--
Deep-link receiver for the OIDC Authorization Code + PKCE flow.
After the user authenticates in the system browser, the OP
redirects to android://postit-signin?... and Android forwards
the Intent to the MainActivity (configured SingleTask so the
existing instance receives OnNewIntent rather than spawning a
new one).
The host value (postit-signin) MUST match the
AndroidRedirectUri constant in PostIt/Settings/Settings.cs and
the corresponding RedirectUri registered for the 'postit'
client in IdentityServer (Yavsc.Org ConfigurationDb).
-->
<activity-alias
android:name="PostIt.Android.OidcCallbackActivity"
android:targetActivity="PostIt.Android.PostItMainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="android" android:host="postit-signin" />
</intent-filter>
</activity-alias>
</application> </application>
</manifest> </manifest>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>MIIEqDCCA5CgAwIBAgIJAN5gc16AJfAsMA0GCSqGSIb3DQEBBQUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHR29vZ2xlMRAwDgYDVQQLEwdBbmRyb2lkMRAwDgYDVQQDEwdBbmRyb2lkMSEwHwYJKoZIhvcNAQkBFhJhbmRyb2lkQGFuZHJvaWQuY29tMCAXDTA4MDQxNTIyNDA0M1YYDzQyMDgxMzA0MjI0MDQzWjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDURvdW50YWluIFZpZXcxEDAOBgNVBAoTB0dvb2dsZTEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEhMB8GCSqGSIb3DQEJARYSYW5kcm9pZEBhbmRyb2lkLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALBi1vF0K1vOEHG7AxneTjOHUka46MIidBqvFcO164A49iU2DkYPhUaM4H8JCdzh6N1GzM6h9o6E2V6z8+gEtdI6nqqs0EGA0G0H701bFjLp9+K/1DkMIFeD4P8J7X1/M8t4+X09X/7bQyV3w0v7q+Qh38sY8W/7K29B3f2O2sLw+uX9U8a8Tf4Xv8A==</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>MIIEQzCCAyugAwIBAgIJAMLgh0ZgXpYOMA0GCSqGSIb3DQEBBQUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKKvSkUIXm+t9M8rXj2V</item>
</string-array>
</resources>

View file

@ -1,9 +1,9 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Android.App; using Android.App;
using Android.Content;
using AndroidX.Browser.CustomTabs; using AndroidX.Browser.CustomTabs;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;
using PostIt.Droid.Services;
namespace PostIt.Android.Services; namespace PostIt.Android.Services;
@ -36,19 +36,14 @@ public sealed class AndroidSystemBrowser : IBrowser
}; };
} }
// 1. Enregistrez la tâche avant de lancer le Custom Tab
var callbackTask = OidcCallbackManager.RegisterCallback(cancellationToken);
// 2. LANCEZ VOTRE CUSTOM TAB ICI (via AndroidX.Browser.CustomTabs)
// ... code pour ouvrir l'URL d'authentification ...
var uri = global::Android.Net.Uri.Parse(options.StartUrl)!; var uri = global::Android.Net.Uri.Parse(options.StartUrl)!;
var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync();
var tabsIntent = new CustomTabsIntent.Builder() var tabsIntent = new CustomTabsIntent.Builder()
.SetShowTitle(true)! .SetShowTitle(true)
.Build(); .Build();
tabsIntent!.LaunchUrl(_activity, uri); tabsIntent.LaunchUrl(_activity, uri);
string responseUri; string responseUri;
try try

View file

@ -1,21 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Droid.Services;
public static class OidcCallbackManager
{
private static TaskCompletionSource<string>? _tcs;
public static Task<string> RegisterCallback(CancellationToken cancellationToken)
{
_tcs = new TaskCompletionSource<string>();
cancellationToken.Register(() => _tcs.TrySetCanceled());
return _tcs.Task;
}
public static void SetResult(string url)
{
_tcs?.TrySetResult(url);
}
}

View file

@ -1,35 +0,0 @@
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using PostIt.Droid.Services;
namespace PostIt.Android;
[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
[IntentFilter(new[] { Intent.ActionView },
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "postit", // Remplacez par votre schéma personnalisé (ex: yavsc ou postit)
DataHost = "callback")] // Correspond à postit://callback
public class WebAuthenticationCallbackActivity : Activity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
// Capturer l'URL de redirection OIDC
var url = Intent?.DataString;
if (!string.IsNullOrEmpty(url))
{
// Transmettre l'URL au gestionnaire partagé pour compléter la Task
OidcCallbackManager.SetResult(url);
}
// Fermer cette activité transparente et ramener l'application au premier plan
var intent = new Intent(this, typeof(MainActivity));
intent.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
StartActivity(intent);
Finish();
}
}

View file

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

View file

@ -1,4 +1,5 @@
using System.Threading.Tasks; using System.Runtime.Versioning;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Browser; using Avalonia.Browser;
using PostIt; using PostIt;

View file

@ -0,0 +1,30 @@
using IdentityModel.OidcClient.Browser;
using PostIt.Services;
namespace PostIt.Desktop;
/// <summary>
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
/// the shared OIDC login path sees a working <c>IBrowser</c> — the
/// custom-scheme browser that hands the OIDC callback off to the
/// running instance through the named pipe. Desktop builds do NOT use
/// a loopback HTTP listener: the <c>postit://</c> scheme is registered
/// with the OS at install time and the browser is whatever the user
/// has configured to open it.
/// </summary>
internal static class PlatformBootstrap
{
private static int _initialized;
internal static void EnsureInitialized()
{
if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0)
return;
// Use the custom-scheme redirect on Desktop. Loopback is only
// a fallback for platforms that cannot register postit://
// (see Settings.DefaultLoopbackRedirectUri for that path).
Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri;
Platform.CustomScheme = "postit";
}
}

View file

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

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Threading;
using Avalonia; using Avalonia;
using PostIt.Services; using PostIt.Services;
@ -12,6 +13,8 @@ sealed class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
PlatformBootstrap.EnsureInitialized();
// Short-circuit 2nd-instance launches (OS handing us the // Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window. // postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes // If we let Avalonia initialise, the new MainWindow flashes
@ -66,6 +69,9 @@ sealed class Program
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>() => AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont() .WithInterFont()
.LogToTrace(); .LogToTrace();
} }

View file

@ -1,153 +0,0 @@
using Avalonia;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless coverage for the two interactive buttons of the
/// "add a circle member" modal: "Ajouter" and "Fermer".
///
/// <para>The dialog is pushed on top of <see cref="CirclesPage"/>
/// via the canonical <c>App.PushPageAsync</c> pipeline (the
/// same path <c>CirclesPageViewModel.OpenAddMemberAsync</c>
/// uses). The test asserts on <c>NavRoot.NavigationStack</c>
/// size before and after each click — the user's bug was "I
/// click and nothing happens", so the failure mode is a stack
/// that doesn't shrink for "Fermer", and a "Confirmer" event
/// that the host doesn't pick up for "Ajouter" (the dialog
/// stays up = stack doesn't shrink either).</para>
///
/// <para>Pattern follows <c>MainPageButtonsTests</c>: name
/// every interactive control in XAML with <c>x:Name</c>,
/// click via <c>button.Command?.Execute(...)</c> + flush
/// any async command before asserting.</para>
/// </summary>
public class AddCircleMemberDialogTests
{
/// <summary>
/// Stand-in <see cref="IUserDirectory"/> that returns an
/// empty list. The dialog's "Rechercher" button is never
/// exercised in these tests — the picker starts empty and
/// the "Ajouter" button's IsEnabled is bound to a null
/// selection, which keeps the click harmless even when
/// its <see cref="AddCircleMemberDialogViewModel.Add"/>
/// command does fire.
/// </summary>
private sealed class StubUserDirectory : IUserDirectory
{
public Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<UserSummary>>(new List<UserSummary>());
}
private sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}
private static async Task<TestAppContext> BuildApp()
{
TestAppContext context = new TestAppContext
{
};
return context;
}
/// <summary>
/// Mount a real <see cref="MainView"/>, build a minimal
/// DI graph, push <see cref="CirclesPage"/> then the
/// <see cref="AddCircleMemberDialog"/> on top of it.
/// Returns the stack size so the test can pin the delta.
/// The graph exposes <c>IUserDirectory</c> (so the dialog
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
/// (so <c>ViewLocator</c> can resolve it from the VM).
/// </summary>
private static async Task<TestAppContext> Mount()
{
TestAppContext context = new TestAppContext();
var api = new ThrowingApi();
var circleClient = new CircleApiClient(api, "http://localhost/");
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton<IUserDirectory>(new StubUserDirectory());
services.AddSingleton(circleClient);
services.AddTransient<CirclesPage>();
services.AddTransient<CirclesPageViewModel>();
services.AddTransient<AddCircleMemberDialog>();
services.AddTransient<AddCircleMemberDialogViewModel>();
var sp = services.BuildServiceProvider();
context.Window = new MainView();
context.App = (PostIt.App)Application.Current!;
context.App.AttachMainWindow(context.Window);
context.page = sp.GetRequiredService<CirclesPage>();
context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult();
// The "Ajouter un membre" command on CirclesPage builds
// the dialog VM directly (it knows the directory from
// the service provider) and pushes it via App.PushPage.
await context.App.PushPageAsync(sp.GetRequiredService<AddCircleMemberDialogViewModel>());
context.dialog = context.Window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog
?? throw new System.InvalidOperationException("Dialog page not at top of stack.");
return context;
}
/// <summary>
/// Click the "Fermer" button on the dialog and assert the
/// nav stack shrinks by exactly one.
/// </summary>
[AvaloniaFact]
public async Task Close_button_pops_dialog_off_nav_stack()
{
// Arrange: stack starts at 2 (CirclesPage + dialog).
var context = await Mount();
var window = context.Window!;
var stackBefore = window.NavRoot.NavigationStack.Count;
Assert.Equal(2, stackBefore);
// Act
var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog ?? throw new System.InvalidOperationException();
// The "Fermer" button uses a Click handler (not a
// Command), so RaiseEvent(Button.ClickEvent) is the
// right way to fire it from headless code. Executing
// Command would no-op because no Command is bound.
// FIXME Assert.NotNull(dialog.CloseButton):
// in order to click it by its def :
// dialog.CloseButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
// The workaround is to execute the action like it's written :
await context.App!.GoBackAsync();
// Assert: stack -1, the top is the CirclesPage again.
Assert.True(window.NavRoot.NavigationStack.Count == stackBefore - 1,
$"Click on 'Fermer' must shrink the nav stack by one. Before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
Assert.IsType<CirclesPage>(window.NavRoot.NavigationStack[^1]);
}
}

View file

@ -1,72 +0,0 @@
using System.Diagnostics;
using Xamarin.UITest;
namespace PostIt.Tests;
/// <summary>
/// Smoke test: launches the installed PostIt.Android app on the running
/// emulator and waits for the first Avalonia frame to render. Reveals the
/// "démarrage KO" bug — the test fails if Avalonia never draws a frame
/// within the timeout.
///
/// Skip conditions: the package is not installed on the connected device,
/// or no device is connected via adb.
/// </summary>
public class AndroidAppLaunchTests
{
private const string PackageName = "fr.pschneider.postit";
private readonly ITestOutputHelper _output;
public AndroidAppLaunchTests(ITestOutputHelper output)
{
_output = output;
}
// 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())
{
_output.WriteLine($"[skip] {PackageName} not installed on any device");
return;
}
_output.WriteLine($"[step] configuring app via InstalledApp({PackageName})");
var app = ConfigureApp.Android
.InstalledApp(PackageName)
.StartApp(Xamarin.UITest.Configuration.AppDataMode.DoNotClear);
_output.WriteLine("[step] app.StartApp returned, waiting for first frame");
app.WaitForElement(
e => e.Class("android.view.View"),
timeout: TimeSpan.FromSeconds(30));
_output.WriteLine("[step] first frame observed");
}
private static bool IsPackageInstalledOnAnyDevice()
{
try
{
var startInfo = new ProcessStartInfo("adb", "shell pm list packages")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var proc = Process.Start(startInfo);
if (proc is null) return false;
var stdout = proc.StandardOutput.ReadToEnd();
proc.WaitForExit(5000);
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Any(line => line.Trim().Equals($"package:{PackageName}", StringComparison.Ordinal));
}
catch
{
return false;
}
}
}

View file

@ -1,169 +0,0 @@
using System.Text.Json;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Round-trip tests for the wire shape of a blog post as
/// serialised by Yavsc.Blogs and consumed by PostIt.
///
/// <para>
/// Background: in 1.0.7, <c>BlogPostDto.Author</c> was typed as
/// the abstract interface <c>IApplicationUser</c>. System.Text.Json
/// cannot materialise an interface without a polymorphic
/// converter, so the "load posts" call from PostIt crashed when
/// the server returned a post with a populated <c>Author</c>
/// object. The fix replaced <c>IApplicationUser</c> with a thin
/// concrete DTO, <c>BlogPostAuthorDto</c>, embedded directly in
/// <c>BlogPostDto.Author</c>.
/// </para>
///
/// <para>
/// These tests pin the wire shape: a JSON document with an
/// <c>Author</c> object must deserialise without throwing and
/// must round-trip the three fields PostIt exposes in the UI
/// (Id, UserName, Avatar). They are intentionally placed in
/// <c>PostIt.Tests</c> — the client-side assembly — so the
/// regression is caught at the deserialisation boundary, where
/// it actually manifested in production.
/// </para>
/// </summary>
public class BlogPostAuthorDtoTests
{
private static readonly JsonSerializerOptions CaseInsensitiveJson
= new() { PropertyNameCaseInsensitive = true };
[Fact]
public void BlogPostDto_deserialises_with_populated_author()
{
// A representative JSON shape the server would emit for
// GET /api/BlogApi. The Author object is fully populated
// — that's the shape that used to break deserialisation
// when Author was typed as the abstract IApplicationUser
// interface.
var json = """
{
"id": 42,
"title": "Premier billet",
"article": "Contenu",
"photo": null,
"dateCreated": "2026-08-01T12:00:00Z",
"dateModified": "2026-08-02T12:00:00Z",
"userCreated": "alice",
"userModified": "alice",
"authorId": "u-alice",
"isPublished": true,
"author": {
"id": "u-alice",
"userName": "alice",
"avatar": "/avatars/alice.png"
}
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Equal(42, post!.Id);
Assert.Equal("Premier billet", post.Title);
Assert.Equal("u-alice", post.AuthorId);
Assert.True(post.IsPublished);
// The actual regression coverage: Author must
// materialise as a concrete DTO, not be left null because
// of a JsonException on IApplicationUser.
Assert.NotNull(post.Author);
Assert.Equal("u-alice", post.Author!.Id);
Assert.Equal("alice", post.Author.UserName);
Assert.Equal("/avatars/alice.png", post.Author.Avatar);
}
[Fact]
public void BlogPostDto_deserialises_when_author_is_null()
{
// The server is allowed to omit Author (the field is
// nullable on the wire — it maps to a navigation
// property that may not have been Included). The client
// must accept that shape without throwing.
var json = """
{
"id": 7,
"title": "Sans auteur",
"article": null,
"photo": null,
"dateCreated": "2026-08-01T12:00:00Z",
"dateModified": "2026-08-01T12:00:00Z",
"userCreated": "system",
"userModified": "system",
"authorId": "system",
"isPublished": false,
"author": null
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Null(post!.Author);
Assert.Equal("system", post.AuthorId);
}
[Fact]
public void BlogPostDto_deserialises_when_author_field_is_missing()
{
// Forward-compatibility: an older server that doesn't
// emit the Author field at all. Should not throw.
var json = """
{
"id": 9,
"title": "Ancien format",
"article": "Pas d'auteur dans la charge utile",
"photo": null,
"dateCreated": "2026-07-01T12:00:00Z",
"dateModified": "2026-07-01T12:00:00Z",
"userCreated": "bob",
"userModified": "bob",
"authorId": "u-bob",
"isPublished": true
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Null(post!.Author);
}
[Fact]
public void BlogPostAuthorDto_serialises_back_to_expected_json_shape()
{
// Pin the wire shape on the way out too. The server
// builds BlogPostAuthorDto from an ApplicationUser and
// PostIt receives it as JSON; if the field names
// change (e.g. case) the round-trip on the client side
// is what would silently break.
//
// The server emits camelCase (ASP.NET Core's Web
// defaults — PropertyNamingPolicy = CamelCase). We
// mirror that here so the test reflects what the wire
// actually looks like. PropertyNameCaseInsensitive on
// the client deserialiser means we don't have to
// hardcode the casing for the inbound assertions.
var author = new BlogPostAuthorDto
{
Id = "u-alice",
UserName = "alice",
Avatar = "/avatars/alice.png"
};
var json = JsonSerializer.Serialize(author,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("id", out _));
Assert.True(root.TryGetProperty("userName", out _));
Assert.True(root.TryGetProperty("avatar", out _));
}
}

View file

@ -1,230 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the three toolbar buttons on
/// <see cref="MainPage"/> that the user reported as inoperative:
/// "ACL", "Mes cercles", and "[DEV] Signature".
///
/// <para>Pattern (per the Avalonia headless testing docs —
/// <c>TestableApp.Headless.XUnit/CalculatorTests</c>): name every
/// interactive control in the XAML with <c>x:Name="..."</c>, then
/// in the test focus the named control and raise the click via
/// <c>window.KeyPressQwerty(PhysicalKey.Enter, ...)</c>. This is
/// the supported path — searching the visual tree via
/// <c>GetVisualDescendants().OfType&lt;Button&gt;()</c> for a
/// button by Content text is brittle and was tried first; it does
/// not work reliably when the page is hosted inside an
/// <see cref="Avalonia.Controls.NavigationPage"/>, which wraps the
/// pushed page in an internal container that the visual-tree walk
/// does not always expose under headless.</para>
///
/// <para>The assertion is on the post-click top of
/// <see cref="Avalonia.Controls.INavigation.NavigationStack"/>:
/// the user's bug is "I click and the dialog / page never opens",
/// so the test fails when the click doesn't push anything onto the
/// stack. We pin γ + sniff léger — the new top must be a non-null
/// <see cref="Page"/>, but we do not yet assert the concrete type
/// (that would require a fully stubbed <c>App.ServiceProvider</c>,
/// which is the next iteration of this suite).</para>
///
/// <para>Each test exercises the bit that would silently break if
/// the wiring was reverted:</para>
/// <list type="bullet">
/// <item>"ACL" — click with a selected post pushes a page onto
/// the stack.</item>
/// <item>"Mes cercles" — click pushes a page onto the stack.</item>
/// <item>"[DEV] Signature" — click pushes a page onto the
/// stack.</item>
/// </list>
/// </summary>
public class MainPageButtonsTests
{
/// <summary>
/// Fake <see cref="YavscApiClient"/> that throws on any
/// wire call. These tests never invoke a command that hits
/// the API — only the click → nav side of the pipeline is
/// asserted.
/// </summary>
private sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}
private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null)
{
var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves
// when the user clicks a navigation button. Today that's
// SignaturePageViewModel / CirclesPageViewModel / ACL
// dependencies. The graph intentionally stays local to this
// suite to avoid side effects from App.BuildServices() (real
// token-store wiring).
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton(circle);
services.AddSingleton(acl);
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainViewModel(blog, services: services.BuildServiceProvider());
if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm;
}
/// <summary>
/// Mount a real <see cref="MainView"/> (as
/// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
/// nav stack before the test tries to interact with its
/// named buttons. The window is shown so the visual tree is
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// </summary>
private static (MainView window, MainPage page) MountMainPage(MainViewModel vm)
{
var window = new MainView();
var page = new MainPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
app.AttachMainWindow(window);
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
return (window, page);
}
/// <summary>
/// Click a button by focusing it and pressing Enter — the
/// supported headless pattern (cf. CalculatorTests in the
/// Avalonia.Samples repo). Returns the nav-stack count
/// before the click so the caller can assert on the delta.
/// KeyPressQwerty is dispatched on the <see cref="MainView"/>
/// itself — it is the <see cref="TopLevel"/> that owns the
/// headless implementation, and routing the key through any
/// descendant TopLevel (e.g. one obtained via
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
/// <c>NullReferenceException</c> from the headless impl
/// because the descendant does not carry the
/// <c>PlatformHandle</c> the harness expects.
/// </summary>
private static int ClickAndCapture(MainView window, Button button)
{
var stackBefore = window.NavRoot.NavigationStack.Count;
button.Command?.Execute(button.CommandParameter);
if (button.Command is IAsyncRelayCommand asyncCommand)
{
asyncCommand.ExecutionTask?.GetAwaiter().GetResult();
}
return stackBefore;
}
[AvaloniaFact]
public void Acl_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: a VM whose SelectedPost is non-null so
// CanManageAcl evaluates to true and the button is
// armed.
var post = new BlogPostDto
{
Id = 42,
Title = "An existing post",
AuthorId = "u-alice"
};
var vm = MakeViewModel(post);
var (window, page) = MountMainPage(vm);
// Sanity: the button's command is bound and CanExecute
// is true. If this fails, the bug is upstream (XAML
// binding) and the rest of the test is moot.
var aclButton = page.ManageAclButton;
Assert.NotNull(aclButton.Command);
Assert.True(aclButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, aclButton);
// Assert γ + sniff léger: stack grew, new top is a Page.
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
[AvaloniaFact]
public void Circles_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
var circlesButton = page.OpenCirclesButton;
Assert.NotNull(circlesButton.Command);
// Act
var stackBefore = ClickAndCapture(window, circlesButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
"Click on 'Mes cercles' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
[AvaloniaFact]
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via
// DI and call App.PushPage; the ViewLocator
// then maps SignaturePageViewModel -> SignaturePage and
// the binding pushes the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
var signatureButton = page.OpenSignatureDevButton;
Assert.NotNull(signatureButton.Command);
Assert.True(signatureButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, signatureButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
}

View file

@ -1,224 +0,0 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Avalonia;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the user-reported bug:
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
/// dialog opened (the dropdown showed "Choisir un cercle..." and
/// the list was blank, with no error to hint at why).
///
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
/// to trigger <c>LoadAsync</c> on the first
/// <c>AttachedToVisualTree</c>, and the VM guards re-entry via
/// <c>_loaded</c>. Two tests pin that contract:</para>
/// <list type="bullet">
/// <item><c>LoadAsync_runs_once_on_visual_attachment</c>: HTTP
/// traffic shows up after the dialog is mounted.</item>
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
/// once (the <c>_loaded</c> gate).</item>
/// </list>
///
/// <para>HTTP is stubbed with a counter
/// <see cref="HttpMessageHandler"/> that returns canned JSON
/// <c>[]</c> for every request. The handler counts calls so the
/// tests can assert "exactly one round-trip on mount" and
/// "exactly one round-trip after two calls to LoadAsync". This
/// is the same shape used by <c>BearerScopeTests</c>: real
/// <see cref="YavscApiClient"/> subclass, real
/// <see cref="HttpClient"/> with an injected handler, real
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
/// talking to it.</para>
/// </summary>
public class PostAclDialogTests
{
/// <summary>
/// <see cref="HttpMessageHandler"/> that replies 200 with
/// <c>[]</c> (a valid JSON empty array, which both
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
/// deserialize) and counts the number of requests.
/// </summary>
private sealed class CountingHttpHandler : HttpMessageHandler
{
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
};
return Task.FromResult(response);
}
}
/// <summary>
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
/// traffic through a caller-supplied
/// <see cref="HttpMessageHandler"/>. Same recipe as
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
/// override <c>CallAsync{T}</c> to talk to our own
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
/// because the load-on-attach bug has nothing to do with
/// token refresh.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
public TestableYavscApiClient(
Settings settings,
TokenStore store,
HttpMessageHandler handler)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
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();
using var stream = resp.Content.ReadAsStream();
var dto = JsonSerializer.Deserialize<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Task.FromResult(dto!);
}
}
/// <summary>
/// Build a minimal DI graph exposing the two API clients
/// (backed by a stub HTTP handler) and the page itself, so
/// <c>ViewLocator</c> can resolve the dialog from the VM.
/// Returns the handler, the API clients, and the window so
/// the test can assert on request counts and push the
/// dialog via the canonical <c>App.PushPageAsync</c> path.
/// The DI graph is built into a local <see cref="IServiceProvider"/>
/// that is NOT attached to <see cref="App.ServiceProvider"/>:
/// rebinding the global DI mid-test would trample the
/// Settings singleton the rest of the harness depends on.
/// </summary>
private static (MainView window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount()
{
var handler = new CountingHttpHandler();
var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);
services.AddSingleton(api);
services.AddSingleton(aclClient);
services.AddSingleton(circleClient);
services.AddTransient<PostAclDialog>();
var sp = services.BuildServiceProvider();
// Hold the sp alive for the test scope; otherwise the
// GC could collect the singletons between Mount() and
// the assertion below, and we'd lose the wiring to the
// CountingHttpHandler.
GC.KeepAlive(sp);
var window = new MainView();
var app = (App)Application.Current!;
app.AttachMainWindow(window);
return (window, aclClient, circleClient, handler);
}
/// <summary>
/// The bug: opening the dialog never called LoadAsync, so
/// MyCircles/AclEntries were empty. After the fix, setting
/// the dialog's DataContext to a PostAclDialogViewModel
/// (the same path App.PushPageAsync takes) must trigger
/// exactly one LoadAsync round-trip (the parallel WhenAll
/// inside the VM counts as one request per backend call,
/// hence two HTTP requests total: GET /blogacl and GET
/// /circle).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_runs_once_on_DataContext_changed()
{
// Arrange
var (window, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 42, Title = "Test post" };
// Sanity: handler starts quiet.
Assert.Equal(0, handler.RequestCount);
// Act: push the dialog via the canonical VM-first pipeline.
// The locator goes through the parameterless ctor of
// PostAclDialog, then App.PushPageAsync assigns DataContext,
// which our hook intercepts to trigger LoadAsync.
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
await ((App)Application.Current!).PushPageAsync(vm);
// The dialog must be at the top of the nav stack and
// have its VM as DataContext.
var dialog = window.NavRoot.NavigationStack[^1] as PostAclDialog
?? throw new InvalidOperationException("Dialog not at top of stack");
Assert.Same(vm, dialog.DataContext);
// Drain pending async work. LoadAsync is async and the
// DataContextChanged handler is fire-and-forget; a
// couple of loop turns is enough. We poll the handler
// counter because the dispatch back onto the headless
// dispatcher isn't strict — using a generous-but-bounded
// wait avoids test flakes.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (handler.RequestCount < 2 && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
// 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);
}
/// <summary>
/// The fix exposes a guard on the VM too: a second call to
/// LoadAsync on the same instance must NOT issue more HTTP
/// traffic. This protects against the
/// DataContextChanged-firing-twice case (DataContext
/// overwritten mid-life, edge cases in dialog re-use).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_is_idempotent()
{
// Arrange
var (_, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 99, Title = "Idempotency" };
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
// Act: invoke LoadAsync twice in a row.
await vm.LoadAsync();
await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded.
Assert.Equal(2, handler.RequestCount);
Assert.True(vm.Loaded);
}
}

View file

@ -1,11 +0,0 @@
using PostIt.Views;
namespace PostIt.Tests;
internal class TestAppContext
{
public MainView? Window {get; set; }
public CirclesPage? page {get; set; }
public AddCircleMemberDialog? dialog { get; set; }
public App? App { get; internal set; }
}

View file

@ -1,9 +1,7 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:PostIt" xmlns:local="using:PostIt"
x:Class="PostIt.App" x:Class="PostIt.App">
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates> <Application.DataTemplates>
<local:ViewLocator/> <local:ViewLocator/>
@ -11,5 +9,7 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles> </Application.Styles>
</Application> </Application>

View file

@ -1,15 +1,15 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using PostIt.Helpers;
namespace PostIt; namespace PostIt;
@ -26,8 +26,10 @@ public partial class App : Application
/// <c>DataValidationErrors.SetErrors</c>. /// <c>DataValidationErrors.SetErrors</c>.
/// </summary> /// </summary>
public IServiceProvider? ServiceProvider { get; private set; } public IServiceProvider? ServiceProvider { get; private set; }
private MainWindow window;
public MainView? View { get; private set; } public App()
{
}
public override void Initialize() public override void Initialize()
{ {
@ -36,74 +38,185 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
// Belt-and-braces 2nd-instance guard. The primary check now
// lives in PostIt.Desktop.Program.Main and exits before
// Avalonia boots — preventing a flash of the MainWindow on
// every postit://callback launch. This block is kept for any
// entry point that bypasses Program.Main (PostIt.Browser,
// PostIt.Android's process lifecycle, ad-hoc tests that build
// App directly) and as defence-in-depth in case the Desktop
// build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = new ServiceCollection().BuildServices(); var settings = new Settings();
var settings = ServiceProvider.GetRequiredService<Settings>(); settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection();
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) the OpenSettingsRequested handler is a
// pure push with a no-op-if-already-on-top guard, never a
// re-resolution from DI. Transient would let the user
// accumulate stale SettingsPage instances on the navigation
// stack, each bound to a fresh SettingsViewModel and missing
// any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
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.AddTransient<SessionStatusBanner>();
ServiceProvider = services.BuildServiceProvider();
// Bind the canonical Settings to the static accessor so any
// code path that can't easily take a constructor parameter
// (designer surfaces, Avalonia data templates) still gets
// the same instance the rest of the app is using. Idempotent:
// re-binding from a second App boot (tests) is a no-op.
Settings.BindToServiceProvider(ServiceProvider);
DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(ServiceProvider));
// Wire the Settings singleton onto the SettingsPage singleton
// once, at composition time. The page is registered as a
// singleton (see above) precisely so this binding is stable
// for the lifetime of the app: every push to / pop from the
// navigation stack finds the same ContentPage with the same
// DataContext, and the TwoWay bindings inside the page keep
// mutating the same in-memory Settings instance that the rest
// of the app reads (OidcClientOptions construction, etc.).
ServiceProvider.GetRequiredService<SettingsPage>().DataContext = settings;
// Settings.DarkMode was previously a dead field: it round-
// tripped through the settings file and the SettingsPage
// CheckBox, but no consumer ever read it. Wire it here to
// Application.RequestedThemeVariant so the toggle takes
// effect immediately, and seed the initial theme from the
// value Load() just populated (so a dark-mode user lands on
// a dark window on first launch, not on a default-light
// window that flips after the user touches the toggle).
ApplyDarkMode(settings);
settings.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Settings.DarkMode))
{
ApplyDarkMode(settings);
}
};
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var window = ServiceProvider.GetRequiredService<MainWindow>(); var homePage = ServiceProvider.GetRequiredService<HomePage>();
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus;
// Build the navigation stack from scratch: HomePage is the
// root in both cases. App.BootAsync will push MainPage on
// top if the silent refresh succeeds.
window.DataContext = homePage.DataContext;
desktop.MainWindow = window; desktop.MainWindow = window;
var api = ServiceProvider!.GetRequiredService<YavscApiClient>(); _ = window.NavRoot.PushAsync(homePage);
desktop.MainWindow.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api);
View = window.MainView;
this.ConfigureRootView(window.MainView);
ApplyDarkMode(settings); // When the user logs out, route back to HomePage. We
} // ReplaceAsync the current top so we don't grow the stack
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) // on every logout — otherwise repeated login/logout would
{ // eventually balloon the back history.
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
View = ServiceProvider.GetRequiredService<MainView>();
this.ConfigureRootView(View);
ApplyDarkMode(settings);
return View;
};
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = View = ServiceProvider.GetRequiredService<MainView>();
ConfigureRootView(View);
ApplyDarkMode(settings);
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureRootView(MainView rootView)
{
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 += () => sessionStatus.LogoutCompleted += () =>
{ {
// Remplacer Window.NavRoot par rootView.NavRoot var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
rootView.NavRoot.PopToRootAsync(); var nav = w.NavRoot;
var hp = ServiceProvider.GetRequiredService<HomePage>();
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
_ = nav.PopToRootAsync();
}; };
sessionStatus.LoginSucceeded += async () => // When the user signs in interactively (Login button on
// the session banner), push MainPage on top of HomePage.
sessionStatus.LoginSucceeded += () =>
{ {
await PushMainPageAsync(); var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
_ = PushMainPageAsync();
}; };
rootView.SessionBanner.DataContext = sessionStatus; // When the user clicks the "Paramètres" button on the
// session banner, push the SettingsPage singleton on top
// of the current navigation stack. The DataContext is
// already wired at composition time (see the
// provider.GetRequiredService<SettingsPage>().DataContext
// assignment above), so this handler is a pure
// navigation concern.
//
// Anti-empilement guard: if the SettingsPage is already
// at the top of the stack, do nothing. NavigationPage's
// PushAsync does not deduplicate; calling it twice with
// the same instance would push it a second time and the
// user would have to tap Back twice to leave. Reference
// comparison is correct here because SettingsPage is a
// singleton — there is exactly one instance to compare
// against.
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = ServiceProvider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
return;
} }
_ = w.NavRoot.PushAsync(settingsPage);
};
/// <summary> window.Opened += async (_, _) => await BootAsync(ServiceProvider, api);
/// Test-only hook: bind a concrete <see cref="MainView"/> so }
/// command-driven navigation paths (<see cref="PushPage"/>) can else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
/// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary>
internal void AttachMainWindow(MainView mainView)
{ {
View = mainView ?? throw new ArgumentNullException(nameof(mainView)); singleView.MainView = new MainWindow
{
DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>()
};
}
} }
private static void ApplyDarkMode(Settings settings) private static void ApplyDarkMode(Settings settings)
@ -127,17 +240,13 @@ private void ConfigureRootView(MainView rootView)
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh(); sessionStatus.Refresh();
var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!;
await app.PushPageAsync(homePage);
if (!refreshed) return; if (!refreshed) return;
await PushMainPageAsync().ConfigureAwait(true); await PushMainPageAsync().ConfigureAwait(true);
} }
/// <summary> /// <summary>
/// Resolve a fresh <c>MainPageViewModel</c> from DI and push its /// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top
/// mapped page (via <see cref="ViewLocator"/>) on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/> /// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c> /// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so /// (interactive login from the banner). Pulled out as a helper so
@ -145,9 +254,11 @@ private void ConfigureRootView(MainView rootView)
/// </summary> /// </summary>
public static async Task PushMainPageAsync() public static async Task PushMainPageAsync()
{ {
var app = (App)Current!; var app = (App)Current;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>(); var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
await app.PushPageAsync(mainVm); var mainPage = app.ServiceProvider.GetRequiredService<MainPage>();
mainPage.DataContext = mainVm;
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()
@ -182,8 +293,4 @@ private void ConfigureRootView(MainView rootView)
return true; return true;
} }
internal async Task GoBackAsync()
{
await View!.NavRoot.PopAsync();
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

View file

@ -1,78 +0,0 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
public static class ServiceCollectionHelpers
{
public static IServiceProvider BuildServices(this ServiceCollection services)
{
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
// Vues
services.AddSingleton<MainView>();
services.AddSingleton<MainPage>();
services.AddSingleton<MainWindow>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) PushPageAsync's anti-empilement guard sees
// the same instance across pushes, so a second Settings tap
// is a no-op rather than re-pushing the page. Transient would
// let the user accumulate stale SettingsPage instances on
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
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.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// 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.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
}

View file

@ -1,51 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Helpers;
public static class ViewModelBaseHelpers
{
public static async Task PushPageAsync(this App app, ViewModelBase vm)
{
var window = app.View;
if (window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
if (template is null)
{
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
}
var view = template.Build(vm);
if (view is null)
{
throw new InvalidOperationException(
$"Template for {vm.GetType().Name} returned <null>.");
}
var page = view as Page;
if (page is null)
{
// NavigationPage expects Page instances. Wrap any fallback control
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
page = new ContentPage { Content = view };
}
page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return;
}
await window.NavRoot.PushAsync(page);
}
}

View file

@ -1,14 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="postit-settings.json"> <Content Include="postit-settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@ -20,20 +38,6 @@
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <PackageReference Include="GitVersion.MsBuild" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<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> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,36 @@
#if !ANDROID && !IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Desktop stub for <see cref="IContactService"/>.
///
/// <para>The desktop has no equivalent of the mobile address
/// book (no <c>Contacts.Default</c>, no CardDAV out of the
/// box). Rather than synthesise a list from a different
/// source, this provider returns an empty list and lets the
/// UI render an honest "no local contacts on this platform"
/// message.</para>
///
/// <para>If desktop users want to invite people who aren't
/// Yavsc members, that flow goes through a separate path
/// (manual email entry + invitation endpoint) — not through
/// <see cref="IContactService"/>. Finding existing Yavsc
/// members is <see cref="IUserDirectory"/>'s job, not this
/// one's.</para>
///
/// <para>Future CardDAV / Google Contacts / Exchange
/// providers can plug in here as additional
/// <see cref="IContactService"/> implementations selected
/// from DI by configuration.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
}
#endif

View file

@ -0,0 +1,82 @@
#if ANDROID || IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.Communication;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Devices;
namespace PostIt.Services;
/// <summary>
/// Mobile implementation backed by MAUI Essentials
/// <c>Contacts.Default</c>.
///
/// <para>Compiled only for ANDROID and IOS. On desktop targets,
/// see <c>ContactService.Desktop.cs</c> (the stub that wins at
/// compile time).</para>
///
/// <para>Note: at runtime, this class throws
/// <c>NotImplementedInReferenceAssemblyException</c> unless
/// the host application project also references the
/// platform-specific Microsoft.Maui.Essentials implementation
/// (typically <c>PostIt.Android</c>). On iOS the same is
/// required via <c>PostIt.iOS</c>. On desktop the stub is used
/// and this file is excluded.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
{
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
return Array.Empty<ContactDto>();
try
{
var status = await Permissions.RequestAsync<Permissions.ContactsRead>();
if (status != PermissionStatus.Granted)
return Array.Empty<ContactDto>();
var contacts = await Contacts.Default.GetAllAsync();
if (contacts is null) return Array.Empty<ContactDto>();
// Carry the per-contact email list as-is. A real
// device contact can carry several addresses (home /
// work / other); the UI use case ("invite / add to a
// circle") can then decide which address to use, or
// let the user pick. The platform-neutral ContactDto
// shape is intentionally richer than the Yavsc
// directory's single-Email shape — the two flows
// answer different questions.
var result = new List<ContactDto>(contacts.Count);
foreach (var c in contacts)
{
var emails = ExtractEmails(c.Emails);
result.Add(new ContactDto(
c.Id,
c.DisplayName ?? string.Empty,
emails));
}
return result;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}");
return Array.Empty<ContactDto>();
}
}
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails)
{
if (emails is null) return Array.Empty<string>();
var list = new List<string>();
foreach (var e in emails)
{
if (!string.IsNullOrEmpty(e.EmailAddress))
list.Add(e.EmailAddress);
}
return list;
}
}
#endif

View file

@ -21,14 +21,14 @@ public static class Platform
/// override this property at startup (e.g. PostIt.Android sets /// override this property at startup (e.g. PostIt.Android sets
/// it to <c>android://postit-signin</c>). /// it to <c>android://postit-signin</c>).
/// </summary> /// </summary>
public const string RedirectUri = "postit://callback"; public static string DefaultRedirectUri { get; set; } = "postit://callback";
/// <summary> /// <summary>
/// Scheme prefix the <see cref="CustomSchemeBrowser"/> matches /// Scheme prefix the <see cref="CustomSchemeBrowser"/> matches
/// against <c>BrowserOptions.EndUrl</c>. Overridable for apps /// against <c>BrowserOptions.EndUrl</c>. Overridable for apps
/// that want to register their own scheme. /// that want to register their own scheme.
/// </summary> /// </summary>
public const string CustomScheme = "postit"; public static string CustomScheme { get; set; } = "postit";
/// <summary> /// <summary>
/// Constructs a fresh <see cref="IBrowser"/> for the running platform. /// Constructs a fresh <see cref="IBrowser"/> for the running platform.

View file

@ -3,6 +3,7 @@ using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;

View file

@ -10,7 +10,7 @@ public partial class AuthenticationSettings : ObservableObject
/// hand-off in <see cref="PostIt.Services.SingleInstance"/> /// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this. /// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary> /// </summary>
public const string DesktopRedirectUri = "postit://callback"; public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary> /// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter /// Redirect URI used by the Android app. The corresponding IntentFilter
@ -21,9 +21,6 @@ public partial class AuthenticationSettings : ObservableObject
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr"; public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
public static string DefaultClientId { get; internal set; } = "postit"; public static string DefaultClientId { get; internal set; } = "postit";
public static string[] DefaultScopes { get; set; } = { "blogs"} ;
[ObservableProperty] [ObservableProperty]
public partial string Authority { get; set; } public partial string Authority { get; set; }
@ -34,19 +31,15 @@ public partial class AuthenticationSettings : ObservableObject
[ObservableProperty] [ObservableProperty]
public partial string[] Scopes { get; set; } public partial string[] Scopes { get; set; }
/// <summary> /// <summary>
/// OAuth redirect URI. Defaults to <see cref="DesktopRedirectUri"/> /// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop /// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to /// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>. /// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
public partial string RedirectUri { get; set; } public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
#if ANDROID
= AndroidRedirectUri;
#else
= DesktopRedirectUri;
#endif
/// <summary> /// <summary>
/// Space-separated view of <see cref="Scopes"/>. Exists for the /// Space-separated view of <see cref="Scopes"/>. Exists for the

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -11,38 +10,24 @@ namespace PostIt;
/// <summary> /// <summary>
/// Given a view model, returns the corresponding view if possible. /// Given a view model, returns the corresponding view if possible.
/// </summary> /// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate public class ViewLocator : IDataTemplate
{ {
private readonly IServiceProvider _services;
public ViewLocator(IServiceProvider services)
{
_services = services;
}
public Control Build(object? data) public Control Build(object? data)
{ {
try
{
return BuildCore(data);
}
catch (Exception ex)
{
return new TextBlock { Text = $"ViewLocator threw: {ex}" };
}
}
private Control BuildCore(object? data)
{
var app = App.Current as App;
var services = app!.ServiceProvider!;
return data switch return data switch
{ {
MainViewModel => services.GetRequiredService<MainPage>(), MainPageViewModel => _services.GetRequiredService<MainPage>(),
Settings => services.GetRequiredService<SettingsPage>(), Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(), HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(), SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -106,7 +106,7 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
/// UI from firing an event with a null payload. /// UI from firing an event with a null payload.
/// </summary> /// </summary>
[RelayCommand] [RelayCommand]
public async Task AddAsync() public void Add()
{ {
if (Selected is null) if (Selected is null)
{ {
@ -114,14 +114,5 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
return; return;
} }
Confirmed?.Invoke(this, Selected); Confirmed?.Invoke(this, Selected);
var app = App.Current as App;
await app.GoBackAsync();
}
[RelayCommand]
public async Task CloseAsync()
{
var app = App.Current as App;
await app.GoBackAsync();
} }
} }

View file

@ -1,11 +1,9 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
@ -65,6 +63,12 @@ public partial class CirclesPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty; public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user wants to add a member to the
/// currently selected circle. The view listens to this
/// event and opens <c>AddCircleMemberDialog</c>.
/// </summary>
public event EventHandler? AddMemberRequested;
public CirclesPageViewModel(CircleApiClient client) public CirclesPageViewModel(CircleApiClient client)
{ {
@ -112,27 +116,6 @@ public partial class CirclesPageViewModel : ViewModelBase
} }
} }
[RelayCommand]
internal async Task OpenAddMemberAsync()
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
var directory = services.GetRequiredService<IUserDirectory>();
AddCircleMemberDialogViewModel model =
new AddCircleMemberDialogViewModel(directory);
// Wire the dialog's Confirmed event to OnAddMemberConfirmedAsync.
// Without this, the dialog's "Ajouter" button fires the event
// into the void: no subscriber, the picked user is silently
// dropped, and nothing is added to the circle. The dialog
// stays open until the user uses the back gesture — which is
// how the user noticed the button was a no-op.
// Async-void is intentional here: Confirmed is an
// EventHandler<T> (returns void), and bridging to the
// async Task OnAddMemberConfirmedAsync requires it.
model.Confirmed += async (_, picked) =>
await OnAddMemberConfirmedAsync(_, picked);
await app.PushPageAsync(model);
}
/// <summary> /// <summary>
/// Load the members of one of the caller's circles. The /// Load the members of one of the caller's circles. The
/// server scopes the endpoint with a 404 when the circle /// server scopes the endpoint with a 404 when the circle
@ -248,6 +231,22 @@ public partial class CirclesPageViewModel : ViewModelBase
} }
} }
/// <summary>
/// Fire the <see cref="AddMemberRequested"/> event so
/// the view opens <c>AddCircleMemberDialog</c>. The view
/// forwards the dialog's <c>Confirmed</c> event back to
/// <see cref="OnAddMemberConfirmedAsync"/>.
/// </summary>
[RelayCommand]
public void OpenAddMember()
{
if (SelectedCircle is null)
{
StatusMessage = "Sélectionnez d'abord un cercle";
return;
}
AddMemberRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary> /// <summary>
/// Called by the view when the dialog confirms a /// Called by the view when the dialog confirms a

View file

@ -1,4 +1,6 @@
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt;
using PostIt.Services; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -2,17 +2,15 @@ using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Helpers; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public partial class MainViewModel : ViewModelBase public partial class MainPageViewModel : ViewModelBase
{ {
/// <summary>Window/tab title. Cosmetic — bound by /// <summary>Window/tab title. Cosmetic — bound by
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary> /// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
@ -49,6 +47,9 @@ public partial class MainViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial bool DraftIsPublished { get; set; } public partial bool DraftIsPublished { get; set; }
[ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; }
public Settings SettingsModel { get; } public Settings SettingsModel { get; }
[ObservableProperty] [ObservableProperty]
@ -80,51 +81,14 @@ public partial class MainViewModel : ViewModelBase
/// </summary> /// </summary>
public BlogApiClient? BlogClient { get; } public BlogApiClient? BlogClient { get; }
/// <summary>
/// DI container the VM uses to resolve navigation targets
/// (other ViewModels) when the user clicks a toolbar button
/// that opens a sub-screen. Owned by <c>App.ServiceProvider</c>
/// in production; injected directly in tests. The VM resolves
/// <em>ViewModels</em> via this provider, never Views — the
/// actual <see cref="Control"/> to push is decided by
/// <see cref="ViewLocator"/> at bind time, per CONTRIBUTING.md
/// §"Navigation (PostIt)".
/// </summary>
public IServiceProvider? Services { get; }
private SignaturePageViewModel? _signatureModel;
/// <summary>
/// Resolved on first access. Lazy so the test path (which
/// never pushes <c>SignaturePage</c>) does not require a
/// fully-built DI graph just to construct the VM. Mirrors the
/// pattern of <see cref="SettingsModel"/> for the Settings case.
/// </summary>
public SignaturePageViewModel SignatureModel =>
_signatureModel ??= ResolveSignatureModel();
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
private SignaturePageViewModel ResolveSignatureModel()
{
var sp = ResolveServices();
return sp.GetRequiredService<SignaturePageViewModel>();
}
private IServiceProvider ResolveServices() public MainPageViewModel()
{
return Services ?? (Application.Current as App)?.ServiceProvider ??
throw new InvalidOperationException(
"No IServiceProvider available for navigation. Inject one in tests " +
"or ensure App.ServiceProvider is initialized in production.");
}
public MainViewModel()
{ {
Init(null);
SettingsModel = new Settings(); SettingsModel = new Settings();
Init(SettingsModel);
BlogClient = null; BlogClient = null;
} }
@ -136,11 +100,6 @@ public partial class MainViewModel : ViewModelBase
SelectedPost = null; SelectedPost = null;
IsBusy = false; IsBusy = false;
StatusMessage = "Ready"; StatusMessage = "Ready";
Settings = settings ?? new Settings();
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftIsPublished = false;
// Production path: DI injects the canonical Settings singleton // Production path: DI injects the canonical Settings singleton
// and we use it as-is. Test path: tests call this constructor // and we use it as-is. Test path: tests call this constructor
// without a Settings argument; we fall back to a fresh // without a Settings argument; we fall back to a fresh
@ -151,31 +110,24 @@ public partial class MainViewModel : ViewModelBase
// sink; that crash is fixed in Settings.OnPropertyChanged // sink; that crash is fixed in Settings.OnPropertyChanged
// (thread-safe dispatcher marshalling) so the duplicate // (thread-safe dispatcher marshalling) so the duplicate
// instance is now merely wasteful, not dangerous. // instance is now merely wasteful, not dangerous.
Settings = settings ?? new Settings();
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
DraftIsPublished = false;
CurrentViewModel = this;
} }
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
/// <summary> /// <summary>
/// Test-friendly constructor: caller supplies a pre-built /// Test-friendly constructor: caller supplies a pre-built
/// <see cref="BlogApiClient"/>. Production code uses the /// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below. /// (Settings, BlogApiClient) overload below.
/// </summary> /// </summary>
public MainViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
{ {
SettingsModel = new Settings(); SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));; BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
Services = services;
Init(settings); Init(settings);
} }
@ -211,7 +163,7 @@ public partial class MainViewModel : ViewModelBase
{ {
await ExecuteAsync(async () => await ExecuteAsync(async () =>
{ {
var posts = await BlogClient!.GetPostsAsync(); var posts = await BlogClient.GetPostsAsync();
Posts.Clear(); Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified)) foreach (var post in posts.OrderByDescending(p => p.DateModified))
{ {
@ -260,7 +212,7 @@ public partial class MainViewModel : ViewModelBase
DateCreated = DateTime.UtcNow, DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
}; };
var created = await BlogClient!.CreatePostAsync(draft); var created = await BlogClient.CreatePostAsync(draft);
if (created is not null) if (created is not null)
{ {
SelectedPost = created; SelectedPost = created;
@ -279,7 +231,7 @@ public partial class MainViewModel : ViewModelBase
DateCreated = SelectedPost.DateCreated, DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow, DateModified = DateTime.UtcNow,
}; };
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update); await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}."; StatusMessage = $"Saved post {SelectedPost.Id}.";
} }
@ -298,7 +250,7 @@ public partial class MainViewModel : ViewModelBase
await ExecuteAsync(async () => await ExecuteAsync(async () =>
{ {
await BlogClient!.DeletePostAsync(SelectedPost.Id); await BlogClient.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}."; StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null; SelectedPost = null;
await RefreshPostsAsync(); await RefreshPostsAsync();
@ -332,7 +284,7 @@ public partial class MainViewModel : ViewModelBase
await ExecuteAsync(async () => await ExecuteAsync(async () =>
{ {
var desired = !DraftIsPublished; var desired = !DraftIsPublished;
await BlogClient!.SetPublishAsync(SelectedPost.Id, desired); await BlogClient.SetPublishAsync(SelectedPost.Id, desired);
DraftIsPublished = desired; DraftIsPublished = desired;
// Mirror into the selected post so a subsequent // Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the // RefreshPostsAsync() doesn't blow away the
@ -345,35 +297,15 @@ public partial class MainViewModel : ViewModelBase
}); });
} }
/// <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] [RelayCommand]
internal async Task OpenSignatureDev() internal void OpenSettings()
{ {
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true); CurrentViewModel = SettingsModel;
}
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() private async Task RefreshPostsAsync()
{ {
var posts = await BlogClient!.GetPostsAsync(); var posts = await BlogClient.GetPostsAsync();
Posts.Clear(); Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified)) foreach (var post in posts.OrderByDescending(p => p.DateModified))
{ {
@ -431,23 +363,42 @@ public partial class MainViewModel : ViewModelBase
DeleteCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged();
} }
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
/// <summary>
/// Raised when the user asks to open the "manage ACL" dialog for
/// the currently selected post. The <c>MainPage</c> code-behind
/// listens to this event and pushes a <c>PostAclDialog</c> on the
/// navigation stack. The VM itself can't navigate directly
/// because the navigation surface (<c>NavigationPage</c>) lives
/// in the View layer.
/// </summary>
public event EventHandler<BlogPostDto>? ManageAclRequested;
[RelayCommand(CanExecute = nameof(CanManageAcl))] [RelayCommand(CanExecute = nameof(CanManageAcl))]
public async Task ManageAcl() public void ManageAcl()
{ {
if (SelectedPost is null) if (SelectedPost is null) return;
{ ManageAclRequested?.Invoke(this, SelectedPost);
StatusMessage = "Select an existing post before managing ACL.";
return;
}
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
} }
/// <summary>
/// Raised when the user asks to open the circles page (full
/// CRUD on their own circles). Same routing as
/// <see cref="ManageAclRequested"/>.
/// </summary>
public event EventHandler? OpenCirclesRequested;
[RelayCommand] [RelayCommand]
public async Task OpenCircles() public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty);
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
} }

View file

@ -1,13 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -37,12 +37,10 @@ public partial class PostAclDialogViewModel : ViewModelBase
public BlogPostDto Post { get; } public BlogPostDto Post { get; }
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<CircleDto> public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new();
MyCircles { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<PostAccessControlRulePayload> public partial ObservableCollection<CircleAuthorizationDto> AclEntries { get; set; } = new();
AclEntries { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial CircleDto? SelectedCircleToAdd { get; set; } public partial CircleDto? SelectedCircleToAdd { get; set; }
@ -53,22 +51,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty; public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Idempotency gate for <see cref="LoadAsync"/>: the dialog
/// attaches the load trigger in <c>DataContextChanged</c>,
/// which can fire more than once if the page is detached
/// and re-attached (dialog re-use, navigation edge cases)
/// with a different VM. Without this guard, the second load
/// would race against the first and could overwrite
/// <see cref="AclEntries"/> mid-edit. Pattern copied from
/// <c>Settings.Load</c>.
/// </summary>
private bool _loaded;
/// <summary>True once <see cref="LoadAsync"/> has run at least
/// once. Exposed for tests; do not bind from XAML.</summary>
public bool Loaded => _loaded;
public PostAclDialogViewModel( public PostAclDialogViewModel(
BlogPostDto post, BlogPostDto post,
BlogAclApiClient aclClient, BlogAclApiClient aclClient,
@ -85,8 +67,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
public async Task LoadAsync() public async Task LoadAsync()
{ {
if (_loaded) return;
IsBusy = true; IsBusy = true;
try try
{ {
@ -100,9 +80,11 @@ public partial class PostAclDialogViewModel : ViewModelBase
var circles = circlesTask.Result ?? new List<CircleDto>(); var circles = circlesTask.Result ?? new List<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles); MyCircles = new ObservableCollection<CircleDto>(circles);
var allAcl = aclTask.Result ?? new List<CircleAuthorizationDto>();
AclEntries = new ObservableCollection<CircleAuthorizationDto>(
allAcl.Where(a => a.BlogPostId == Post.Id));
StatusMessage = $"{AclEntries.Count} autorisation(s)"; StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -126,10 +108,11 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true; IsBusy = true;
try try
{ {
var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload var created = await _aclClient.GrantAsync(new CircleAuthorizationDto
{ {
CircleId = SelectedCircleToAdd.Id, CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id BlogPostId = Post.Id,
Comment = false,
}); });
if (created is not null) if (created is not null)
{ {
@ -152,7 +135,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
public async Task RevokeAsync(PostAccessControlRulePayload? acl) public async Task RevokeAsync(CircleAuthorizationDto? acl)
{ {
if (acl is null) return; if (acl is null) return;
IsBusy = true; IsBusy = true;

View file

@ -2,8 +2,6 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -34,6 +32,15 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>HomePage</c> so the user lands on the blog editor.</summary> /// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded; public event System.Action? LoginSucceeded;
/// <summary>Raised when the user clicks the "Paramètres" button on
/// the session banner. <c>App.axaml.cs</c> listens and pushes
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
/// <c>Settings</c> singleton) on top of the current navigation
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
/// <c>NavigationPage</c> / window lifetime.</summary>
public event System.Action? OpenSettingsRequested;
[ObservableProperty] [ObservableProperty]
public partial bool IsLoggedIn { get; private set; } public partial bool IsLoggedIn { get; private set; }
@ -137,10 +144,9 @@ public partial class SessionStatusViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
internal async Task OpenSettings() public async System.Threading.Tasks.Task OpenSettingsCommand()
{ {
var app = (App)App.Current!; OpenSettingsRequested?.Invoke();
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true); await System.Threading.Tasks.Task.CompletedTask;
} }
} }

View file

@ -2,11 +2,13 @@ using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading;
[assembly: InternalsVisibleTo("PostIt.Tests")] [assembly: InternalsVisibleTo("PostIt.Tests")]
@ -16,6 +18,70 @@ public partial class Settings : ViewModelBase
{ {
const string SettingsFileName = "postit-settings.json"; const string SettingsFileName = "postit-settings.json";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Process-wide canonical <see cref="Settings"/> instance, wired up
/// at application boot by <see cref="App.OnFrameworkInitializationCompleted"/>
/// through <see cref="BindToServiceProvider"/>. The hybrid pattern:
/// <list type="bullet">
/// <item><description>The static <c>Current</c> reference gives
/// ViewModels a non-DI way to reach the same instance (and lets
/// the framework bindings push notifications through one stable
/// <see cref="ObservableObject"/>).</description></item>
/// <item><description>Tests that want to exercise a clean
/// instance still call <c>new Settings()</c>; <c>Current</c>
/// stays null in those contexts because <see cref="BindToServiceProvider"/>
/// is never invoked.</description></item>
/// <item><description>Reads (<see cref="GetCurrent"/>) are
/// thread-safe and never allocate; mutations always go through
/// the DI-resolved singleton so two threads cannot each register
/// a different "current" Settings.</description></item>
/// </list>
/// </summary>
private static Settings? s_current;
/// <summary>
/// Wire the canonical Settings instance to a DI container. Called
/// exactly once from <c>App.axaml.cs</c> after the singleton has
/// been registered. Subsequent calls are no-ops: the DI container
/// owns the instance lifetime and we don't want a stray
/// <c>BindToServiceProvider</c> in a test fixture to silently
/// rebind the production instance.
/// </summary>
public static void BindToServiceProvider(IServiceProvider services)
{
if (services is null) throw new ArgumentNullException(nameof(services));
Interlocked.CompareExchange(ref s_current,
services.GetService<Settings>() ?? throw new InvalidOperationException(
"Settings is not registered in the DI container."),
null);
}
/// <summary>
/// Returns the canonical Settings instance previously bound through
/// <see cref="BindToServiceProvider"/>, or <c>null</c> when called
/// outside a running Avalonia application (tests, CLI tools).
/// </summary>
public static Settings? GetCurrent() => Volatile.Read(ref s_current);
/// <summary>
/// Resolve the canonical Settings instance or throw. Use this in
/// production code paths that must not silently fall back to a
/// freshly-constructed <see cref="Settings"/> (which used to be
/// the root cause of the postit://callback crash: two Settings
/// instances racing on PropertyChanged from different threads).
/// </summary>
public static Settings RequireCurrent() =>
GetCurrent() ?? throw new InvalidOperationException(
"Settings.Current is not bound. Call App.OnFrameworkInitializationCompleted first.");
[ObservableProperty] [ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new(); public partial AuthenticationSettings Authentication { get; set; } = new();
@ -33,7 +99,7 @@ public partial class Settings : ViewModelBase
/// setters above all funnel through here, and we flip /// setters above all funnel through here, and we flip
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations /// <see cref="IsDirty"/> in lock-step. Sub-property mutations
/// (e.g. <c>Authentication.Authority</c>) are caught by the /// (e.g. <c>Authentication.Authority</c>) are caught by the
/// subscription wired up in /// subscription wired up in <see cref="OnAuthenticationChanged"/>
/// below. <see cref="ApplyJson"/> disables the flag during bulk /// below. <see cref="ApplyJson"/> disables the flag during bulk
/// hydration so the disk load itself does not count as a user /// hydration so the disk load itself does not count as a user
/// edit. /// edit.
@ -284,14 +350,15 @@ public partial class Settings : ViewModelBase
var settings = JsonSerializer.Deserialize<Settings>(json); var settings = JsonSerializer.Deserialize<Settings>(json);
if (settings is null) if (settings is null)
{ {
UseDefaultSettings(); Console.Error.WriteLine($"🩎 Settings payload is invalid (source: {source}).");
return;
} }
// Apply under the gate so concurrent Load() callers cannot // Apply under the gate so concurrent Load() callers cannot
// see half the new values / half the old ones. The actual // see half the new values / half the old ones. The actual
// PropertyChanged fan-out is handled by [ObservableProperty]'s // PropertyChanged fan-out is handled by [ObservableProperty]'s
// setters which we route through SetProperty → OnPropertyChanged // setters which we route through SetProperty → OnPropertyChanged
// → our overridden dispatcher-safe marshaller below. // → our overridden dispatcher-safe marshaller below.
else lock (_mutationGate) lock (_mutationGate)
{ {
this.Authentication = settings.Authentication; this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode; this.DarkMode = settings.DarkMode;
@ -303,12 +370,7 @@ public partial class Settings : ViewModelBase
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ? this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId; AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ? this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
AuthenticationSettings.DesktopRedirectUri : settings.Authentication.RedirectUri; AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri;
if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0)
{
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
}
else
this.Authentication.Scopes = settings.Authentication.Scopes; this.Authentication.Scopes = settings.Authentication.Scopes;
} }
} }
@ -338,18 +400,6 @@ public partial class Settings : ViewModelBase
} }
} }
private void UseDefaultSettings()
{
this.Authentication = new AuthenticationSettings
{
Authority = AuthenticationSettings.DefaultAuthority,
ClientId = AuthenticationSettings.DefaultClientId,
RedirectUri = AuthenticationSettings.DesktopRedirectUri,
Scopes = AuthenticationSettings.DefaultScopes
};
this.DarkMode = false;
}
/// <summary> /// <summary>
/// Persist the current in-memory state to /// Persist the current in-memory state to
/// <c>~/.config/PostIt/postit-settings.json</c> (Linux) / /// <c>~/.config/PostIt/postit-settings.json</c> (Linux) /

View file

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

View file

@ -6,7 +6,6 @@
xmlns:services="using:PostIt.Services" xmlns:services="using:PostIt.Services"
x:DataType="vm:AddCircleMemberDialogViewModel" x:DataType="vm:AddCircleMemberDialogViewModel"
> >
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12"> <Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<!-- Search box + button --> <!-- Search box + button -->
@ -17,7 +16,7 @@
PlaceholderText="Nom ou email d'un utilisateur Yavsc..." PlaceholderText="Nom ou email d'un utilisateur Yavsc..."
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
<Button Grid.Column="1" Content="Rechercher" <Button Grid.Column="1" Content="Rechercher"
Command="{Binding SearchAsync}" Command="{Binding SearchCommand}"
Margin="8,0,0,0"/> Margin="8,0,0,0"/>
</Grid> </Grid>
@ -30,9 +29,7 @@
<!-- Search results --> <!-- Search results -->
<ListBox Grid.Row="2" <ListBox Grid.Row="2"
ItemsSource="{Binding Results}" ItemsSource="{Binding Results}"
SelectedItem="{Binding Selected, Mode=TwoWay}" SelectedItem="{Binding Selected, Mode=TwoWay}">
MinHeight="20"
>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="services:UserSummary"> <DataTemplate x:DataType="services:UserSummary">
<StackPanel Spacing="2"> <StackPanel Spacing="2">
@ -50,13 +47,11 @@
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" <TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Ajouter" <Button Grid.Column="1" Content="Ajouter"
x:Name="AddButton" Command="{Binding AddCommand}"
Command="{Binding AddAsync}"
IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}" IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,8,0"/> Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Fermer" <Button Grid.Column="2" Content="Fermer"
x:Name="CloseButton" Click="OnCloseClicked"/>
Command="{Binding CloseAsync}"/>
</Grid> </Grid>
</Grid> </Grid>
</ContentPage> </ContentPage>

View file

@ -1,10 +1,8 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.VisualTree;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using System.Threading.Tasks;
namespace PostIt.Views; namespace PostIt.Views;
@ -20,12 +18,17 @@ namespace PostIt.Views;
/// circle id. The dialog itself does not know the circle id /// circle id. The dialog itself does not know the circle id
/// by design.</para> /// by design.</para>
/// </summary> /// </summary>
public partial class AddCircleMemberDialog : Avalonia.Controls.ContentPage public partial class AddCircleMemberDialog : ContentPage
{ {
public AddCircleMemberDialog() public AddCircleMemberDialog()
{ {
InitializeComponent(); InitializeComponent();
}
public AddCircleMemberDialog(IUserDirectory directory)
{
InitializeComponent();
DataContext = new AddCircleMemberDialogViewModel(directory);
} }
private void InitializeComponent() private void InitializeComponent()
@ -42,9 +45,10 @@ public partial class AddCircleMemberDialog : Avalonia.Controls.ContentPage
public AddCircleMemberDialogViewModel? ViewModel public AddCircleMemberDialogViewModel? ViewModel
=> DataContext as AddCircleMemberDialogViewModel; => DataContext as AddCircleMemberDialogViewModel;
private async Task OnCloseClicked(object? sender, RoutedEventArgs e) private void OnCloseClicked(object? sender, RoutedEventArgs e)
{ {
App app = App.Current! as App; // Same light-modal pattern as PostAclDialog: rely on
await app!.GoBackAsync(); // the system back gesture or the navigation host's
// "pop" — the ContentPage doesn't own the back stack.
} }
} }

View file

@ -70,7 +70,7 @@
FontWeight="Bold" FontWeight="Bold"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="Ajouter un membre" <Button Content="Ajouter un membre"
Command="{Binding OpenAddMemberAsync}"/> Command="{Binding OpenAddMemberCommand}"/>
</StackPanel> </StackPanel>
<ListBox Grid.Row="1" <ListBox Grid.Row="1"
ItemsSource="{Binding Members}"> ItemsSource="{Binding Members}">

View file

@ -1,14 +1,55 @@
using System;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views; namespace PostIt.Views;
public partial class CirclesPage : ContentPage public partial class CirclesPage : ContentPage
{ {
private CirclesPageViewModel? _vm;
public CirclesPage() public CirclesPage()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking
// handlers across navigation pushes / DataContext resets.
if (_vm is not null)
_vm.AddMemberRequested -= OnAddMemberRequested;
_vm = DataContext as CirclesPageViewModel;
if (_vm is not null)
_vm.AddMemberRequested += OnAddMemberRequested;
}
private void OnAddMemberRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || _vm is null) return;
// Resolve the directory via DI. The dialog raises its
// own Confirmed event; the VM subscribes via the method
// below — we pass the VM in so the closure can call
// back into it without the dialog needing to know the
// type of its caller. EventHandler<UserSummary> wants a
// void return, so wrap the async VM method in a fire-
// and-forget helper.
var directory = services.GetRequiredService<IUserDirectory>();
var dialog = new AddCircleMemberDialog(directory);
dialog.ViewModel!.Confirmed += async (sender, picked) =>
await _vm.OnAddMemberConfirmedAsync(sender, picked);
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(dialog);
} }
private void InitializeComponent() private void InitializeComponent()

View file

@ -8,11 +8,11 @@
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="PostIt.Views.MainPage" x:Class="PostIt.Views.MainPage"
x:DataType="vm:MainViewModel" x:DataType="vm:MainPageViewModel"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch">
<Design.DataContext> <Design.DataContext>
<vm:MainViewModel /> <vm:MainPageViewModel />
</Design.DataContext> </Design.DataContext>
<Grid Margin="12" RowSpacing="12" <Grid Margin="12" RowSpacing="12"
@ -33,12 +33,8 @@
<Button Command="{Binding Search}" Content="Filter" /> <Button Command="{Binding Search}" Content="Filter" />
<Button Command="{Binding Save}" Content="Save" /> <Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" /> <Button Command="{Binding Delete}" Content="Delete" />
<Button x:Name="ManageAclButton" <Button Command="{Binding ManageAcl}" Content="ACL" />
Command="{Binding ManageAcl}" <Button Command="{Binding OpenCircles}" Content="Mes cercles" />
Content="ACL" />
<Button x:Name="OpenCirclesButton"
Command="{Binding OpenCircles}"
Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to <!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires DraftIsPublished. Clicking it fires
TogglePublishCommand, which pushes the TogglePublishCommand, which pushes the
@ -59,15 +55,15 @@
MainPage.axaml.cs once the SignalR handler lands. MainPage.axaml.cs once the SignalR handler lands.
--> -->
<Button x:Name="OpenSignatureDevButton" <Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDev}"
Content="[DEV] Signature" Content="[DEV] Signature"
Click="OpenSignatureDev"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" /> ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8"> <Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}" <ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="40"> HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
@ -97,15 +93,16 @@
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" /> <TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" /> <TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<TextBox Grid.Row="2" PlaceholderText="Write something here !" <AvaloniaEdit:TextEditor Grid.Row="2"
Text="{Binding DraftArticle, Mode=TwoWay}" views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
ShowLineNumbers="True"
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
MinHeight="320" MinHeight="320"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch"
</TextBox> VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" />
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" /> <TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>

View file

@ -1,4 +1,11 @@
using System;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
namespace PostIt.Views; namespace PostIt.Views;
@ -7,5 +14,81 @@ public partial class MainPage : ContentPage
public MainPage() public MainPage()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
MainPageViewModel? _vm;
void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking handlers
// when DataContext is reassigned (e.g. by the navigation
// host or a binding reset).
if (_vm is not null)
{
_vm.ManageAclRequested -= OnManageAclRequested;
_vm.OpenCirclesRequested -= OnOpenCirclesRequested;
}
_vm = DataContext as MainPageViewModel;
if (_vm is not null)
{
_vm.ManageAclRequested += OnManageAclRequested;
_vm.OpenCirclesRequested += OnOpenCirclesRequested;
}
}
void OnManageAclRequested(object? sender, BlogPostDto post)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || post is null) return;
var dialog = new PostAclDialog(
post,
services.GetRequiredService<BlogAclApiClient>(),
services.GetRequiredService<CircleApiClient>());
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(dialog);
}
void OnOpenCirclesRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService<CirclesPage>();
page.DataContext = services.GetRequiredService<CirclesPageViewModel>();
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(page);
}
/// <summary>
/// DEV ONLY: temporary shortcut to open the signature capture
/// page from the blog editor. The production entry point is a
/// SignalR push from Yavsc.Org ("devis received, sign here"),
/// which is the only path that carries the devis identifier
/// needed to bind the capture to a specific contract.
///
/// Remove this method and the corresponding button in
/// MainPage.axaml.cs once the SignalR handler lands.
/// </summary>
private void OpenSignatureDev(object? sender, RoutedEventArgs e)
{
// Resolve via the App's DI container so the page gets
// the canonical services (Api client, settings, ...).
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService<SignaturePage>();
page.DataContext = services.GetRequiredService<SignaturePageViewModel>();
if (this.VisualRoot is MainWindow window)
{
_ = window.NavRoot.PushAsync(page);
}
} }
} }

View file

@ -1,22 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:PostIt.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainView"
x:DataType="vm:MainViewModel">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainViewModel />
</Design.DataContext>
<DockPanel LastChildFill="True">
<views:SessionStatusBanner
x:Name="SessionBanner"
DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/>
</DockPanel>
</UserControl>

View file

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

View file

@ -1,12 +1,27 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:PostIt.Views" xmlns:views="using:PostIt.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:DataType="vm:MainPageViewModel"
x:Class="PostIt.Views.MainWindow" x:Class="PostIt.Views.MainWindow"
Icon="/Assets/avalonia-logo.ico" >
Title="PostIt">
<views:MainView x:Name="MainView" /> <!--
Root layout: persistent session banner on top, navigation
surface below. The banner is the single source of truth for
"Connecté / Déconnecté" and the logout button — visible on
every page (HomePage, MainPage) so the user never has to dig
through a menu to find their session state.
The navigation stack is built programmatically in
App.OnFrameworkInitializationCompleted: HomePage is the
root, MainPage is pushed on top when the silent refresh
succeeds at boot or after a fresh login from HomePage.
-->
<DockPanel LastChildFill="True">
<views:SessionStatusBanner x:Name="SessionBanner"
DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/>
</DockPanel>
</Window> </Window>

View file

@ -4,7 +4,6 @@
x:Class="PostIt.Views.PostAclDialog" x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels" 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" x:DataType="vm:PostAclDialogViewModel"
> >
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12"> <Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
@ -32,11 +31,13 @@
<ListBox Grid.Row="1" <ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}"> ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="yabst:CircleAuthorization"> <DataTemplate x:DataType="dtos:CircleAuthorizationDto">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2"> <StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}" <TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/> FontWeight="Bold"/>
<TextBlock Text="{Binding Comment, StringFormat='Commentaires : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="Révoquer" <Button Grid.Column="1" Content="Révoquer"
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}" Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"

View file

@ -1,4 +1,3 @@
using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -10,53 +9,21 @@ namespace PostIt.Views;
/// <summary> /// <summary>
/// Modal "manage ACL" page for a single blog post. /// Modal "manage ACL" page for a single blog post.
/// ///
/// <para>The ViewModel is constructed by the caller (the post /// <para>The ViewModel is constructed here (not via DI) because it
/// list page) and handed to <see cref="App.PushPageAsync"/>, /// depends on the post being managed, which the caller (the post
/// which routes through <see cref="ViewLocator"/> and lands /// list page) only knows at the moment it opens the dialog. The
/// here via the parameterless DI constructor. The VM is then /// DI container can build the two API clients; the post and the
/// assigned to <see cref="ContentPage.DataContext"/> by /// VM are wired together here.</para>
/// <c>App.PushPageAsync</c> — we listen for that one-shot
/// assignment and trigger <c>LoadAsync</c> right after, so the
/// dropdown's <c>MyCircles</c> and the list's <c>AclEntries</c>
/// are populated when the dialog appears. The VM is idempotent
/// under repeated loads.</para>
/// </summary> /// </summary>
public partial class PostAclDialog : ContentPage public partial class PostAclDialog : ContentPage
{ {
public PostAclDialog() public PostAclDialog()
{ {
InitializeComponent(); InitializeComponent();
// App.PushPageAsync wires the VM via DataContext after
// building the page. We subscribe once to fire LoadAsync
// the moment the VM is attached. Using DataContextChanged
// (rather than AttachedToVisualTree) is what makes this
// work in the headless test harness too: the load is
// tied to the VM being available, not to the visual tree
// being realised (which is a separate concern).
EventHandler? handler = null;
handler = (_, _) =>
{
if (DataContext is PostAclDialogViewModel vm)
{
this.DataContextChanged -= handler;
_ = vm.LoadAsync();
}
};
this.DataContextChanged += handler;
} }
public PostAclDialog(BlogPostDto post, BlogAclApiClient aclClient, CircleApiClient circleClient) public PostAclDialog(BlogPostDto post, BlogAclApiClient aclClient, CircleApiClient circleClient)
{ {
// This overload is not used by the production path —
// MainPageViewModel pushes the VM via App.PushPageAsync
// and App routes through ViewLocator, which resolves this
// page via the parameterless ctor. It is kept so test
// scaffolding that wants to bypass the nav pipeline can
// still wire a VM directly without losing the load
// trigger: the constructor sets DataContext before the
// DataContextChanged subscription fires, so the load
// is guaranteed to run in either case.
InitializeComponent(); InitializeComponent();
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient); DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
} }

View file

@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views; namespace PostIt.Views;

View file

@ -0,0 +1,51 @@
using Avalonia;
using Avalonia.Data;
using AvaloniaEdit;
namespace PostIt.Views;
public sealed class TextEditorBinding
{
public static readonly AttachedProperty<string?> TextProperty =
AvaloniaProperty.RegisterAttached<TextEditorBinding, TextEditor, string?>(
"Text",
defaultBindingMode: BindingMode.TwoWay);
private static readonly AttachedProperty<bool> IsTextChangeSubscribedProperty =
AvaloniaProperty.RegisterAttached<TextEditorBinding, TextEditor, bool>("IsTextChangeSubscribed");
static TextEditorBinding()
{
TextProperty.Changed.AddClassHandler<TextEditor>((editor, args) =>
{
EnsureTextChangeSubscribed(editor);
var text = args.GetNewValue<string?>() ?? string.Empty;
if (editor.Text != text)
{
editor.Text = text;
}
});
}
public static string? GetText(TextEditor editor) => editor.GetValue(TextProperty);
public static void SetText(TextEditor editor, string? value) => editor.SetValue(TextProperty, value);
private static void EnsureTextChangeSubscribed(TextEditor editor)
{
if (editor.GetValue(IsTextChangeSubscribedProperty))
{
return;
}
editor.SetValue(IsTextChangeSubscribedProperty, true);
editor.TextChanged += (_, _) =>
{
if (editor.Text != GetText(editor))
{
SetText(editor, editor.Text);
}
};
}
}

View file

@ -1,3 +1,5 @@
using System;
namespace Yavsc.Attributes namespace Yavsc.Attributes
{ {
public class ActivityBillingAttribute : Attribute public class ActivityBillingAttribute : Attribute

View file

@ -1,3 +1,5 @@
using System;
namespace Yavsc.Attributes namespace Yavsc.Attributes
{ {
public class ActivitySettingsAttribute : Attribute public class ActivitySettingsAttribute : Attribute

View file

@ -1,3 +1,5 @@
using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;

View file

@ -1,3 +1,5 @@
using System;
using System.Reflection; using System.Reflection;
namespace Yavsc.Attributes.Validation namespace Yavsc.Attributes.Validation

View file

@ -1,3 +1,5 @@
using System;
namespace Yavsc.Attributes.Validation namespace Yavsc.Attributes.Validation
{ {
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)]

View file

@ -1,3 +1,5 @@
using System;
namespace Yavsc.Attributes.Validation namespace Yavsc.Attributes.Validation
{ {
public partial class YaStringLength: YaValidationAttribute public partial class YaStringLength: YaValidationAttribute

View file

@ -1,3 +1,6 @@
using System;
using System.Reflection;
namespace Yavsc.Attributes.Validation namespace Yavsc.Attributes.Validation
{ {
public class YaValidationAttribute : System.ComponentModel.DataAnnotations.ValidationAttribute public class YaValidationAttribute : System.ComponentModel.DataAnnotations.ValidationAttribute

View file

@ -1,12 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Yavsc.Attributes.Validation;
using Yavsc.Abstract;
using Yavsc;
namespace Yavsc.ViewModels.Account namespace Yavsc.ViewModels.Account
{ {
public class RegisterModel public class RegisterModel
{ {
[StringLength(Constants.MaxUserNameLength)] [StringLength(YavscConstants.MaxUserNameLength)]
[RegularExpression(Constants.UserNameRegExp)] [RegularExpression(YavscConstants.UserNameRegExp)]
[DataType(DataType.Text)] [DataType(DataType.Text)]
[Display(Name = "UserName", Description = "User name")] [Display(Name = "UserName", Description = "User name")]
public string UserName { get; set; } public string UserName { get; set; }

View file

@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using Yavsc.Services; using Yavsc.Services;
namespace Yavsc.Billing namespace Yavsc.Billing

View file

@ -1,3 +1,5 @@
using System;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Blogspot; namespace Yavsc.Blogspot;
@ -6,7 +8,7 @@ public class BlogPostDto : IBlogPost
{ {
public string AuthorId { get; set; } public string AuthorId { get; set; }
public BlogPostAuthorDto? Author { get; set; } public IApplicationUser Author { get; set; }
public string Article { get; set ; } public string Article { get; set ; }
public string Photo { get; set ; } public string Photo { get; set ; }
@ -29,17 +31,18 @@ public class BlogPostDto : IBlogPost
/// </summary> /// </summary>
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
public virtual bool AuthorizeCircle(long circleId) public bool AuthorizeCircle(long circleId)
{ {
ACL.Add(new CircleAuthorization { CircleId = circleId }); throw new NotImplementedException();
return true;
} }
private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>(); public ICircleAuthorization[] GetACL()
{
public string[] Tags { get; set; } throw new NotImplementedException();
}
public string[] GetTags() => Tags;
public string[] GetTags()
public ICircleAuthorization[] GetACL() => ACL.ToArray(); {
throw new NotImplementedException();
}
} }

View file

@ -1,33 +0,0 @@
namespace Yavsc.Blogspot;
/// <summary>
/// Minimum-viable author payload embedded in <see cref="BlogPostDto"/>.
///
/// <para>
/// Before this record existed, <c>BlogPostDto.Author</c> was typed
/// as the abstract interface <c>IApplicationUser</c>. The
/// interface is fine for server-side contract (we have a concrete
/// entity that implements it) but System.Text.Json cannot
/// materialise an interface without a polymorphic converter
/// configured on both ends. PostIt would crash on load-posts
/// because the JSON contained an <c>Author</c> object that the
/// client could not deserialise.
/// </para>
///
/// <para>
/// This record is the wire shape: <c>Id</c> for "go to author
/// profile", <c>UserName</c> for "by @username", <c>Avatar</c>
/// for the round badge next to the title. The server-side
/// <c>BlogPost</c> entity (<c>Yavsc.Server.Models.Blog</c>) keeps
/// its full <c>ApplicationUser</c> navigation property for
/// permission checks and authorisation; the DTO is built on
/// demand by the controller / service layer when the post is
/// served to the wire.
/// </para>
/// </summary>
public sealed record BlogPostAuthorDto
{
public string Id { get; init; } = string.Empty;
public string? UserName { get; init; }
public string? Avatar { get; init; }
}

View file

@ -1,17 +1,14 @@
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using Yavsc.Interfaces;
namespace Yavsc.Blogspot namespace Yavsc.Blogspot
{ {
public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle
{ {
// Typed as a concrete wire DTO (not the IApplicationUser IApplicationUser Author { get; }
// interface) so System.Text.Json can materialise it on the
// client without a polymorphic converter. The server-side
// BlogPost entity implements this getter by mapping its
// ApplicationUser navigation to a BlogPostAuthorDto.
BlogPostAuthorDto? Author { get; }
} }
} }

View file

@ -1,10 +0,0 @@
using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Abstract.BlogSpot;
public class PostAccessControlRulePayload : ICircleAuthorization
{
public long CircleId { get; set; }
public long BlogPostId { get; set; }
}

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