feat: run local Dart tasks via Dagger (#417) #418
@@ -1,5 +1,6 @@
|
||||
# --- Flutter/Dart ---
|
||||
coverage/
|
||||
screenshots/
|
||||
.dart_tool/
|
||||
.dart-tool/
|
||||
.packages
|
||||
|
||||
@@ -42,3 +42,9 @@ repos:
|
||||
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: ci-image-exists
|
||||
name: verify container images in ci/main.go are reachable
|
||||
language: system
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
||||
pass_filenames: false
|
||||
files: ^ci/main\.go$
|
||||
|
||||
+12
-1
@@ -550,7 +550,7 @@ tasks:
|
||||
|
||||
run:
|
||||
desc: Run the app on Linux desktop
|
||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
||||
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
|
||||
cmds:
|
||||
- fvm flutter run -d linux --no-pub
|
||||
|
||||
@@ -682,6 +682,11 @@ tasks:
|
||||
fi
|
||||
echo "Hygiene check passed."
|
||||
|
||||
check-ci-images:
|
||||
desc: Verify that all container images referenced in ci/main.go are reachable
|
||||
cmds:
|
||||
- scripts/check_ci_images.sh
|
||||
|
||||
_integrations:
|
||||
internal: true
|
||||
run: once
|
||||
@@ -694,6 +699,12 @@ tasks:
|
||||
cmds:
|
||||
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
||||
|
||||
screenshots:
|
||||
desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes)
|
||||
deps: [_preflight, _codegen]
|
||||
cmds:
|
||||
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||
|
||||
check:
|
||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||
deps: [analyze, build-linux, test]
|
||||
|
||||
@@ -20,7 +20,7 @@ pluginManagement {
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -2,44 +2,4 @@ module dagger/ci
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
|
||||
github.com/Khan/genqlient v0.8.1
|
||||
github.com/dagger/otel-go v1.43.0
|
||||
github.com/vektah/gqlparser/v2 v2.5.33
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.90 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/sosodev/duration v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
require golang.org/x/sync v0.20.0
|
||||
|
||||
@@ -1,129 +1,2 @@
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
|
||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
|
||||
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
||||
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
||||
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
|
||||
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
|
||||
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
||||
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
||||
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+34
-16
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"dagger/ci/internal/dagger"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -148,16 +149,33 @@ if __name__ == "__main__":
|
||||
`
|
||||
|
||||
type Ci struct {
|
||||
Source *dagger.Directory
|
||||
Source *dagger.Directory
|
||||
FlutterVersion string
|
||||
}
|
||||
|
||||
func New(
|
||||
ctx context.Context,
|
||||
// +defaultPath=".."
|
||||
source *dagger.Directory,
|
||||
) *Ci {
|
||||
) (*Ci, error) {
|
||||
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
|
||||
}
|
||||
var fvmrc struct {
|
||||
Flutter string `json:"flutter"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
|
||||
}
|
||||
if fvmrc.Flutter == "" {
|
||||
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
|
||||
}
|
||||
return &Ci{
|
||||
FlutterVersion: fvmrc.Flutter,
|
||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{
|
||||
".fvmrc",
|
||||
"lib/",
|
||||
"test/",
|
||||
"assets/",
|
||||
@@ -173,7 +191,7 @@ func New(
|
||||
"website/",
|
||||
},
|
||||
}),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
||||
@@ -181,7 +199,7 @@ func New(
|
||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||
func (m *Ci) toolchain() *dagger.Container {
|
||||
return dag.Container().
|
||||
From("ghcr.io/cirruslabs/flutter:3.44.0").
|
||||
From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
|
||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||
@@ -471,7 +489,7 @@ func (m *Ci) CheckFast(ctx context.Context) (string, error) {
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
_, err := m.CheckMocks(ctx)
|
||||
_, err := m.CheckGenerated(ctx)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
@@ -484,11 +502,11 @@ func (m *Ci) CheckFast(ctx context.Context) (string, error) {
|
||||
return "All fast checks passed!", nil
|
||||
}
|
||||
|
||||
// CheckMocks verifies that generated mocks are up to date.
|
||||
// It snapshots the committed source (including any stale *.mocks.dart) before
|
||||
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
|
||||
// It snapshots the committed source (including any stale generated files) before
|
||||
// running build_runner, so git diff detects real staleness instead of always
|
||||
// comparing two freshly-generated outputs.
|
||||
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
||||
return m.pubGetLayer().
|
||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
@@ -501,16 +519,16 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Coverage runs unit tests with coverage gate.
|
||||
// Coverage runs unit and widget tests with coverage gate.
|
||||
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||
Stdout(ctx)
|
||||
@@ -577,7 +595,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
return analyze, err
|
||||
}
|
||||
|
||||
mocks, err := m.CheckMocks(ctx)
|
||||
mocks, err := m.CheckGenerated(ctx)
|
||||
if err != nil {
|
||||
return mocks, err
|
||||
}
|
||||
@@ -964,12 +982,12 @@ func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string
|
||||
//
|
||||
// dagger call --progress=plain -q -m ci --source=. graph
|
||||
func (m *Ci) Graph() string {
|
||||
return `# CI Pipeline Graph
|
||||
return fmt.Sprintf(`# CI Pipeline Graph
|
||||
|
||||
` + "```" + `mermaid
|
||||
`+"```"+`mermaid
|
||||
flowchart TD
|
||||
subgraph dagger ["Dagger · Check pipeline"]
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
||||
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
|
||||
pubGet["pubGetLayer\nflutter pub get"]
|
||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||
@@ -979,7 +997,7 @@ flowchart TD
|
||||
|
||||
pubGet --> hygiene["CheckHygiene"]
|
||||
pubGet --> layers["CheckLayers"]
|
||||
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
||||
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
|
||||
|
||||
codegen --> fmt["Format"]
|
||||
codegen --> analyze["Analyze"]
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
httplib2
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
|
||||
+16
-5
@@ -12,13 +12,23 @@ import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/router.dart';
|
||||
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
||||
import 'package:stack_trace/stack_trace.dart' as stack_trace;
|
||||
|
||||
void main({List<Override> overrides = const []}) async {
|
||||
void main({List<Override> overrides = const []}) {
|
||||
unawaited(
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Dart's async machinery propagates stack traces in chain format
|
||||
// (with '===== asynchronous gap =====' separators). Flutter's
|
||||
// StackFrame parser asserts on those lines, so strip them first.
|
||||
FlutterError.demangleStackTrace = (StackTrace s) {
|
||||
if (s is stack_trace.Chain) return s.toTrace().vmTrace;
|
||||
if (s is stack_trace.Trace) return s.vmTrace;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
||||
ErrorWidget.builder = (details) => CrashScreen(
|
||||
exception: details.exception,
|
||||
@@ -46,10 +56,11 @@ void main({List<Override> overrides = const []}) async {
|
||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||
);
|
||||
},
|
||||
(error, stack) {
|
||||
// Catch unhandled async errors.
|
||||
runApp(CrashScreen(exception: error, stackTrace: stack));
|
||||
},
|
||||
// This handler runs in the parent zone — runApp cannot be called here.
|
||||
// Framework errors are already handled by FlutterError.onError above.
|
||||
(error, stack) => FlutterError.reportError(
|
||||
FlutterErrorDetails(exception: error, stack: stack),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
+1
-1
@@ -1021,7 +1021,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.44.4"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
|
||||
@@ -58,6 +58,9 @@ dependencies:
|
||||
flutter_local_notifications: ^21.0.0
|
||||
workmanager: ^0.9.0
|
||||
|
||||
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
|
||||
stack_trace: ^1.12.1
|
||||
|
||||
# App version metadata for crash reports
|
||||
package_info_plus: ^10.1.0
|
||||
share_plus: ^13.1.0
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify that every container image referenced in ci/main.go is reachable.
|
||||
# Runs skopeo inspect (manifest-only, no layer pull) for each From("...") call.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(git rev-parse --show-toplevel)
|
||||
FILE="$ROOT/ci/main.go"
|
||||
|
||||
images=$(grep -oP 'From\("\K[^"]+' "$FILE" | sort -u)
|
||||
|
||||
if [ -z "$images" ]; then
|
||||
echo "check-ci-images: no From() image references found in $FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail=0
|
||||
while IFS= read -r image; do
|
||||
printf "check-ci-images: %-55s" "$image"
|
||||
if skopeo inspect --no-creds "docker://$image" > /dev/null 2>&1; then
|
||||
echo "OK"
|
||||
else
|
||||
echo "NOT FOUND"
|
||||
fail=1
|
||||
fi
|
||||
done <<< "$images"
|
||||
|
||||
if [ "$fail" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "ERROR: one or more container images in ci/main.go could not be resolved."
|
||||
echo "Fix the image tag before committing."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,43 @@
|
||||
// Loads Material fonts (Roboto + MaterialIcons) before any test runs so that
|
||||
// golden/screenshot tests render real text instead of placeholder boxes.
|
||||
//
|
||||
// Flutter widget tests don't load fonts by default. This file is discovered
|
||||
// automatically by `flutter test` for every test under test/.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
setUpAll(_loadMaterialFonts);
|
||||
await testMain();
|
||||
}
|
||||
|
||||
Future<void> _loadMaterialFonts() async {
|
||||
// Locate Flutter's cached material fonts relative to the flutter_tester executable.
|
||||
// Layout: <flutter-root>/bin/cache/artifacts/engine/linux-x64/flutter_tester
|
||||
// <flutter-root>/bin/cache/artifacts/material_fonts/
|
||||
final cacheDir =
|
||||
File(Platform.resolvedExecutable).parent.parent.parent.parent;
|
||||
final fontsDir = '${cacheDir.path}/artifacts/material_fonts';
|
||||
|
||||
Future<ByteData> load(String name) async {
|
||||
final bytes = await File('$fontsDir/$name').readAsBytes();
|
||||
return ByteData.view(bytes.buffer);
|
||||
}
|
||||
|
||||
await (FontLoader('Roboto')
|
||||
..addFont(load('Roboto-Regular.ttf'))
|
||||
..addFont(load('Roboto-Medium.ttf'))
|
||||
..addFont(load('Roboto-Bold.ttf'))
|
||||
..addFont(load('Roboto-Italic.ttf'))
|
||||
..addFont(load('Roboto-MediumItalic.ttf'))
|
||||
..addFont(load('Roboto-BoldItalic.ttf')))
|
||||
.load();
|
||||
|
||||
await (FontLoader('MaterialIcons')
|
||||
..addFont(load('MaterialIcons-Regular.otf')))
|
||||
.load();
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// Generates Play Store promotional screenshots for all three device classes.
|
||||
//
|
||||
// Run with:
|
||||
// fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||
//
|
||||
// Output: screenshots/{phone,tablet_7in,tablet_10in}/{light,dark}/<scene>.png
|
||||
// at the repository root (one directory above test/).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
||||
|
||||
import 'widget/helpers.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device configurations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
typedef _Device = ({String name, double width, double height, double dpr});
|
||||
|
||||
const _devices = <_Device>[
|
||||
(name: 'phone', width: 1080.0, height: 1920.0, dpr: 3.0),
|
||||
(name: 'tablet_7in', width: 1200.0, height: 1920.0, dpr: 2.0),
|
||||
(name: 'tablet_10in', width: 1600.0, height: 2560.0, dpr: 2.0),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data — fixed date so golden files are stable between runs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _kAccount = Account(
|
||||
id: 'acc-1',
|
||||
displayName: 'Alice',
|
||||
email: 'alice@sharedinbox.de',
|
||||
imapHost: 'imap.sharedinbox.de',
|
||||
smtpHost: 'smtp.sharedinbox.de',
|
||||
);
|
||||
|
||||
final _kDate = DateTime(2025, 5, 14, 10, 30);
|
||||
|
||||
Email _email({
|
||||
required String id,
|
||||
required String subject,
|
||||
required String fromName,
|
||||
required String fromEmail,
|
||||
bool isSeen = true,
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
String? preview,
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: int.parse(id.split(':').last),
|
||||
subject: subject,
|
||||
receivedAt: _kDate,
|
||||
sentAt: _kDate,
|
||||
from: [EmailAddress(name: fromName, email: fromEmail)],
|
||||
to: const [EmailAddress(name: 'Alice', email: 'alice@sharedinbox.de')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
preview: preview,
|
||||
);
|
||||
|
||||
final _sampleEmails = [
|
||||
_email(
|
||||
id: 'acc-1:1',
|
||||
subject: 'Re: Project kick-off next week',
|
||||
fromName: 'Maria Hoffmann',
|
||||
fromEmail: 'maria@corp.example',
|
||||
isSeen: false,
|
||||
preview: 'Sounds great! I will prepare the slides beforehand.',
|
||||
),
|
||||
_email(
|
||||
id: 'acc-1:2',
|
||||
subject: 'Your invoice #2024-0312 is ready',
|
||||
fromName: 'Billing',
|
||||
fromEmail: 'billing@service.example',
|
||||
isSeen: false,
|
||||
preview: 'Your invoice for May is attached as a PDF.',
|
||||
),
|
||||
_email(
|
||||
id: 'acc-1:3',
|
||||
subject: 'Team lunch — Friday 12:30',
|
||||
fromName: 'Thomas Müller',
|
||||
fromEmail: 'thomas@corp.example',
|
||||
isFlagged: true,
|
||||
preview: 'The Italian place on Main Street. RSVP by Thursday please.',
|
||||
),
|
||||
_email(
|
||||
id: 'acc-1:4',
|
||||
subject: 'Quarterly review agenda',
|
||||
fromName: 'HR Team',
|
||||
fromEmail: 'hr@corp.example',
|
||||
preview:
|
||||
"Please find the agenda for next week's quarterly review attached.",
|
||||
),
|
||||
_email(
|
||||
id: 'acc-1:5',
|
||||
subject: 'Weekend hiking trip — photos inside',
|
||||
fromName: 'Jonas Weber',
|
||||
fromEmail: 'jonas@personal.example',
|
||||
hasAttachment: true,
|
||||
preview: 'Had such a great time! Here are the photos from Saturday.',
|
||||
),
|
||||
_email(
|
||||
id: 'acc-1:6',
|
||||
subject: 'Reminder: dentist appointment tomorrow',
|
||||
fromName: 'City Dental',
|
||||
fromEmail: 'noreply@citydental.example',
|
||||
preview: 'Your appointment is confirmed for Thursday at 14:00.',
|
||||
),
|
||||
_email(
|
||||
id: 'acc-1:7',
|
||||
subject: 'Re: Feedback on the draft',
|
||||
fromName: 'Laura Schmidt',
|
||||
fromEmail: 'laura@corp.example',
|
||||
isSeen: false,
|
||||
preview: 'I left some comments on page 3. Overall it looks really solid!',
|
||||
),
|
||||
_email(
|
||||
id: 'acc-1:8',
|
||||
subject: 'Flight confirmation PNR XYZ123',
|
||||
fromName: 'Sunshine Airlines',
|
||||
fromEmail: 'noreply@airline.example',
|
||||
preview:
|
||||
'Your booking is confirmed. Check-in opens 24 hours before departure.',
|
||||
),
|
||||
];
|
||||
|
||||
final _sampleMailboxes = [
|
||||
const Mailbox(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
role: 'inbox',
|
||||
unreadCount: 3,
|
||||
totalCount: 8,
|
||||
),
|
||||
const Mailbox(
|
||||
id: 'acc-1:Sent',
|
||||
accountId: 'acc-1',
|
||||
path: 'Sent',
|
||||
name: 'Sent',
|
||||
role: 'sent',
|
||||
unreadCount: 0,
|
||||
totalCount: 42,
|
||||
),
|
||||
const Mailbox(
|
||||
id: 'acc-1:Drafts',
|
||||
accountId: 'acc-1',
|
||||
path: 'Drafts',
|
||||
name: 'Drafts',
|
||||
role: 'drafts',
|
||||
unreadCount: 0,
|
||||
totalCount: 1,
|
||||
),
|
||||
const Mailbox(
|
||||
id: 'acc-1:Trash',
|
||||
accountId: 'acc-1',
|
||||
path: 'Trash',
|
||||
name: 'Trash',
|
||||
role: 'trash',
|
||||
unreadCount: 0,
|
||||
totalCount: 7,
|
||||
),
|
||||
];
|
||||
|
||||
// Email shown in the detail scene.
|
||||
final _detailEmail = _email(
|
||||
id: 'acc-1:1',
|
||||
subject: 'Re: Project kick-off next week',
|
||||
fromName: 'Maria Hoffmann',
|
||||
fromEmail: 'maria@corp.example',
|
||||
);
|
||||
|
||||
const _detailBody = EmailBody(
|
||||
emailId: 'acc-1:1',
|
||||
attachments: [],
|
||||
textBody: 'Hi Alice,\n\n'
|
||||
'Sounds great! I will prepare the slides beforehand so we have '
|
||||
'something concrete to discuss.\n\n'
|
||||
'Looking forward to meeting everyone!\n\n'
|
||||
'Best,\nMaria',
|
||||
);
|
||||
|
||||
// Emails shown when the user searches for "invoice".
|
||||
final _searchResults = [
|
||||
_email(
|
||||
id: 'acc-1:2',
|
||||
subject: 'Your invoice #2024-0312 is ready',
|
||||
fromName: 'Billing',
|
||||
fromEmail: 'billing@service.example',
|
||||
isSeen: false,
|
||||
),
|
||||
_email(
|
||||
id: 'acc-1:9',
|
||||
subject: 'Invoice for March services',
|
||||
fromName: 'Cloud Services',
|
||||
fromEmail: 'noreply@cloud.example',
|
||||
),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider override sets for each scene
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<Override> _inboxOverrides() => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([_kAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(_sampleMailboxes),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: _sampleEmails),
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
];
|
||||
|
||||
List<Override> _detailOverrides() => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([_kAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(_sampleMailboxes),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
emails: _sampleEmails,
|
||||
emailDetail: _detailEmail,
|
||||
emailBody: _detailBody,
|
||||
),
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
];
|
||||
|
||||
List<Override> _composeOverrides() => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([_kAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(_sampleMailboxes),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: _sampleEmails),
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
];
|
||||
|
||||
List<Override> _mailboxOverrides() => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([_kAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(_sampleMailboxes),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
];
|
||||
|
||||
List<Override> _searchOverrides() => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([_kAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(_sampleMailboxes),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
emails: _sampleEmails,
|
||||
searchResults: _searchResults,
|
||||
),
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — 3 devices × 2 themes × 5 scenes = 30 golden files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
for (final device in _devices) {
|
||||
for (final themeMode in [ThemeMode.light, ThemeMode.dark]) {
|
||||
final themeName = themeMode == ThemeMode.light ? 'light' : 'dark';
|
||||
// Golden files are stored relative to this test file (test/).
|
||||
// The ../ prefix places them at repo root under screenshots/.
|
||||
final dir = '../screenshots/${device.name}/$themeName';
|
||||
final prefix = '${device.name}_$themeName';
|
||||
|
||||
group('${device.name}/$themeName', () {
|
||||
void setDevice(WidgetTester tester) {
|
||||
tester.view.physicalSize = Size(device.width, device.height);
|
||||
tester.view.devicePixelRatio = device.dpr;
|
||||
addTearDown(tester.view.reset);
|
||||
}
|
||||
|
||||
testWidgets('inbox_list', (tester) async {
|
||||
setDevice(tester);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: _inboxOverrides(),
|
||||
themeMode: themeMode,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await expectLater(
|
||||
find.byType(MaterialApp),
|
||||
matchesGoldenFile('$dir/${prefix}_inbox_list.png'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('email_detail', (tester) async {
|
||||
setDevice(tester);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
// The colon in "acc-1:1" must be percent-encoded in the URL.
|
||||
initialLocation:
|
||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A1',
|
||||
overrides: _detailOverrides(),
|
||||
themeMode: themeMode,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await expectLater(
|
||||
find.byType(MaterialApp),
|
||||
matchesGoldenFile('$dir/${prefix}_email_detail.png'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('compose', (tester) async {
|
||||
setDevice(tester);
|
||||
// Start at the inbox, then navigate to compose with pre-fill extras
|
||||
// so GoRouter can pass them to ComposeScreen via state.extra.
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: _composeOverrides(),
|
||||
themeMode: themeMode,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
GoRouter.of(tester.element(find.byType(EmailListScreen))).go(
|
||||
'/compose',
|
||||
extra: <String, dynamic>{
|
||||
'accountId': 'acc-1',
|
||||
'prefillTo': 'thomas@corp.example',
|
||||
'prefillSubject': 'Re: Team lunch — Friday 12:30',
|
||||
'prefillBody':
|
||||
'Hi Thomas,\n\nCount me in! See you on Friday.\n\nBest,\nAlice',
|
||||
},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await expectLater(
|
||||
find.byType(MaterialApp),
|
||||
matchesGoldenFile('$dir/${prefix}_compose.png'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('mailbox_list', (tester) async {
|
||||
setDevice(tester);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
initialLocation: '/accounts/acc-1/mailboxes',
|
||||
overrides: _mailboxOverrides(),
|
||||
themeMode: themeMode,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await expectLater(
|
||||
find.byType(MaterialApp),
|
||||
matchesGoldenFile('$dir/${prefix}_mailbox_list.png'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('search_results', (tester) async {
|
||||
setDevice(tester);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: _searchOverrides(),
|
||||
themeMode: themeMode,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(SearchBar), 'invoice');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
await expectLater(
|
||||
find.byType(MaterialApp),
|
||||
matchesGoldenFile('$dir/${prefix}_search_results.png'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
@Tags(['golden'])
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -28,7 +28,8 @@ import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' show AppDatabase, SyncHealthRow;
|
||||
import 'package:sharedinbox/data/db/database.dart'
|
||||
show AppDatabase, SyncHealthRow;
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
||||
@@ -420,6 +421,8 @@ Widget buildApp({
|
||||
required String initialLocation,
|
||||
required List<Override> overrides,
|
||||
UserPreferencesRepository? userPreferences,
|
||||
ThemeMode themeMode = ThemeMode.light,
|
||||
bool debugShowCheckedModeBanner = true,
|
||||
}) {
|
||||
final testRouter = GoRouter(
|
||||
initialLocation: initialLocation,
|
||||
@@ -543,10 +546,19 @@ Widget buildApp({
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
routerConfig: testRouter,
|
||||
themeMode: themeMode,
|
||||
debugShowCheckedModeBanner: debugShowCheckedModeBanner,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user