Compare commits
12 Commits
SEND010
...
bc6a56c129
| Author | SHA1 | Date | |
|---|---|---|---|
| bc6a56c129 | |||
|
|
ec54579921 | ||
| 1ed76f7243 | |||
|
|
6527d183ec | ||
| 41b0dec460 | |||
|
|
d26ba84094 | ||
|
|
4073c8819c | ||
|
|
47ada0691c | ||
| 97c67670e5 | |||
|
|
dfad7fb335 | ||
| 41abf723e6 | |||
|
|
2d6586430f |
@@ -49,6 +49,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.77.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -50,5 +50,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 // indirect
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222010151-8a13a32a690c // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
@@ -86,5 +86,5 @@ require (
|
|||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
|||||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 h1:Rge3uIIO891+nLqKTfMulCw+tWHtTl16Oudi0yUcAoE=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222010151-8a13a32a690c h1:1HaIKi7tUhYKk05NOy2tgqtDky4aVXjCeTaBU7ziJZE=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222010151-8a13a32a690c/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -362,8 +362,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -95,7 +96,31 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadata := shared.CloneMetadata(req.GetMetadata())
|
||||||
|
desc := req.GetDescribable()
|
||||||
|
name := strings.TrimSpace(desc.GetName())
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(metadata["name"])
|
||||||
|
}
|
||||||
|
var description *string
|
||||||
|
if desc != nil && desc.Description != nil {
|
||||||
|
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if description == nil {
|
||||||
|
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = walletRef
|
||||||
|
}
|
||||||
|
|
||||||
wallet := &model.ManagedWallet{
|
wallet := &model.ManagedWallet{
|
||||||
|
Describable: pkgmodel.Describable{
|
||||||
|
Name: name,
|
||||||
|
},
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
WalletRef: walletRef,
|
WalletRef: walletRef,
|
||||||
OrganizationRef: organizationRef,
|
OrganizationRef: organizationRef,
|
||||||
@@ -106,7 +131,10 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
|||||||
DepositAddress: strings.ToLower(keyInfo.Address),
|
DepositAddress: strings.ToLower(keyInfo.Address),
|
||||||
KeyReference: keyInfo.KeyID,
|
KeyReference: keyInfo.KeyID,
|
||||||
Status: model.ManagedWalletStatusActive,
|
Status: model.ManagedWalletStatusActive,
|
||||||
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
if description != nil {
|
||||||
|
wallet.Describable.Description = description
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package wallet
|
package wallet
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
|
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -16,6 +19,25 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
|||||||
TokenSymbol: wallet.TokenSymbol,
|
TokenSymbol: wallet.TokenSymbol,
|
||||||
ContractAddress: wallet.ContractAddress,
|
ContractAddress: wallet.ContractAddress,
|
||||||
}
|
}
|
||||||
|
name := strings.TrimSpace(wallet.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(wallet.Metadata["name"])
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = wallet.WalletRef
|
||||||
|
}
|
||||||
|
description := ""
|
||||||
|
switch {
|
||||||
|
case wallet.Description != nil:
|
||||||
|
description = strings.TrimSpace(*wallet.Description)
|
||||||
|
default:
|
||||||
|
description = strings.TrimSpace(wallet.Metadata["description"])
|
||||||
|
}
|
||||||
|
desc := &describablev1.Describable{Name: name}
|
||||||
|
if description != "" {
|
||||||
|
desc.Description = &description
|
||||||
|
}
|
||||||
|
|
||||||
return &chainv1.ManagedWallet{
|
return &chainv1.ManagedWallet{
|
||||||
WalletRef: wallet.WalletRef,
|
WalletRef: wallet.WalletRef,
|
||||||
OrganizationRef: wallet.OrganizationRef,
|
OrganizationRef: wallet.OrganizationRef,
|
||||||
@@ -26,6 +48,7 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
|
|||||||
Metadata: shared.CloneMetadata(wallet.Metadata),
|
Metadata: shared.CloneMetadata(wallet.Metadata),
|
||||||
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
|
||||||
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
|
||||||
|
Describable: desc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
)
|
)
|
||||||
@@ -19,7 +20,8 @@ const (
|
|||||||
|
|
||||||
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
|
||||||
type ManagedWallet struct {
|
type ManagedWallet struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
pkgmodel.Describable `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||||
@@ -77,6 +79,15 @@ func (m *ManagedWallet) Normalize() {
|
|||||||
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
m.WalletRef = strings.TrimSpace(m.WalletRef)
|
||||||
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
|
||||||
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
|
||||||
|
m.Name = strings.TrimSpace(m.Name)
|
||||||
|
if m.Description != nil {
|
||||||
|
desc := strings.TrimSpace(*m.Description)
|
||||||
|
if desc == "" {
|
||||||
|
m.Description = nil
|
||||||
|
} else {
|
||||||
|
m.Description = &desc
|
||||||
|
}
|
||||||
|
}
|
||||||
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
|
||||||
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
|
||||||
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
|
||||||
|
|||||||
@@ -50,5 +50,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -51,5 +51,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ require (
|
|||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.77.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -227,8 +227,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -62,5 +62,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -215,8 +215,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
|
||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
@@ -413,74 +411,3 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
|
|
||||||
switch code {
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
|
|
||||||
return model.PaymentFailureCodeBalance
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
|
|
||||||
return model.PaymentFailureCodeLedger
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
|
|
||||||
return model.PaymentFailureCodeFX
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
|
|
||||||
return model.PaymentFailureCodeChain
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
|
|
||||||
return model.PaymentFailureCodeFees
|
|
||||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
|
|
||||||
return model.PaymentFailureCodePolicy
|
|
||||||
default:
|
|
||||||
return model.PaymentFailureCodeUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
|
|
||||||
if src == nil || dst == nil {
|
|
||||||
return merrors.InvalidArgument("payment payload is required")
|
|
||||||
}
|
|
||||||
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
|
|
||||||
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
|
|
||||||
dst.Intent = intentFromProto(src.GetIntent())
|
|
||||||
dst.State = modelStateFromProto(src.GetState())
|
|
||||||
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
|
|
||||||
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
|
|
||||||
dst.Metadata = cloneMetadata(src.GetMetadata())
|
|
||||||
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
|
|
||||||
dst.Execution = executionFromProto(src.GetExecution())
|
|
||||||
if src.GetCardPayout() != nil {
|
|
||||||
dst.CardPayout = &model.CardPayout{
|
|
||||||
PayoutRef: strings.TrimSpace(src.GetCardPayout().GetPayoutRef()),
|
|
||||||
ProviderPaymentID: strings.TrimSpace(src.GetCardPayout().GetProviderPaymentId()),
|
|
||||||
Status: strings.TrimSpace(src.GetCardPayout().GetStatus()),
|
|
||||||
FailureReason: strings.TrimSpace(src.GetCardPayout().GetFailureReason()),
|
|
||||||
CardCountry: strings.TrimSpace(src.GetCardPayout().GetCardCountry()),
|
|
||||||
MaskedPan: strings.TrimSpace(src.GetCardPayout().GetMaskedPan()),
|
|
||||||
ProviderCode: strings.TrimSpace(src.GetCardPayout().GetProviderCode()),
|
|
||||||
GatewayReference: strings.TrimSpace(src.GetCardPayout().GetGatewayReference()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
|
|
||||||
if src == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &model.ExecutionRefs{
|
|
||||||
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
|
|
||||||
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
|
|
||||||
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
|
|
||||||
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
|
|
||||||
CardPayoutRef: strings.TrimSpace(src.GetCardPayoutRef()),
|
|
||||||
FeeTransferRef: strings.TrimSpace(src.GetFeeTransferRef()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
|
|
||||||
if req == nil {
|
|
||||||
return &paginationv1.CursorPageRequest{}
|
|
||||||
}
|
|
||||||
if req.GetPage() == nil {
|
|
||||||
return &paginationv1.CursorPageRequest{}
|
|
||||||
}
|
|
||||||
return req.GetPage()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -214,20 +214,6 @@ func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
|
|||||||
return decimal.NewFromString(m.GetAmount())
|
return decimal.NewFromString(m.GetAmount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
|
|
||||||
if reference == nil || candidate == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
value, err := decimal.NewFromString(candidate.GetAmount())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
||||||
return &moneyv1.Money{
|
return &moneyv1.Money{
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestMinQuoteExpiry(t *testing.T) {
|
|||||||
later := now.Add(10 * time.Minute)
|
later := now.Add(10 * time.Minute)
|
||||||
earliest := now.Add(5 * time.Minute)
|
earliest := now.Add(5 * time.Minute)
|
||||||
|
|
||||||
min, ok := minQuoteExpiry([]time.Time{later, time.Time{}, earliest})
|
min, ok := minQuoteExpiry([]time.Time{later, {}, earliest})
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected min expiry to be set")
|
t.Fatal("expected min expiry to be set")
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func TestMinQuoteExpiry(t *testing.T) {
|
|||||||
t.Fatalf("expected min expiry %v, got %v", earliest, min)
|
t.Fatalf("expected min expiry %v, got %v", earliest, min)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := minQuoteExpiry([]time.Time{time.Time{}}); ok {
|
if _, ok := minQuoteExpiry([]time.Time{{}}); ok {
|
||||||
t.Fatal("expected min expiry to be unset")
|
t.Fatal("expected min expiry to be unset")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error)
|
|||||||
{Field: "clientId", Sort: ri.Asc},
|
{Field: "clientId", Sort: ri.Asc},
|
||||||
{Field: "deviceId", Sort: ri.Asc},
|
{Field: "deviceId", Sort: ri.Asc},
|
||||||
},
|
},
|
||||||
Unique: true,
|
Unique: true,
|
||||||
|
Name: "unique_active_session",
|
||||||
|
PartialFilter: repository.Filter(IsRevokedField, false),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
|
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -10,23 +10,29 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
|
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
factory "github.com/tech/sendico/pkg/mlogger/factory"
|
factory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/testcontainers/testcontainers-go"
|
"github.com/testcontainers/testcontainers-go"
|
||||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
"github.com/testcontainers/testcontainers-go/wait"
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
|
func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
|
||||||
|
db, _, cleanup := setupTestDBWithMongo(t)
|
||||||
|
return db, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo.Database, func()) {
|
||||||
// mark as helper for better test failure reporting
|
// mark as helper for better test failure reporting
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -62,7 +68,7 @@ func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
|
|||||||
_ = mongoContainer.Terminate(termCtx)
|
_ = mongoContainer.Terminate(termCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, cleanup
|
return db, database, cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
|
func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
|
||||||
@@ -332,6 +338,63 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
|
|||||||
_, err = db.GetByCRT(ctx, secondCRT)
|
_, err = db.GetByCRT(ctx, secondCRT)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
|
||||||
|
userID := primitive.NewObjectID()
|
||||||
|
clientID := "web-app"
|
||||||
|
deviceID := "user-laptop"
|
||||||
|
|
||||||
|
firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
|
||||||
|
err := db.Create(ctx, firstToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, firstToken.GetID())
|
||||||
|
|
||||||
|
// Global revoke (deviceID empty) — all tokens should be revoked
|
||||||
|
err = db.RevokeAll(ctx, userID, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var revoked model.RefreshToken
|
||||||
|
err = db.Get(ctx, *firstToken.GetID(), &revoked)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, revoked.IsRevoked)
|
||||||
|
|
||||||
|
// Creating a new token for the same account/client/device must succeed and produce an active token
|
||||||
|
reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
|
||||||
|
err = db.Create(ctx, reissueToken)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newCRT := &model.ClientRefreshToken{
|
||||||
|
SessionIdentifier: model.SessionIdentifier{
|
||||||
|
ClientID: clientID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
},
|
||||||
|
RefreshToken: "new_token_after_revocation",
|
||||||
|
}
|
||||||
|
_, err = db.GetByCRT(ctx, newCRT)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Old token must remain unusable
|
||||||
|
oldCRT := &model.ClientRefreshToken{
|
||||||
|
SessionIdentifier: model.SessionIdentifier{
|
||||||
|
ClientID: clientID,
|
||||||
|
DeviceID: deviceID,
|
||||||
|
},
|
||||||
|
RefreshToken: "revoked_token_123",
|
||||||
|
}
|
||||||
|
_, err = db.GetByCRT(ctx, oldCRT)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Both records exist: revoked + new active
|
||||||
|
query := repository.Query().
|
||||||
|
Filter(repository.AccountField(), userID).
|
||||||
|
And(
|
||||||
|
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
|
||||||
|
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
|
||||||
|
)
|
||||||
|
ids, err := db.Repository.ListIDs(ctx, query)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, ids, 2)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshTokenDB_ClientManagement(t *testing.T) {
|
func TestRefreshTokenDB_ClientManagement(t *testing.T) {
|
||||||
@@ -637,3 +700,29 @@ func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
|
|||||||
assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
|
assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
|
||||||
|
db, database, cleanup := setupTestDBWithMongo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for cursor.Next(ctx) {
|
||||||
|
var idx bson.M
|
||||||
|
require.NoError(t, cursor.Decode(&idx))
|
||||||
|
if idx["name"] == "unique_active_session" {
|
||||||
|
found = true
|
||||||
|
assert.Equal(t, true, idx["unique"])
|
||||||
|
|
||||||
|
partial, ok := idx["partialFilterExpression"].(bson.M)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, bson.M{"isRevoked": false}, partial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "unique_active_session index not found")
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
|
|||||||
if def.Name != "" {
|
if def.Name != "" {
|
||||||
opts.SetName(def.Name)
|
opts.SetName(def.Name)
|
||||||
}
|
}
|
||||||
|
if def.PartialFilter != nil {
|
||||||
|
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
|
||||||
|
}
|
||||||
|
|
||||||
_, err := r.collection.Indexes().CreateOne(
|
_, err := r.collection.Indexes().CreateOne(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
|
package repositoryimp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateIndex_WithPartialFilter(t *testing.T) {
|
||||||
|
startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer startCancel()
|
||||||
|
|
||||||
|
mongoContainer, err := mongodb.Run(startCtx,
|
||||||
|
"mongo:latest",
|
||||||
|
mongodb.WithUsername("root"),
|
||||||
|
mongodb.WithPassword("password"),
|
||||||
|
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mongoURI, err := mongoContainer.ConnectionString(startCtx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client, err := mongo.Connect(startCtx, options.Client().ApplyURI(mongoURI))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer client.Disconnect(context.Background())
|
||||||
|
|
||||||
|
database := client.Database("test_partial_index_" + t.Name())
|
||||||
|
defer database.Drop(context.Background())
|
||||||
|
|
||||||
|
repo := repository.CreateMongoRepository(database, "partial_index_items")
|
||||||
|
|
||||||
|
def := &ri.Definition{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: "field", Sort: ri.Asc},
|
||||||
|
},
|
||||||
|
Unique: true,
|
||||||
|
Name: "partial_unique_field_true",
|
||||||
|
PartialFilter: repository.Filter("flag", treu),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, repo.CreateIndex(def))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cursor, err := database.Collection(repo.Collection()).Indexes().List(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for cursor.Next(ctx) {
|
||||||
|
var idx bson.M
|
||||||
|
require.NoError(t, cursor.Decode(&idx))
|
||||||
|
if idx["name"] == def.Name {
|
||||||
|
found = true
|
||||||
|
assert.Equal(t, true, idx["unique"])
|
||||||
|
assert.Equal(t, bson.M{"field": int32(1)}, idx["key"])
|
||||||
|
partial, ok := idx["partialFilterExpression"].(bson.M)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, bson.M{"flag": true}, partial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "partial unique index was not created")
|
||||||
|
|
||||||
|
termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer termCancel()
|
||||||
|
_ = mongoContainer.Terminate(termCtx)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
|
|
||||||
type Sort int8
|
type Sort int8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -14,8 +16,9 @@ type Key struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Definition struct {
|
type Definition struct {
|
||||||
Keys []Key // mandatory, at least one element
|
Keys []Key // mandatory, at least one element
|
||||||
Unique bool // unique constraint?
|
Unique bool // unique constraint?
|
||||||
TTL *int32 // seconds; nil means “no TTL”
|
TTL *int32 // seconds; nil means “no TTL”
|
||||||
Name string // optional explicit name
|
Name string // optional explicit name
|
||||||
|
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,6 @@ require (
|
|||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/time v0.5.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -269,8 +269,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
11
api/proto/common/describable/v1/describable.proto
Normal file
11
api/proto/common/describable/v1/describable.proto
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package common.describable.v1;
|
||||||
|
|
||||||
|
option go_package = "github.com/tech/sendico/pkg/proto/common/describable/v1;describablev1";
|
||||||
|
|
||||||
|
// Describable captures a name/description pair reusable across resources.
|
||||||
|
message Describable {
|
||||||
|
string name = 1;
|
||||||
|
optional string description = 2;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/gateway/chain/v1;chainv1"
|
|||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
import "common/money/v1/money.proto";
|
import "common/money/v1/money.proto";
|
||||||
import "common/pagination/v1/cursor.proto";
|
import "common/pagination/v1/cursor.proto";
|
||||||
|
import "common/describable/v1/describable.proto";
|
||||||
|
|
||||||
// Supported blockchain networks for the managed wallets.
|
// Supported blockchain networks for the managed wallets.
|
||||||
enum ChainNetwork {
|
enum ChainNetwork {
|
||||||
@@ -57,6 +58,7 @@ message ManagedWallet {
|
|||||||
map<string, string> metadata = 7;
|
map<string, string> metadata = 7;
|
||||||
google.protobuf.Timestamp created_at = 8;
|
google.protobuf.Timestamp created_at = 8;
|
||||||
google.protobuf.Timestamp updated_at = 9;
|
google.protobuf.Timestamp updated_at = 9;
|
||||||
|
common.describable.v1.Describable describable = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateManagedWalletRequest {
|
message CreateManagedWalletRequest {
|
||||||
@@ -65,6 +67,7 @@ message CreateManagedWalletRequest {
|
|||||||
string owner_ref = 3;
|
string owner_ref = 3;
|
||||||
Asset asset = 4;
|
Asset asset = 4;
|
||||||
map<string, string> metadata = 5;
|
map<string, string> metadata = 5;
|
||||||
|
common.describable.v1.Describable describable = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateManagedWalletResponse {
|
message CreateManagedWalletResponse {
|
||||||
|
|||||||
@@ -139,6 +139,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||||
google.golang.org/grpc v1.77.0 // indirect
|
google.golang.org/grpc v1.77.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -361,8 +361,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sresponse
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/api/http/response"
|
"github.com/tech/sendico/pkg/api/http/response"
|
||||||
@@ -26,6 +27,8 @@ type wallet struct {
|
|||||||
DepositAddress string `json:"depositAddress"`
|
DepositAddress string `json:"depositAddress"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
CreatedAt string `json:"createdAt,omitempty"`
|
CreatedAt string `json:"createdAt,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,27 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
|
|||||||
token = asset.GetTokenSymbol()
|
token = asset.GetTokenSymbol()
|
||||||
contract = asset.GetContractAddress()
|
contract = asset.GetContractAddress()
|
||||||
}
|
}
|
||||||
|
name := ""
|
||||||
|
if d := w.GetDescribable(); d != nil {
|
||||||
|
name = strings.TrimSpace(d.GetName())
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(w.GetMetadata()["name"])
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = w.GetWalletRef()
|
||||||
|
}
|
||||||
|
var description *string
|
||||||
|
if d := w.GetDescribable(); d != nil && d.Description != nil {
|
||||||
|
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if description == nil {
|
||||||
|
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
return wallet{
|
return wallet{
|
||||||
WalletRef: w.GetWalletRef(),
|
WalletRef: w.GetWalletRef(),
|
||||||
OrganizationRef: w.GetOrganizationRef(),
|
OrganizationRef: w.GetOrganizationRef(),
|
||||||
@@ -92,6 +116,8 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
|
|||||||
DepositAddress: w.GetDepositAddress(),
|
DepositAddress: w.GetDepositAddress(),
|
||||||
Status: w.GetStatus().String(),
|
Status: w.GetStatus().String(),
|
||||||
Metadata: w.GetMetadata(),
|
Metadata: w.GetMetadata(),
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
CreatedAt: tsToString(w.GetCreatedAt()),
|
CreatedAt: tsToString(w.GetCreatedAt()),
|
||||||
UpdatedAt: tsToString(w.GetUpdatedAt()),
|
UpdatedAt: tsToString(w.GetUpdatedAt()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *sre
|
|||||||
pr.logger.Warn("Failed to create login confirmation code", zap.Error(err))
|
pr.logger.Warn("Failed to create login confirmation code", zap.Error(err))
|
||||||
return response.Internal(pr.logger, pr.service, err)
|
return response.Internal(pr.logger, pr.service, err)
|
||||||
}
|
}
|
||||||
pr.logger.Info("Login confirmation code issued",
|
pr.logger.Info("Login confirmation code issued", zap.String("destination", pr.maskEmail(account.Login)))
|
||||||
zap.String("destination", pr.maskEmail(account.Login)),
|
|
||||||
zap.String("account", account.Login))
|
|
||||||
|
|
||||||
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
|
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ class ChangePassword {
|
|||||||
@JsonKey(name: 'new')
|
@JsonKey(name: 'new')
|
||||||
final String newPassword;
|
final String newPassword;
|
||||||
|
|
||||||
const ChangePassword({required this.oldPassword, required this.newPassword});
|
final String deviceId;
|
||||||
|
|
||||||
|
const ChangePassword({required this.oldPassword, required this.newPassword, required this.deviceId});
|
||||||
|
|
||||||
factory ChangePassword.fromJson(Map<String, dynamic> json) => _$ChangePasswordFromJson(json);
|
factory ChangePassword.fromJson(Map<String, dynamic> json) => _$ChangePasswordFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$ChangePasswordToJson(this);
|
Map<String, dynamic> toJson() => _$ChangePasswordToJson(this);
|
||||||
|
|||||||
25
frontend/pshared/lib/api/requests/payment/quotes.dart
Normal file
25
frontend/pshared/lib/api/requests/payment/quotes.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
import 'package:pshared/api/requests/payment/base.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/intent/payment.dart';
|
||||||
|
|
||||||
|
part 'quotes.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class QuotePaymentsRequest extends PaymentBaseRequest {
|
||||||
|
final List<PaymentIntentDTO> intents;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
final bool previewOnly;
|
||||||
|
|
||||||
|
const QuotePaymentsRequest({
|
||||||
|
required super.idempotencyKey,
|
||||||
|
super.metadata,
|
||||||
|
required this.intents,
|
||||||
|
this.previewOnly = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory QuotePaymentsRequest.fromJson(Map<String, dynamic> json) => _$QuotePaymentsRequestFromJson(json);
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => _$QuotePaymentsRequestToJson(this);
|
||||||
|
}
|
||||||
20
frontend/pshared/lib/api/requests/username.dart
Normal file
20
frontend/pshared/lib/api/requests/username.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'username.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class ResetUserNameRequest {
|
||||||
|
final String userName;
|
||||||
|
|
||||||
|
const ResetUserNameRequest({
|
||||||
|
required this.userName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ResetUserNameRequest.fromJson(Map<String, dynamic> json) => _$ResetUserNameRequestFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$ResetUserNameRequestToJson(this);
|
||||||
|
|
||||||
|
static ResetUserNameRequest build({
|
||||||
|
required String userName,
|
||||||
|
}) => ResetUserNameRequest(userName: userName);
|
||||||
|
}
|
||||||
26
frontend/pshared/lib/api/responses/confirmation.dart
Normal file
26
frontend/pshared/lib/api/responses/confirmation.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'confirmation.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class ConfirmationResponse {
|
||||||
|
@JsonKey(name: 'ttl_seconds', defaultValue: 0)
|
||||||
|
final int ttlSeconds;
|
||||||
|
@JsonKey(name: 'cooldown_seconds', defaultValue: 0)
|
||||||
|
final int cooldownSeconds;
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String destination;
|
||||||
|
|
||||||
|
const ConfirmationResponse({
|
||||||
|
required this.ttlSeconds,
|
||||||
|
required this.cooldownSeconds,
|
||||||
|
required this.destination,
|
||||||
|
});
|
||||||
|
|
||||||
|
Duration get cooldownDuration => Duration(seconds: cooldownSeconds);
|
||||||
|
Duration get ttlDuration => Duration(seconds: ttlSeconds);
|
||||||
|
|
||||||
|
factory ConfirmationResponse.fromJson(Map<String, dynamic> json) => _$ConfirmationResponseFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$ConfirmationResponseToJson(this);
|
||||||
|
}
|
||||||
20
frontend/pshared/lib/api/responses/payment/quotes.dart
Normal file
20
frontend/pshared/lib/api/responses/payment/quotes.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/api/responses/base.dart';
|
||||||
|
import 'package:pshared/api/responses/token.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/quotes.dart';
|
||||||
|
|
||||||
|
part 'quotes.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class PaymentQuotesResponse extends BaseAuthorizedResponse {
|
||||||
|
|
||||||
|
final PaymentQuotesDTO quote;
|
||||||
|
|
||||||
|
const PaymentQuotesResponse({required super.accessToken, required this.quote});
|
||||||
|
|
||||||
|
factory PaymentQuotesResponse.fromJson(Map<String, dynamic> json) => _$PaymentQuotesResponseFromJson(json);
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => _$PaymentQuotesResponseToJson(this);
|
||||||
|
}
|
||||||
24
frontend/pshared/lib/data/dto/payment/quote_aggregate.dart
Normal file
24
frontend/pshared/lib/data/dto/payment/quote_aggregate.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/data/dto/payment/money.dart';
|
||||||
|
|
||||||
|
part 'quote_aggregate.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class PaymentQuoteAggregateDTO {
|
||||||
|
final List<MoneyDTO>? debitAmounts;
|
||||||
|
final List<MoneyDTO>? expectedSettlementAmounts;
|
||||||
|
final List<MoneyDTO>? expectedFeeTotals;
|
||||||
|
final List<MoneyDTO>? networkFeeTotals;
|
||||||
|
|
||||||
|
const PaymentQuoteAggregateDTO({
|
||||||
|
this.debitAmounts,
|
||||||
|
this.expectedSettlementAmounts,
|
||||||
|
this.expectedFeeTotals,
|
||||||
|
this.networkFeeTotals,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaymentQuoteAggregateDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuoteAggregateDTOFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$PaymentQuoteAggregateDTOToJson(this);
|
||||||
|
}
|
||||||
23
frontend/pshared/lib/data/dto/payment/quotes.dart
Normal file
23
frontend/pshared/lib/data/dto/payment/quotes.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/data/dto/payment/quote_aggregate.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/payment_quote.dart';
|
||||||
|
|
||||||
|
part 'quotes.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class PaymentQuotesDTO {
|
||||||
|
final String quoteRef;
|
||||||
|
final PaymentQuoteAggregateDTO? aggregate;
|
||||||
|
final List<PaymentQuoteDTO>? quotes;
|
||||||
|
|
||||||
|
const PaymentQuotesDTO({
|
||||||
|
required this.quoteRef,
|
||||||
|
this.aggregate,
|
||||||
|
this.quotes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaymentQuotesDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuotesDTOFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$PaymentQuotesDTOToJson(this);
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ class WalletDTO {
|
|||||||
final WalletAssetDTO asset;
|
final WalletAssetDTO asset;
|
||||||
final String depositAddress;
|
final String depositAddress;
|
||||||
final String status;
|
final String status;
|
||||||
|
final String name;
|
||||||
|
final String? description;
|
||||||
final Map<String, String>? metadata;
|
final Map<String, String>? metadata;
|
||||||
final String? createdAt;
|
final String? createdAt;
|
||||||
final String? updatedAt;
|
final String? updatedAt;
|
||||||
@@ -24,6 +26,8 @@ class WalletDTO {
|
|||||||
required this.asset,
|
required this.asset,
|
||||||
required this.depositAddress,
|
required this.depositAddress,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
this.metadata,
|
this.metadata,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:pshared/data/dto/payment/quote_aggregate.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/money.dart';
|
||||||
|
import 'package:pshared/models/payment/quote_aggregate.dart';
|
||||||
|
|
||||||
|
|
||||||
|
extension PaymentQuoteAggregateDTOMapper on PaymentQuoteAggregateDTO {
|
||||||
|
PaymentQuoteAggregate toDomain() => PaymentQuoteAggregate(
|
||||||
|
debitAmounts: debitAmounts?.map((amount) => amount.toDomain()).toList(),
|
||||||
|
expectedSettlementAmounts: expectedSettlementAmounts?.map((amount) => amount.toDomain()).toList(),
|
||||||
|
expectedFeeTotals: expectedFeeTotals?.map((amount) => amount.toDomain()).toList(),
|
||||||
|
networkFeeTotals: networkFeeTotals?.map((amount) => amount.toDomain()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PaymentQuoteAggregateMapper on PaymentQuoteAggregate {
|
||||||
|
PaymentQuoteAggregateDTO toDTO() => PaymentQuoteAggregateDTO(
|
||||||
|
debitAmounts: debitAmounts?.map((amount) => amount.toDTO()).toList(),
|
||||||
|
expectedSettlementAmounts: expectedSettlementAmounts?.map((amount) => amount.toDTO()).toList(),
|
||||||
|
expectedFeeTotals: expectedFeeTotals?.map((amount) => amount.toDTO()).toList(),
|
||||||
|
networkFeeTotals: networkFeeTotals?.map((amount) => amount.toDTO()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/pshared/lib/data/mapper/payment/quotes.dart
Normal file
21
frontend/pshared/lib/data/mapper/payment/quotes.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:pshared/data/dto/payment/quotes.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/payment_quote.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/quote_aggregate.dart';
|
||||||
|
import 'package:pshared/models/payment/quotes.dart';
|
||||||
|
|
||||||
|
|
||||||
|
extension PaymentQuotesDTOMapper on PaymentQuotesDTO {
|
||||||
|
PaymentQuotes toDomain() => PaymentQuotes(
|
||||||
|
quoteRef: quoteRef,
|
||||||
|
aggregate: aggregate?.toDomain(),
|
||||||
|
quotes: quotes?.map((quote) => quote.toDomain()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PaymentQuotesMapper on PaymentQuotes {
|
||||||
|
PaymentQuotesDTO toDTO() => PaymentQuotesDTO(
|
||||||
|
quoteRef: quoteRef,
|
||||||
|
aggregate: aggregate?.toDTO(),
|
||||||
|
quotes: quotes?.map((quote) => quote.toDTO()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,8 +24,10 @@ extension WalletDTOMapper on WalletDTO {
|
|||||||
balance: balance?.toDomain(),
|
balance: balance?.toDomain(),
|
||||||
availableMoney: balance?.available?.toDomain(),
|
availableMoney: balance?.available?.toDomain(),
|
||||||
describable: newDescribable(
|
describable: newDescribable(
|
||||||
name: metadata?['name'] ?? 'Crypto Wallet',
|
name: name.isNotEmpty ? name : (metadata?['name'] ?? 'Crypto Wallet'),
|
||||||
description: metadata?['description'],
|
description: (description != null && description!.isNotEmpty)
|
||||||
|
? description
|
||||||
|
: metadata?['description'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class PendingLogin {
|
|||||||
final String destination;
|
final String destination;
|
||||||
final int ttlSeconds;
|
final int ttlSeconds;
|
||||||
final SessionIdentifier session;
|
final SessionIdentifier session;
|
||||||
|
final int? cooldownSeconds;
|
||||||
|
final DateTime? cooldownUntil;
|
||||||
|
|
||||||
const PendingLogin({
|
const PendingLogin({
|
||||||
required this.account,
|
required this.account,
|
||||||
@@ -18,6 +20,8 @@ class PendingLogin {
|
|||||||
required this.destination,
|
required this.destination,
|
||||||
required this.ttlSeconds,
|
required this.ttlSeconds,
|
||||||
required this.session,
|
required this.session,
|
||||||
|
this.cooldownSeconds,
|
||||||
|
this.cooldownUntil,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory PendingLogin.fromResponse(
|
factory PendingLogin.fromResponse(
|
||||||
@@ -30,4 +34,30 @@ class PendingLogin {
|
|||||||
ttlSeconds: response.ttlSeconds,
|
ttlSeconds: response.ttlSeconds,
|
||||||
session: session,
|
session: session,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
PendingLogin copyWith({
|
||||||
|
Account? account,
|
||||||
|
TokenData? pendingToken,
|
||||||
|
String? destination,
|
||||||
|
int? ttlSeconds,
|
||||||
|
SessionIdentifier? session,
|
||||||
|
int? cooldownSeconds,
|
||||||
|
DateTime? cooldownUntil,
|
||||||
|
bool clearCooldown = false,
|
||||||
|
}) {
|
||||||
|
return PendingLogin(
|
||||||
|
account: account ?? this.account,
|
||||||
|
pendingToken: pendingToken ?? this.pendingToken,
|
||||||
|
destination: destination ?? this.destination,
|
||||||
|
ttlSeconds: ttlSeconds ?? this.ttlSeconds,
|
||||||
|
session: session ?? this.session,
|
||||||
|
cooldownSeconds: clearCooldown ? null : cooldownSeconds ?? this.cooldownSeconds,
|
||||||
|
cooldownUntil: clearCooldown ? null : cooldownUntil ?? this.cooldownUntil,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get cooldownRemainingSeconds {
|
||||||
|
final remaining = cooldownUntil?.difference(DateTime.now()).inSeconds ?? 0;
|
||||||
|
return remaining < 0 ? 0 : remaining;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
frontend/pshared/lib/models/payment/quote_aggregate.dart
Normal file
16
frontend/pshared/lib/models/payment/quote_aggregate.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:pshared/models/payment/money.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentQuoteAggregate {
|
||||||
|
final List<Money>? debitAmounts;
|
||||||
|
final List<Money>? expectedSettlementAmounts;
|
||||||
|
final List<Money>? expectedFeeTotals;
|
||||||
|
final List<Money>? networkFeeTotals;
|
||||||
|
|
||||||
|
const PaymentQuoteAggregate({
|
||||||
|
required this.debitAmounts,
|
||||||
|
required this.expectedSettlementAmounts,
|
||||||
|
required this.expectedFeeTotals,
|
||||||
|
required this.networkFeeTotals,
|
||||||
|
});
|
||||||
|
}
|
||||||
15
frontend/pshared/lib/models/payment/quotes.dart
Normal file
15
frontend/pshared/lib/models/payment/quotes.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:pshared/models/payment/quote.dart';
|
||||||
|
import 'package:pshared/models/payment/quote_aggregate.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentQuotes {
|
||||||
|
final String quoteRef;
|
||||||
|
final PaymentQuoteAggregate? aggregate;
|
||||||
|
final List<PaymentQuote>? quotes;
|
||||||
|
|
||||||
|
const PaymentQuotes({
|
||||||
|
required this.quoteRef,
|
||||||
|
required this.aggregate,
|
||||||
|
required this.quotes,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import 'package:pshared/api/requests/signup.dart';
|
|||||||
import 'package:pshared/api/requests/login_data.dart';
|
import 'package:pshared/api/requests/login_data.dart';
|
||||||
import 'package:pshared/config/constants.dart';
|
import 'package:pshared/config/constants.dart';
|
||||||
import 'package:pshared/models/account/account.dart';
|
import 'package:pshared/models/account/account.dart';
|
||||||
|
import 'package:pshared/api/responses/confirmation.dart';
|
||||||
import 'package:pshared/models/auth/login_outcome.dart';
|
import 'package:pshared/models/auth/login_outcome.dart';
|
||||||
import 'package:pshared/models/auth/pending_login.dart';
|
import 'package:pshared/models/auth/pending_login.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
import 'package:pshared/models/describable.dart';
|
||||||
@@ -101,8 +102,8 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
if (pending == null) {
|
if (pending == null) {
|
||||||
throw Exception('Pending login data is missing');
|
throw Exception('Pending login data is missing');
|
||||||
}
|
}
|
||||||
await VerificationService.requestLoginCode(pending);
|
final confirmation = await VerificationService.requestLoginCode(pending);
|
||||||
_pendingLogin = pending;
|
_pendingLogin = _applyConfirmationMeta(pending, confirmation);
|
||||||
_authState = AuthState.idle;
|
_authState = AuthState.idle;
|
||||||
_setResource(_resource.copyWith(isLoading: false));
|
_setResource(_resource.copyWith(isLoading: false));
|
||||||
}
|
}
|
||||||
@@ -114,6 +115,27 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PendingLogin _applyConfirmationMeta(PendingLogin pending, ConfirmationResponse confirmation) {
|
||||||
|
final ttlSeconds = confirmation.ttlSeconds != 0 ? confirmation.ttlSeconds : pending.ttlSeconds;
|
||||||
|
final destination = confirmation.destination.isNotEmpty ? confirmation.destination : pending.destination;
|
||||||
|
final cooldownSeconds = confirmation.cooldownSeconds;
|
||||||
|
|
||||||
|
return pending.copyWith(
|
||||||
|
ttlSeconds: ttlSeconds,
|
||||||
|
destination: destination,
|
||||||
|
cooldownSeconds: cooldownSeconds > 0 ? cooldownSeconds : null,
|
||||||
|
cooldownUntil: cooldownSeconds > 0 ? DateTime.now().add(confirmation.cooldownDuration) : null,
|
||||||
|
clearCooldown: cooldownSeconds <= 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updatePendingLogin(ConfirmationResponse confirmation) {
|
||||||
|
final pending = _pendingLogin;
|
||||||
|
if (pending == null) return;
|
||||||
|
_pendingLogin = _applyConfirmationMeta(pending, confirmation);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void completePendingLogin(Account account) {
|
void completePendingLogin(Account account) {
|
||||||
_pendingLogin = null;
|
_pendingLogin = null;
|
||||||
_authState = AuthState.ready;
|
_authState = AuthState.ready;
|
||||||
@@ -228,6 +250,13 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Account?> resetUsername(String userName) async {
|
||||||
|
if (account == null) throw ErrorUnauthorized();
|
||||||
|
return update(
|
||||||
|
describable: account!.describable.copyWith(name: userName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> forgotPassword(String email) async {
|
Future<void> forgotPassword(String email) async {
|
||||||
_setResource(_resource.copyWith(isLoading: true, error: null));
|
_setResource(_resource.copyWith(isLoading: true, error: null));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:pshared/service/device_id.dart';
|
||||||
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
@@ -66,7 +67,11 @@ class AccountService {
|
|||||||
return _getAccount(AuthorizationService.getPATCHResponse(
|
return _getAccount(AuthorizationService.getPATCHResponse(
|
||||||
_objectType,
|
_objectType,
|
||||||
'password',
|
'password',
|
||||||
ChangePassword(oldPassword: oldPassword, newPassword: newPassword).toJson(),
|
ChangePassword(
|
||||||
|
oldPassword: oldPassword,
|
||||||
|
newPassword: newPassword,
|
||||||
|
deviceId: await DeviceIdManager.getDeviceId(),
|
||||||
|
).toJson(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
import 'package:pshared/api/requests/payment/quote.dart';
|
import 'package:pshared/api/requests/payment/quote.dart';
|
||||||
|
import 'package:pshared/api/requests/payment/quotes.dart';
|
||||||
import 'package:pshared/api/responses/payment/quotation.dart';
|
import 'package:pshared/api/responses/payment/quotation.dart';
|
||||||
|
import 'package:pshared/api/responses/payment/quotes.dart';
|
||||||
import 'package:pshared/data/mapper/payment/payment_quote.dart';
|
import 'package:pshared/data/mapper/payment/payment_quote.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/quotes.dart';
|
||||||
import 'package:pshared/models/payment/quote.dart';
|
import 'package:pshared/models/payment/quote.dart';
|
||||||
|
import 'package:pshared/models/payment/quotes.dart';
|
||||||
import 'package:pshared/service/authorization/service.dart';
|
import 'package:pshared/service/authorization/service.dart';
|
||||||
import 'package:pshared/service/services.dart';
|
import 'package:pshared/service/services.dart';
|
||||||
|
|
||||||
@@ -21,4 +25,14 @@ class QuotationService {
|
|||||||
);
|
);
|
||||||
return PaymentQuoteResponse.fromJson(response).quote.toDomain();
|
return PaymentQuoteResponse.fromJson(response).quote.toDomain();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<PaymentQuotes> getMultiQuotation(String organizationRef, QuotePaymentsRequest request) async {
|
||||||
|
_logger.fine('Quoting payments for organization $organizationRef');
|
||||||
|
final response = await AuthorizationService.getPOSTResponse(
|
||||||
|
_objectType,
|
||||||
|
'/multiquote/$organizationRef',
|
||||||
|
request.toJson(),
|
||||||
|
);
|
||||||
|
return PaymentQuotesResponse.fromJson(response).quote.toDomain();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:pshared/api/responses/login.dart';
|
|||||||
import 'package:pshared/data/mapper/session_identifier.dart';
|
import 'package:pshared/data/mapper/session_identifier.dart';
|
||||||
import 'package:pshared/models/account/account.dart';
|
import 'package:pshared/models/account/account.dart';
|
||||||
import 'package:pshared/data/mapper/account/account.dart';
|
import 'package:pshared/data/mapper/account/account.dart';
|
||||||
|
import 'package:pshared/api/responses/confirmation.dart';
|
||||||
import 'package:pshared/models/auth/pending_login.dart';
|
import 'package:pshared/models/auth/pending_login.dart';
|
||||||
import 'package:pshared/service/authorization/storage.dart';
|
import 'package:pshared/service/authorization/storage.dart';
|
||||||
import 'package:pshared/service/services.dart';
|
import 'package:pshared/service/services.dart';
|
||||||
@@ -15,24 +16,26 @@ class VerificationService {
|
|||||||
static final _logger = Logger('service.verification');
|
static final _logger = Logger('service.verification');
|
||||||
static const String _objectType = Services.confirmations;
|
static const String _objectType = Services.confirmations;
|
||||||
|
|
||||||
static Future<void> requestLoginCode(PendingLogin pending, {String? destination}) async {
|
static Future<ConfirmationResponse> requestLoginCode(PendingLogin pending, {String? destination}) async {
|
||||||
_logger.fine('Requesting login confirmation code');
|
_logger.fine('Requesting login confirmation code');
|
||||||
await getPOSTResponse(
|
final response = await getPOSTResponse(
|
||||||
_objectType,
|
_objectType,
|
||||||
'',
|
'',
|
||||||
LoginConfirmationRequest(destination: destination).toJson(),
|
LoginConfirmationRequest(destination: destination).toJson(),
|
||||||
authToken: pending.pendingToken.token,
|
authToken: pending.pendingToken.token,
|
||||||
);
|
);
|
||||||
|
return ConfirmationResponse.fromJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> resendLoginCode(PendingLogin pending, {String? destination}) async {
|
static Future<ConfirmationResponse> resendLoginCode(PendingLogin pending, {String? destination}) async {
|
||||||
_logger.fine('Resending login confirmation code');
|
_logger.fine('Resending login confirmation code');
|
||||||
await getPOSTResponse(
|
final response = await getPOSTResponse(
|
||||||
_objectType,
|
_objectType,
|
||||||
'/resend',
|
'/resend',
|
||||||
LoginConfirmationRequest(destination: destination).toJson(),
|
LoginConfirmationRequest(destination: destination).toJson(),
|
||||||
authToken: pending.pendingToken.token,
|
authToken: pending.pendingToken.token,
|
||||||
);
|
);
|
||||||
|
return ConfirmationResponse.fromJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Account> confirmLoginCode({
|
static Future<Account> confirmLoginCode({
|
||||||
|
|||||||
45
frontend/pshared/lib/widgets/password/confirm_field.dart
Normal file
45
frontend/pshared/lib/widgets/password/confirm_field.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmPasswordField extends StatelessWidget {
|
||||||
|
const ConfirmPasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.fieldWidth,
|
||||||
|
required this.isEnabled,
|
||||||
|
required this.confirmPasswordLabel,
|
||||||
|
required this.newPasswordController,
|
||||||
|
required this.missingPasswordError,
|
||||||
|
required this.passwordsDoNotMatchError,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final double fieldWidth;
|
||||||
|
final bool isEnabled;
|
||||||
|
final String confirmPasswordLabel;
|
||||||
|
final TextEditingController newPasswordController;
|
||||||
|
final String missingPasswordError;
|
||||||
|
final String passwordsDoNotMatchError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: fieldWidth,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: true,
|
||||||
|
enabled: isEnabled,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: confirmPasswordLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) return missingPasswordError;
|
||||||
|
if (value != newPasswordController.text) {
|
||||||
|
return passwordsDoNotMatchError;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/pshared/lib/widgets/password/field.dart
Normal file
35
frontend/pshared/lib/widgets/password/field.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordField extends StatelessWidget {
|
||||||
|
const PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.labelText,
|
||||||
|
required this.fieldWidth,
|
||||||
|
required this.isEnabled,
|
||||||
|
required this.validator,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String labelText;
|
||||||
|
final double fieldWidth;
|
||||||
|
final bool isEnabled;
|
||||||
|
final String? Function(String?) validator;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: fieldWidth,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: true,
|
||||||
|
enabled: isEnabled,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: labelText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: validator,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
frontend/pshared/lib/widgets/password/fields.dart
Normal file
69
frontend/pshared/lib/widgets/password/fields.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/widgets/password/confirm_field.dart';
|
||||||
|
import 'package:pshared/widgets/password/field.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordFields extends StatelessWidget {
|
||||||
|
const PasswordFields({
|
||||||
|
super.key,
|
||||||
|
required this.oldPasswordController,
|
||||||
|
required this.newPasswordController,
|
||||||
|
required this.confirmPasswordController,
|
||||||
|
required this.oldPasswordLabel,
|
||||||
|
required this.newPasswordLabel,
|
||||||
|
required this.confirmPasswordLabel,
|
||||||
|
required this.missingPasswordError,
|
||||||
|
required this.passwordsDoNotMatchError,
|
||||||
|
required this.fieldWidth,
|
||||||
|
required this.gapSmall,
|
||||||
|
required this.isEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController oldPasswordController;
|
||||||
|
final TextEditingController newPasswordController;
|
||||||
|
final TextEditingController confirmPasswordController;
|
||||||
|
final String oldPasswordLabel;
|
||||||
|
final String newPasswordLabel;
|
||||||
|
final String confirmPasswordLabel;
|
||||||
|
final String missingPasswordError;
|
||||||
|
final String passwordsDoNotMatchError;
|
||||||
|
final double fieldWidth;
|
||||||
|
final double gapSmall;
|
||||||
|
final bool isEnabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
PasswordField(
|
||||||
|
controller: oldPasswordController,
|
||||||
|
labelText: oldPasswordLabel,
|
||||||
|
fieldWidth: fieldWidth,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
validator: (value) =>
|
||||||
|
(value == null || value.isEmpty) ? missingPasswordError : null,
|
||||||
|
),
|
||||||
|
SizedBox(height: gapSmall),
|
||||||
|
PasswordField(
|
||||||
|
controller: newPasswordController,
|
||||||
|
labelText: newPasswordLabel,
|
||||||
|
fieldWidth: fieldWidth,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
validator: (value) =>
|
||||||
|
(value == null || value.isEmpty) ? missingPasswordError : null,
|
||||||
|
),
|
||||||
|
SizedBox(height: gapSmall),
|
||||||
|
ConfirmPasswordField(
|
||||||
|
controller: confirmPasswordController,
|
||||||
|
fieldWidth: fieldWidth,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
confirmPasswordLabel: confirmPasswordLabel,
|
||||||
|
newPasswordController: newPasswordController,
|
||||||
|
missingPasswordError: missingPasswordError,
|
||||||
|
passwordsDoNotMatchError: passwordsDoNotMatchError,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'package:pshared/models/wallet/wallet.dart' as domain;
|
import 'package:pshared/models/wallet/wallet.dart' as domain;
|
||||||
|
|
||||||
import 'package:pshared/models/currency.dart';
|
import 'package:pshared/models/currency.dart';
|
||||||
import 'package:pshared/models/describable.dart';
|
|
||||||
|
|
||||||
import 'package:pweb/models/wallet.dart';
|
import 'package:pweb/models/wallet.dart';
|
||||||
|
|
||||||
|
|
||||||
@@ -26,10 +24,7 @@ extension WalletUiMapper on domain.WalletModel {
|
|||||||
network: asset.chain,
|
network: asset.chain,
|
||||||
tokenSymbol: asset.tokenSymbol,
|
tokenSymbol: asset.tokenSymbol,
|
||||||
contractAddress: asset.contractAddress,
|
contractAddress: asset.contractAddress,
|
||||||
describable: newDescribable(
|
describable: describable,
|
||||||
name: metadata?['name'] ?? 'Crypto Wallet',
|
|
||||||
description: metadata?['description'],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,13 @@
|
|||||||
"usernameErrorInvalid": "Provide a valid email address",
|
"usernameErrorInvalid": "Provide a valid email address",
|
||||||
"usernameUnknownTLD": "Domain .{domain} is not known, please, check it",
|
"usernameUnknownTLD": "Domain .{domain} is not known, please, check it",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"oldPassword": "Current password",
|
||||||
|
"newPassword": "New password",
|
||||||
"confirmPassword": "Confirm password",
|
"confirmPassword": "Confirm password",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"savePassword": "Save changed password",
|
||||||
|
"changePasswordSuccess": "Password updated",
|
||||||
|
"changePasswordError": "Could not update password",
|
||||||
"passwordValidationRuleDigit": "has digit",
|
"passwordValidationRuleDigit": "has digit",
|
||||||
"passwordValidationRuleUpperCase": "has uppercase letter",
|
"passwordValidationRuleUpperCase": "has uppercase letter",
|
||||||
"passwordValidationRuleLowerCase": "has lowercase letter",
|
"passwordValidationRuleLowerCase": "has lowercase letter",
|
||||||
|
|||||||
@@ -9,7 +9,13 @@
|
|||||||
"usernameErrorInvalid": "Укажите действительный адрес электронной почты",
|
"usernameErrorInvalid": "Укажите действительный адрес электронной почты",
|
||||||
"usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его",
|
"usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
|
"oldPassword": "Текущий пароль",
|
||||||
|
"newPassword": "Новый пароль",
|
||||||
"confirmPassword": "Подтвердите пароль",
|
"confirmPassword": "Подтвердите пароль",
|
||||||
|
"changePassword": "Изменить пароль",
|
||||||
|
"savePassword": "Сохранить пароль",
|
||||||
|
"changePasswordSuccess": "Пароль обновлен",
|
||||||
|
"changePasswordError": "Не удалось обновить пароль",
|
||||||
"passwordValidationRuleDigit": "содержит цифру",
|
"passwordValidationRuleDigit": "содержит цифру",
|
||||||
"passwordValidationRuleUpperCase": "содержит заглавную букву",
|
"passwordValidationRuleUpperCase": "содержит заглавную букву",
|
||||||
"passwordValidationRuleLowerCase": "содержит строчную букву",
|
"passwordValidationRuleLowerCase": "содержит строчную букву",
|
||||||
|
|||||||
1
frontend/pweb/lib/models/edit_state.dart
Normal file
1
frontend/pweb/lib/models/edit_state.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum EditState { view, edit, saving }
|
||||||
@@ -13,9 +13,15 @@ class ResendCodeButton extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final localizations = AppLocalizations.of(context)!;
|
final localizations = AppLocalizations.of(context)!;
|
||||||
|
final provider = context.watch<TwoFactorProvider>();
|
||||||
|
final isDisabled = provider.isCooldownActive || provider.isResending;
|
||||||
|
|
||||||
|
final label = provider.isCooldownActive
|
||||||
|
? '${localizations.twoFactorResend} (${_formatCooldown(provider.cooldownRemainingSeconds)})'
|
||||||
|
: localizations.twoFactorResend;
|
||||||
|
|
||||||
return TextButton(
|
return TextButton(
|
||||||
onPressed: () => context.read<TwoFactorProvider>().resendCode(),
|
onPressed: isDisabled ? null : () => provider.resendCode(),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
minimumSize: const Size(0, 0),
|
minimumSize: const Size(0, 0),
|
||||||
@@ -26,7 +32,25 @@ class ResendCodeButton extends StatelessWidget {
|
|||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(localizations.twoFactorResend),
|
child: provider.isResending
|
||||||
|
? SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(label),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatCooldown(int seconds) {
|
||||||
|
final minutes = seconds ~/ 60;
|
||||||
|
final remainingSeconds = seconds % 60;
|
||||||
|
if (minutes > 0) {
|
||||||
|
return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
return remainingSeconds.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
//import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/widgets/drawer/avatar.dart';
|
||||||
|
|
||||||
|
|
||||||
class AvatarTile extends StatefulWidget {
|
class AvatarTile extends StatefulWidget {
|
||||||
@@ -28,80 +31,106 @@ class _AvatarTileState extends State<AvatarTile> {
|
|||||||
static const double _avatarSize = 96.0;
|
static const double _avatarSize = 96.0;
|
||||||
static const double _iconSize = 32.0;
|
static const double _iconSize = 32.0;
|
||||||
static const double _titleSpacing = 4.0;
|
static const double _titleSpacing = 4.0;
|
||||||
static const String _placeholderAsset = 'assets/images/avatar_placeholder.png';
|
|
||||||
|
|
||||||
bool _isHovering = false;
|
bool _isHovering = false;
|
||||||
|
bool _isUploading = false;
|
||||||
|
String _errorText = '';
|
||||||
|
|
||||||
|
Future<void> _pickImage(AccountProvider provider) async {
|
||||||
|
if (_isUploading) return;
|
||||||
|
|
||||||
Future<void> _pickImage() async {
|
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final file = await picker.pickImage(source: ImageSource.gallery);
|
final file = await picker.pickImage(source: ImageSource.gallery);
|
||||||
if (file != null) {
|
if (file == null) return;
|
||||||
debugPrint('Selected new avatar: ${file.path}');
|
|
||||||
|
setState(() {
|
||||||
|
_isUploading = true;
|
||||||
|
_errorText = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await provider.uploadAvatar(file);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _errorText = widget.errorText);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(widget.errorText)),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isUploading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loc = AppLocalizations.of(context)!;
|
return Consumer<AccountProvider>(
|
||||||
final safeUrl =
|
builder: (context, provider, _) {
|
||||||
widget.avatarUrl?.trim().isNotEmpty == true ? widget.avatarUrl : null;
|
final theme = Theme.of(context);
|
||||||
final theme = Theme.of(context);
|
final isBusy = _isUploading || provider.isLoading;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
MouseRegion(
|
MouseRegion(
|
||||||
onEnter: (_) => setState(() => _isHovering = true),
|
onEnter: (_) => setState(() => _isHovering = true),
|
||||||
onExit: (_) => setState(() => _isHovering = false),
|
onExit: (_) => setState(() => _isHovering = false),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: _pickImage,
|
onTap: isBusy ? null : () => _pickImage(provider),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
ClipOval(
|
AccountAvatar(
|
||||||
child: safeUrl != null
|
size: _avatarSize,
|
||||||
? Image.network(
|
showHeader: false,
|
||||||
safeUrl,
|
provider: provider,
|
||||||
|
fallbackUrl: widget.avatarUrl,
|
||||||
|
),
|
||||||
|
if (_isHovering || _isUploading)
|
||||||
|
ClipOval(
|
||||||
|
child: Container(
|
||||||
width: _avatarSize,
|
width: _avatarSize,
|
||||||
height: _avatarSize,
|
height: _avatarSize,
|
||||||
fit: BoxFit.cover,
|
color: theme.colorScheme.primary.withAlpha(90),
|
||||||
errorBuilder: (_, _, _) => _buildPlaceholder(),
|
child: _isUploading
|
||||||
)
|
? SizedBox(
|
||||||
: _buildPlaceholder(),
|
width: _iconSize,
|
||||||
),
|
height: _iconSize,
|
||||||
if (_isHovering)
|
child: CircularProgressIndicator(
|
||||||
ClipOval(
|
strokeWidth: 3,
|
||||||
child: Container(
|
valueColor: AlwaysStoppedAnimation(theme.colorScheme.onSecondary),
|
||||||
width: _avatarSize,
|
),
|
||||||
height: _avatarSize,
|
)
|
||||||
color: theme.colorScheme.primary.withAlpha(90),
|
: Icon(
|
||||||
child: Icon(
|
Icons.camera_alt,
|
||||||
Icons.camera_alt,
|
color: theme.colorScheme.onSecondary,
|
||||||
color: theme.colorScheme.onSecondary,
|
size: _iconSize,
|
||||||
size: _iconSize,
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: _titleSpacing),
|
||||||
),
|
Text(
|
||||||
SizedBox(height: _titleSpacing),
|
widget.description,
|
||||||
Text(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
loc.avatarHint,
|
color: theme.colorScheme.onSecondary,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
),
|
||||||
color: theme.colorScheme.onSecondary,
|
),
|
||||||
),
|
if (_errorText.isNotEmpty) ...[
|
||||||
),
|
SizedBox(height: _titleSpacing),
|
||||||
],
|
Text(
|
||||||
);
|
_errorText,
|
||||||
}
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
Widget _buildPlaceholder() {
|
),
|
||||||
return Image.asset(
|
),
|
||||||
_placeholderAsset,
|
],
|
||||||
width: _avatarSize,
|
],
|
||||||
height: _avatarSize,
|
);
|
||||||
fit: BoxFit.cover,
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
|
|
||||||
class AccountName extends StatefulWidget {
|
|
||||||
final String name;
|
|
||||||
final String title;
|
|
||||||
final String hintText;
|
|
||||||
final String errorText;
|
|
||||||
|
|
||||||
const AccountName({
|
|
||||||
super.key,
|
|
||||||
required this.name,
|
|
||||||
required this.title,
|
|
||||||
required this.hintText,
|
|
||||||
required this.errorText,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AccountName> createState() => _AccountNameState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AccountNameState extends State<AccountName> {
|
|
||||||
static const double _inputWidth = 200;
|
|
||||||
static const double _spacing = 8;
|
|
||||||
static const double _errorSpacing = 4;
|
|
||||||
static const double _borderWidth = 2;
|
|
||||||
|
|
||||||
late final TextEditingController _controller;
|
|
||||||
bool _isEditing = false;
|
|
||||||
late String _originalName;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = TextEditingController(text: widget.name);
|
|
||||||
_originalName = widget.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startEditing() => setState(() => _isEditing = true);
|
|
||||||
|
|
||||||
void _cancelEditing() {
|
|
||||||
setState(() {
|
|
||||||
_controller.text = _originalName;
|
|
||||||
_isEditing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _saveEditing() {
|
|
||||||
setState(() {
|
|
||||||
_originalName = _controller.text;
|
|
||||||
_isEditing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (_isEditing)
|
|
||||||
SizedBox(
|
|
||||||
width: _inputWidth,
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _controller,
|
|
||||||
style: theme.textTheme.headlineMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
autofocus: true,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: widget.hintText,
|
|
||||||
isDense: true,
|
|
||||||
border: UnderlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
width: _borderWidth,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Text(
|
|
||||||
_originalName,
|
|
||||||
style: theme.textTheme.headlineMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: _spacing),
|
|
||||||
if (_isEditing) ...[
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.check, color: theme.colorScheme.primary),
|
|
||||||
onPressed: _saveEditing,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.close, color: theme.colorScheme.error),
|
|
||||||
onPressed: _cancelEditing,
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.edit, color: theme.colorScheme.primary),
|
|
||||||
onPressed: _startEditing,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: _errorSpacing),
|
|
||||||
if (widget.errorText.isEmpty)
|
|
||||||
Text(
|
|
||||||
widget.errorText,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/providers/account_name.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AccountNameActions extends StatelessWidget {
|
||||||
|
const AccountNameActions({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.watch<AccountNameState>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
if (state.isEditing) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.check, color: theme.colorScheme.primary),
|
||||||
|
onPressed: state.isBusy
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final wasSaved = await state.save();
|
||||||
|
if (!context.mounted || wasSaved || state.errorText.isEmpty) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(state.errorText)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.close, color: theme.colorScheme.error),
|
||||||
|
onPressed: state.isBusy ? null : state.cancelEditing,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(Icons.edit, color: theme.colorScheme.primary),
|
||||||
|
onPressed: state.isBusy ? null : state.startEditing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/settings/profile/account/name/actions.dart';
|
||||||
|
import 'package:pweb/providers/account_name.dart';
|
||||||
|
import 'package:pweb/pages/settings/profile/account/name/text.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class _AccountNameConstants {
|
||||||
|
static const inputWidth = 200.0;
|
||||||
|
static const spacing = 8.0;
|
||||||
|
static const errorSpacing = 4.0;
|
||||||
|
static const borderWidth = 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountName extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final String title;
|
||||||
|
final String hintText;
|
||||||
|
final String errorText;
|
||||||
|
|
||||||
|
const AccountName({
|
||||||
|
super.key,
|
||||||
|
required this.name,
|
||||||
|
required this.title,
|
||||||
|
required this.hintText,
|
||||||
|
required this.errorText,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ChangeNotifierProvider(
|
||||||
|
create: (ctx) => AccountNameState(
|
||||||
|
initialName: name,
|
||||||
|
errorMessage: errorText,
|
||||||
|
accountProvider: ctx.read<AccountProvider>(),
|
||||||
|
),
|
||||||
|
child: _AccountNameBody(
|
||||||
|
hintText: hintText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountNameBody extends StatelessWidget {
|
||||||
|
const _AccountNameBody({
|
||||||
|
required this.hintText,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String hintText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.watch<AccountNameState>();
|
||||||
|
final provider = context.watch<AccountProvider>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final currentName = provider.account?.name ?? state.initialName;
|
||||||
|
state.syncName(currentName);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
AccountNameText(
|
||||||
|
hintText: hintText,
|
||||||
|
inputWidth: _AccountNameConstants.inputWidth,
|
||||||
|
borderWidth: _AccountNameConstants.borderWidth,
|
||||||
|
),
|
||||||
|
const SizedBox(width: _AccountNameConstants.spacing),
|
||||||
|
const AccountNameActions(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: _AccountNameConstants.errorSpacing),
|
||||||
|
if (state.errorText.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
state.errorText,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/providers/account_name.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AccountNameText extends StatelessWidget {
|
||||||
|
const AccountNameText({
|
||||||
|
super.key,
|
||||||
|
required this.hintText,
|
||||||
|
required this.inputWidth,
|
||||||
|
required this.borderWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String hintText;
|
||||||
|
final double inputWidth;
|
||||||
|
final double borderWidth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = context.watch<AccountNameState>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
if (state.isEditing) {
|
||||||
|
return SizedBox(
|
||||||
|
width: inputWidth,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: state.controller,
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
enabled: !state.isBusy,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
isDense: true,
|
||||||
|
border: UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
width: borderWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
state.currentName,
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/settings/profile/account/password/form/form.dart';
|
||||||
|
import 'package:pweb/providers/password_form.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPasswordContent extends StatelessWidget {
|
||||||
|
const AccountPasswordContent({
|
||||||
|
required this.title,
|
||||||
|
required this.successText,
|
||||||
|
required this.errorText,
|
||||||
|
required this.oldPasswordLabel,
|
||||||
|
required this.newPasswordLabel,
|
||||||
|
required this.confirmPasswordLabel,
|
||||||
|
required this.savePassword,
|
||||||
|
required this.loc,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String successText;
|
||||||
|
final String errorText;
|
||||||
|
final String oldPasswordLabel;
|
||||||
|
final String newPasswordLabel;
|
||||||
|
final String confirmPasswordLabel;
|
||||||
|
final String savePassword;
|
||||||
|
final AppLocalizations loc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Consumer2<AccountProvider, PasswordFormProvider>(
|
||||||
|
builder: (context, accountProvider, formProvider, _) {
|
||||||
|
final isBusy = accountProvider.isLoading || formProvider.isSaving;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: isBusy ? null : formProvider.toggleExpanded,
|
||||||
|
icon: Icon(Icons.lock_outline, color: theme.colorScheme.primary),
|
||||||
|
label: Text(title, style: theme.textTheme.bodyMedium),
|
||||||
|
),
|
||||||
|
if (formProvider.isExpanded)
|
||||||
|
PasswordForm(
|
||||||
|
formProvider: formProvider,
|
||||||
|
accountProvider: accountProvider,
|
||||||
|
isBusy: accountProvider.isLoading,
|
||||||
|
oldPasswordLabel: oldPasswordLabel,
|
||||||
|
newPasswordLabel: newPasswordLabel,
|
||||||
|
confirmPasswordLabel: confirmPasswordLabel,
|
||||||
|
savePassword: savePassword,
|
||||||
|
successText: successText,
|
||||||
|
errorText: errorText,
|
||||||
|
loc: loc,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordErrorText extends StatelessWidget {
|
||||||
|
const PasswordErrorText({
|
||||||
|
super.key,
|
||||||
|
required this.errorText,
|
||||||
|
required this.gapSmall,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String errorText;
|
||||||
|
final double gapSmall;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (errorText.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(height: gapSmall),
|
||||||
|
Text(
|
||||||
|
errorText,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
import 'package:pshared/widgets/password/fields.dart';
|
||||||
|
import 'package:pshared/utils/snackbar.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/providers/password_form.dart';
|
||||||
|
import 'package:pweb/pages/settings/profile/account/password/form/error_text.dart';
|
||||||
|
import 'package:pweb/pages/settings/profile/account/password/form/submit_button.dart';
|
||||||
|
import 'package:pweb/utils/error/snackbar.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordForm extends StatelessWidget {
|
||||||
|
const PasswordForm({
|
||||||
|
super.key,
|
||||||
|
required this.formProvider,
|
||||||
|
required this.accountProvider,
|
||||||
|
required this.isBusy,
|
||||||
|
required this.oldPasswordLabel,
|
||||||
|
required this.newPasswordLabel,
|
||||||
|
required this.confirmPasswordLabel,
|
||||||
|
required this.savePassword,
|
||||||
|
required this.successText,
|
||||||
|
required this.errorText,
|
||||||
|
required this.loc,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const double _fieldWidth = 320;
|
||||||
|
static const double _gapMedium = 12;
|
||||||
|
static const double _gapSmall = 8;
|
||||||
|
|
||||||
|
final PasswordFormProvider formProvider;
|
||||||
|
final AccountProvider accountProvider;
|
||||||
|
final bool isBusy;
|
||||||
|
final String oldPasswordLabel;
|
||||||
|
final String newPasswordLabel;
|
||||||
|
final String confirmPasswordLabel;
|
||||||
|
final String savePassword;
|
||||||
|
final String successText;
|
||||||
|
final String errorText;
|
||||||
|
final AppLocalizations loc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isFormBusy = isBusy || formProvider.isSaving;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: _gapMedium),
|
||||||
|
Form(
|
||||||
|
key: formProvider.formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
PasswordFields(
|
||||||
|
oldPasswordController: formProvider.oldPasswordController,
|
||||||
|
newPasswordController: formProvider.newPasswordController,
|
||||||
|
confirmPasswordController: formProvider.confirmPasswordController,
|
||||||
|
oldPasswordLabel: oldPasswordLabel,
|
||||||
|
newPasswordLabel: newPasswordLabel,
|
||||||
|
confirmPasswordLabel: confirmPasswordLabel,
|
||||||
|
missingPasswordError: loc.errorPasswordMissing,
|
||||||
|
passwordsDoNotMatchError: loc.passwordsDoNotMatch,
|
||||||
|
fieldWidth: _fieldWidth,
|
||||||
|
gapSmall: _gapSmall,
|
||||||
|
isEnabled: !isFormBusy,
|
||||||
|
),
|
||||||
|
const SizedBox(height: _gapMedium),
|
||||||
|
PasswordSubmitButton(
|
||||||
|
isBusy: isFormBusy,
|
||||||
|
label: savePassword,
|
||||||
|
onSubmit: () async {
|
||||||
|
try {
|
||||||
|
await formProvider.submit(
|
||||||
|
accountProvider: accountProvider,
|
||||||
|
errorText: errorText,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
notifyUser(context, successText);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await postNotifyUserOfErrorX(
|
||||||
|
context: context,
|
||||||
|
errorSituation: errorText,
|
||||||
|
exception: e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PasswordErrorText(
|
||||||
|
errorText: formProvider.errorText,
|
||||||
|
gapSmall: _gapSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordSubmitButton extends StatelessWidget {
|
||||||
|
const PasswordSubmitButton({
|
||||||
|
super.key,
|
||||||
|
required this.isBusy,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isBusy;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ElevatedButton.icon(
|
||||||
|
onPressed: isBusy ? null : onSubmit,
|
||||||
|
icon: const Icon(Icons.save_outlined),
|
||||||
|
label: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/settings/profile/account/password/content.dart';
|
||||||
|
import 'package:pweb/providers/password_form.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPassword extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String successText;
|
||||||
|
final String errorText;
|
||||||
|
final String oldPasswordLabel;
|
||||||
|
final String newPasswordLabel;
|
||||||
|
final String confirmPasswordLabel;
|
||||||
|
final String savePassword;
|
||||||
|
|
||||||
|
const AccountPassword({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.successText,
|
||||||
|
required this.errorText,
|
||||||
|
required this.oldPasswordLabel,
|
||||||
|
required this.newPasswordLabel,
|
||||||
|
required this.confirmPasswordLabel,
|
||||||
|
required this.savePassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return ChangeNotifierProvider(
|
||||||
|
create: (_) => PasswordFormProvider(),
|
||||||
|
child: AccountPasswordContent(
|
||||||
|
title: title,
|
||||||
|
successText: successText,
|
||||||
|
errorText: errorText,
|
||||||
|
oldPasswordLabel: oldPasswordLabel,
|
||||||
|
newPasswordLabel: newPasswordLabel,
|
||||||
|
confirmPasswordLabel: confirmPasswordLabel,
|
||||||
|
savePassword: savePassword,
|
||||||
|
loc: loc,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordToggleButton extends StatelessWidget {
|
||||||
|
const PasswordToggleButton({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.isExpanded,
|
||||||
|
required this.isBusy,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final bool isExpanded;
|
||||||
|
final bool isBusy;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final iconColor = theme.colorScheme.primary;
|
||||||
|
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: isBusy
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
onToggle();
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
isExpanded ? Icons.lock_open : Icons.lock_outline,
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
label: Text(title, style: theme.textTheme.bodyMedium),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/settings/profile/account/avatar.dart';
|
import 'package:pweb/pages/settings/profile/account/avatar.dart';
|
||||||
import 'package:pweb/pages/settings/profile/account/locale.dart';
|
import 'package:pweb/pages/settings/profile/account/locale.dart';
|
||||||
import 'package:pweb/pages/settings/profile/account/name.dart';
|
import 'package:pweb/pages/settings/profile/account/name/name.dart';
|
||||||
|
import 'package:pweb/pages/settings/profile/account/password/password.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -18,34 +23,51 @@ class ProfileSettingsPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loc = AppLocalizations.of(context)!;
|
final loc = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final accountName = context.select<AccountProvider, String?>(
|
||||||
|
(provider) => provider.account?.describable.name,
|
||||||
|
);
|
||||||
|
final accountAvatarUrl = context.select<AccountProvider, String?>(
|
||||||
|
(provider) => provider.account?.avatarUrl,
|
||||||
|
);
|
||||||
|
|
||||||
return Material(
|
return Align(
|
||||||
elevation: 4,
|
alignment: Alignment.topCenter,
|
||||||
borderRadius: BorderRadius.circular(_cardRadius),
|
child: Material(
|
||||||
clipBehavior: Clip.antiAlias,
|
elevation: 4,
|
||||||
color: theme.colorScheme.onSecondary,
|
borderRadius: BorderRadius.circular(_cardRadius),
|
||||||
child: Padding(
|
color: theme.colorScheme.onSecondary,
|
||||||
padding: _cardPadding,
|
child: Padding(
|
||||||
child: Column(
|
padding: _cardPadding,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
spacing: _itemSpacing,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
spacing: _itemSpacing,
|
||||||
AvatarTile(
|
children: [
|
||||||
avatarUrl: 'https://avatars.githubusercontent.com/u/65651201',
|
AvatarTile(
|
||||||
title: loc.avatar,
|
avatarUrl: accountAvatarUrl,
|
||||||
description: loc.avatarHint,
|
title: loc.avatar,
|
||||||
errorText: loc.avatarUpdateError,
|
description: loc.avatarHint,
|
||||||
),
|
errorText: loc.avatarUpdateError,
|
||||||
AccountName(
|
),
|
||||||
name: loc.userNamePlaceholder,
|
AccountName(
|
||||||
title: loc.accountName,
|
name: accountName ?? loc.userNamePlaceholder,
|
||||||
hintText: loc.accountNameHint,
|
title: loc.accountName,
|
||||||
errorText: loc.accountNameUpdateError,
|
hintText: loc.accountNameHint,
|
||||||
),
|
errorText: loc.accountNameUpdateError,
|
||||||
LocalePicker(
|
),
|
||||||
title: loc.language,
|
AccountPassword(
|
||||||
),
|
title: loc.changePassword,
|
||||||
],
|
successText: loc.changePasswordSuccess,
|
||||||
|
errorText: loc.changePasswordError,
|
||||||
|
oldPasswordLabel: loc.oldPassword,
|
||||||
|
newPasswordLabel: loc.newPassword,
|
||||||
|
confirmPasswordLabel: loc.confirmPassword,
|
||||||
|
savePassword: loc.savePassword,
|
||||||
|
),
|
||||||
|
LocalePicker(
|
||||||
|
title: loc.language,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/models/edit_state.dart';
|
||||||
import 'package:pweb/utils/error/snackbar.dart';
|
import 'package:pweb/utils/error/snackbar.dart';
|
||||||
|
|
||||||
enum _EditState { view, edit, saving }
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
/// Базовый класс, управляющий состояниями (view/edit/saving),
|
|
||||||
/// показом snackbar ошибок и успешного сохранения.
|
|
||||||
abstract class BaseEditTile<T> extends AbstractSettingsTile {
|
abstract class BaseEditTile<T> extends AbstractSettingsTile {
|
||||||
const BaseEditTile({
|
const BaseEditTile({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -16,6 +17,7 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
|
|||||||
required this.valueGetter,
|
required this.valueGetter,
|
||||||
required this.valueSetter,
|
required this.valueSetter,
|
||||||
required this.errorSituation,
|
required this.errorSituation,
|
||||||
|
this.editStateNotifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
@@ -23,12 +25,10 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
|
|||||||
final ValueGetter<T?> valueGetter;
|
final ValueGetter<T?> valueGetter;
|
||||||
final Future<void> Function(T) valueSetter;
|
final Future<void> Function(T) valueSetter;
|
||||||
final String errorSituation;
|
final String errorSituation;
|
||||||
|
final ValueNotifier<EditState>? editStateNotifier;
|
||||||
|
|
||||||
/// Рисует в режиме просмотра (read-only).
|
|
||||||
Widget buildView(BuildContext context, T? value);
|
Widget buildView(BuildContext context, T? value);
|
||||||
|
|
||||||
/// Рисует UI редактора.
|
|
||||||
/// Если [useDialogEditor]==true, его обернут в диалог.
|
|
||||||
Widget buildEditor(
|
Widget buildEditor(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
T? initial,
|
T? initial,
|
||||||
@@ -37,7 +37,6 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
|
|||||||
bool isSaving,
|
bool isSaving,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// true → показывать редактор в диалоге, false → inline под заголовком.
|
|
||||||
bool get useDialogEditor => false;
|
bool get useDialogEditor => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -52,16 +51,35 @@ class _BaseEditTileBody<T> extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
|
class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
|
||||||
_EditState _state = _EditState.view;
|
late final ValueNotifier<EditState> _stateNotifier;
|
||||||
bool get _isSaving => _state == _EditState.saving;
|
late final bool _ownsNotifier;
|
||||||
|
|
||||||
|
bool get _isSaving => _stateNotifier.value == EditState.saving;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final providedNotifier = widget.delegate.editStateNotifier ??
|
||||||
|
Provider.of<ValueNotifier<EditState>?>(context, listen: false);
|
||||||
|
_ownsNotifier = providedNotifier == null;
|
||||||
|
_stateNotifier = providedNotifier ?? ValueNotifier(EditState.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_ownsNotifier) {
|
||||||
|
_stateNotifier.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _performSave(T newValue) async {
|
Future<void> _performSave(T newValue) async {
|
||||||
final current = widget.delegate.valueGetter();
|
final current = widget.delegate.valueGetter();
|
||||||
if (newValue == current) {
|
if (newValue == current) {
|
||||||
setState(() => _state = _EditState.view);
|
_stateNotifier.value = EditState.view;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _state = _EditState.saving);
|
_stateNotifier.value = EditState.saving;
|
||||||
final sms = ScaffoldMessenger.of(context);
|
final sms = ScaffoldMessenger.of(context);
|
||||||
final locs = AppLocalizations.of(context)!;
|
final locs = AppLocalizations.of(context)!;
|
||||||
try {
|
try {
|
||||||
@@ -78,7 +96,7 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
|
|||||||
exception: e,
|
exception: e,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _state = _EditState.view);
|
if (mounted) _stateNotifier.value = EditState.view;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,33 +128,45 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final delegate = widget.delegate;
|
final delegate = widget.delegate;
|
||||||
final current = delegate.valueGetter();
|
|
||||||
|
|
||||||
// Диалоговый режим
|
|
||||||
if (delegate.useDialogEditor) {
|
if (delegate.useDialogEditor) {
|
||||||
return SettingsTile.navigation(
|
return ValueListenableBuilder<EditState>(
|
||||||
leading: Icon(delegate.icon),
|
valueListenable: _stateNotifier,
|
||||||
title: Text(delegate.title),
|
builder: (context, state, _) {
|
||||||
value: delegate.buildView(context, current),
|
final current = delegate.valueGetter();
|
||||||
onPressed: (_) => _openDialogEditor(),
|
return SettingsTile.navigation(
|
||||||
|
leading: Icon(delegate.icon),
|
||||||
|
title: Text(delegate.title),
|
||||||
|
value: delegate.buildView(context, current),
|
||||||
|
onPressed: state == EditState.saving ? null : (_) => _openDialogEditor(),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline-режим (под заголовком будет редактор прямо в tile)
|
return ValueListenableBuilder<EditState>(
|
||||||
return SettingsTile.navigation(
|
valueListenable: _stateNotifier,
|
||||||
leading: Icon(delegate.icon),
|
builder: (context, state, _) {
|
||||||
title: Text(delegate.title),
|
final current = delegate.valueGetter();
|
||||||
value: _state == _EditState.view
|
final isView = state == EditState.view;
|
||||||
? delegate.buildView(context, current)
|
final isSaving = state == EditState.saving;
|
||||||
: delegate.buildEditor(
|
|
||||||
context,
|
return SettingsTile.navigation(
|
||||||
current,
|
leading: Icon(delegate.icon),
|
||||||
_performSave,
|
title: Text(delegate.title),
|
||||||
() => setState(() => _state = _EditState.view),
|
value: isView
|
||||||
_isSaving,
|
? delegate.buildView(context, current)
|
||||||
),
|
: delegate.buildEditor(
|
||||||
onPressed: (_) {
|
context,
|
||||||
if (_state == _EditState.view) setState(() => _state = _EditState.edit);
|
current,
|
||||||
|
_performSave,
|
||||||
|
() => _stateNotifier.value = EditState.view,
|
||||||
|
isSaving,
|
||||||
|
),
|
||||||
|
onPressed: (_) {
|
||||||
|
if (isView) _stateNotifier.value = EditState.edit;
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
94
frontend/pweb/lib/providers/account_name.dart
Normal file
94
frontend/pweb/lib/providers/account_name.dart
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/models/edit_state.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AccountNameState extends ChangeNotifier {
|
||||||
|
AccountNameState({
|
||||||
|
required this.initialName,
|
||||||
|
required this.errorMessage,
|
||||||
|
required AccountProvider accountProvider,
|
||||||
|
}) : _accountProvider = accountProvider {
|
||||||
|
_controller = TextEditingController(text: initialName);
|
||||||
|
}
|
||||||
|
|
||||||
|
final AccountProvider _accountProvider;
|
||||||
|
final String initialName;
|
||||||
|
final String errorMessage;
|
||||||
|
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
EditState _editState = EditState.view;
|
||||||
|
String _errorText = '';
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
TextEditingController get controller => _controller;
|
||||||
|
EditState get editState => _editState;
|
||||||
|
String get errorText => _errorText;
|
||||||
|
bool get isEditing => _editState != EditState.view;
|
||||||
|
bool get isSaving => _editState == EditState.saving;
|
||||||
|
bool get isBusy => _accountProvider.isLoading || isSaving;
|
||||||
|
String get currentName => _accountProvider.account?.name ?? initialName;
|
||||||
|
|
||||||
|
void startEditing() => _setState(EditState.edit);
|
||||||
|
|
||||||
|
void cancelEditing() {
|
||||||
|
_controller.text = currentName;
|
||||||
|
_setError('');
|
||||||
|
_setState(EditState.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
void syncName(String latestName) {
|
||||||
|
if (isEditing) return;
|
||||||
|
if (_controller.text != latestName) {
|
||||||
|
_controller.text = latestName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> save() async {
|
||||||
|
final newName = _controller.text.trim();
|
||||||
|
final current = currentName;
|
||||||
|
|
||||||
|
if (newName.isEmpty || newName == current) {
|
||||||
|
cancelEditing();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setError('');
|
||||||
|
_setState(EditState.saving);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _accountProvider.resetUsername(newName);
|
||||||
|
_setState(EditState.view);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
_setError(errorMessage);
|
||||||
|
_setState(EditState.edit);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (_editState == EditState.saving) {
|
||||||
|
_setState(EditState.edit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setState(EditState value) {
|
||||||
|
if (_disposed || _editState == value) return;
|
||||||
|
_editState = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setError(String value) {
|
||||||
|
if (_disposed) return;
|
||||||
|
_errorText = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
76
frontend/pweb/lib/providers/password_form.dart
Normal file
76
frontend/pweb/lib/providers/password_form.dart
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/models/edit_state.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordFormProvider extends ChangeNotifier {
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
final oldPasswordController = TextEditingController();
|
||||||
|
final newPasswordController = TextEditingController();
|
||||||
|
final confirmPasswordController = TextEditingController();
|
||||||
|
|
||||||
|
EditState _state = EditState.view;
|
||||||
|
String _errorText = '';
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
bool get isExpanded => _state != EditState.view;
|
||||||
|
bool get isSaving => _state == EditState.saving;
|
||||||
|
String get errorText => _errorText;
|
||||||
|
EditState get state => _state;
|
||||||
|
|
||||||
|
void toggleExpanded() {
|
||||||
|
if (_state == EditState.saving) return;
|
||||||
|
_setState(_state == EditState.view ? EditState.edit : EditState.view);
|
||||||
|
_setError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> submit({
|
||||||
|
required AccountProvider accountProvider,
|
||||||
|
required String errorText,
|
||||||
|
}) async {
|
||||||
|
final currentForm = formKey.currentState;
|
||||||
|
if (currentForm == null || !currentForm.validate()) return;
|
||||||
|
|
||||||
|
_setState(EditState.saving);
|
||||||
|
_setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await accountProvider.changePassword(
|
||||||
|
oldPasswordController.text,
|
||||||
|
newPasswordController.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
oldPasswordController.clear();
|
||||||
|
newPasswordController.clear();
|
||||||
|
confirmPasswordController.clear();
|
||||||
|
} catch (e) {
|
||||||
|
_setError(errorText);
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_setState(EditState.edit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setState(EditState value) {
|
||||||
|
if (_state == value || _disposed) return;
|
||||||
|
_state = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setError(String value) {
|
||||||
|
if (_disposed) return;
|
||||||
|
_errorText = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
oldPasswordController.dispose();
|
||||||
|
newPasswordController.dispose();
|
||||||
|
confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -14,15 +16,21 @@ class TwoFactorProvider extends ChangeNotifier {
|
|||||||
TwoFactorProvider();
|
TwoFactorProvider();
|
||||||
|
|
||||||
bool _isSubmitting = false;
|
bool _isSubmitting = false;
|
||||||
|
bool _isResending = false;
|
||||||
bool _hasError = false;
|
bool _hasError = false;
|
||||||
bool _verificationSuccess = false;
|
bool _verificationSuccess = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
String? _currentPendingToken;
|
String? _currentPendingToken;
|
||||||
|
Timer? _cooldownTimer;
|
||||||
|
int _cooldownRemainingSeconds = 0;
|
||||||
|
|
||||||
bool get isSubmitting => _isSubmitting;
|
bool get isSubmitting => _isSubmitting;
|
||||||
|
bool get isResending => _isResending;
|
||||||
bool get hasError => _hasError;
|
bool get hasError => _hasError;
|
||||||
bool get verificationSuccess => _verificationSuccess;
|
bool get verificationSuccess => _verificationSuccess;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
int get cooldownRemainingSeconds => _cooldownRemainingSeconds;
|
||||||
|
bool get isCooldownActive => _cooldownRemainingSeconds > 0;
|
||||||
PendingLogin? get pendingLogin => _accountProvider.pendingLogin;
|
PendingLogin? get pendingLogin => _accountProvider.pendingLogin;
|
||||||
|
|
||||||
void update(AccountProvider accountProvider) {
|
void update(AccountProvider accountProvider) {
|
||||||
@@ -33,6 +41,7 @@ class TwoFactorProvider extends ChangeNotifier {
|
|||||||
_resetState();
|
_resetState();
|
||||||
_currentPendingToken = token;
|
_currentPendingToken = token;
|
||||||
}
|
}
|
||||||
|
_syncCooldown(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> submitCode(String code) async {
|
Future<void> submitCode(String code) async {
|
||||||
@@ -70,12 +79,23 @@ class TwoFactorProvider extends ChangeNotifier {
|
|||||||
_logger.warning('No pending login to resend code for');
|
_logger.warning('No pending login to resend code for');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_isResending || isCooldownActive) return;
|
||||||
|
|
||||||
|
_isResending = true;
|
||||||
|
_hasError = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await VerificationService.resendLoginCode(pending);
|
final confirmation = await VerificationService.resendLoginCode(pending);
|
||||||
|
_accountProvider.updatePendingLogin(confirmation);
|
||||||
|
_startCooldown(confirmation.cooldownSeconds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.warning('Failed to resend login code', e);
|
_logger.warning('Failed to resend login code', e);
|
||||||
_hasError = true;
|
_hasError = true;
|
||||||
_errorMessage = e.toString();
|
_errorMessage = e.toString();
|
||||||
|
} finally {
|
||||||
|
_isResending = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,9 +107,71 @@ class TwoFactorProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
void _resetState() {
|
void _resetState() {
|
||||||
_isSubmitting = false;
|
_isSubmitting = false;
|
||||||
|
_isResending = false;
|
||||||
_hasError = false;
|
_hasError = false;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_verificationSuccess = false;
|
_verificationSuccess = false;
|
||||||
|
_stopCooldown();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncCooldown(PendingLogin? pending) {
|
||||||
|
if (pending == null) {
|
||||||
|
_stopCooldown(notify: _cooldownRemainingSeconds != 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final remaining = pending.cooldownRemainingSeconds;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
_stopCooldown(notify: _cooldownRemainingSeconds != 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_cooldownRemainingSeconds != remaining) {
|
||||||
|
_startCooldown(remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startCooldown(int seconds) {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownRemainingSeconds = seconds;
|
||||||
|
|
||||||
|
if (_cooldownRemainingSeconds <= 0) {
|
||||||
|
_cooldownTimer = null;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (_cooldownRemainingSeconds <= 1) {
|
||||||
|
_cooldownRemainingSeconds = 0;
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cooldownRemainingSeconds -= 1;
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopCooldown({bool notify = false}) {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
final hadCooldown = _cooldownRemainingSeconds != 0;
|
||||||
|
_cooldownRemainingSeconds = 0;
|
||||||
|
|
||||||
|
if (notify && hadCooldown) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stopCooldown();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,49 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
|
|||||||
|
|
||||||
|
|
||||||
class AccountAvatar extends StatelessWidget {
|
class AccountAvatar extends StatelessWidget {
|
||||||
const AccountAvatar({super.key});
|
final double? size;
|
||||||
|
final bool showHeader;
|
||||||
|
final String? fallbackUrl;
|
||||||
|
final AccountProvider? provider;
|
||||||
|
|
||||||
|
const AccountAvatar({
|
||||||
|
super.key,
|
||||||
|
this.size,
|
||||||
|
this.showHeader = true,
|
||||||
|
this.fallbackUrl,
|
||||||
|
this.provider,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loc = AppLocalizations.of(context)!;
|
if (provider != null) {
|
||||||
|
return _buildAvatar(context, provider!);
|
||||||
|
}
|
||||||
|
|
||||||
return Consumer<AccountProvider>(
|
return Consumer<AccountProvider>(
|
||||||
builder: (context, provider, _) => UserAccountsDrawerHeader(
|
builder: (context, provider, _) => _buildAvatar(context, provider),
|
||||||
accountName: Text(provider.account?.name ?? loc.userNamePlaceholder),
|
);
|
||||||
accountEmail: Text(provider.account?.login ?? loc.usernameHint),
|
}
|
||||||
currentAccountPicture: CircleAvatar(
|
|
||||||
backgroundImage: (provider.account?.avatarUrl?.isNotEmpty ?? false)
|
Widget _buildAvatar(BuildContext context, AccountProvider provider) {
|
||||||
? CachedNetworkImageProvider(provider.account!.avatarUrl!)
|
final avatarUrl = (provider.account?.avatarUrl ?? fallbackUrl)?.trim();
|
||||||
: null,
|
final hasAvatar = avatarUrl?.isNotEmpty == true;
|
||||||
child: (provider.account?.avatarUrl?.isNotEmpty ?? false)
|
final radius = size != null ? size! / 2 : null;
|
||||||
? null
|
final double placeholderIconSize = size != null ? size! * 0.55 : 50;
|
||||||
: const Icon(Icons.account_circle, size: 50),
|
|
||||||
),
|
final avatar = CircleAvatar(
|
||||||
),
|
radius: radius,
|
||||||
|
backgroundImage: hasAvatar ? CachedNetworkImageProvider(avatarUrl!) : null,
|
||||||
|
child: hasAvatar ? null : Icon(Icons.account_circle, size: placeholderIconSize),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showHeader) return avatar;
|
||||||
|
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
return UserAccountsDrawerHeader(
|
||||||
|
accountName: Text(provider.account?.describable.name ?? loc.userNamePlaceholder),
|
||||||
|
accountEmail: Text(provider.account?.login ?? loc.usernameHint),
|
||||||
|
currentAccountPicture: avatar,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/account.dart';
|
||||||
|
|
||||||
import 'package:pweb/widgets/sidebar/destinations.dart';
|
import 'package:pweb/widgets/sidebar/destinations.dart';
|
||||||
import 'package:pweb/widgets/sidebar/side_menu.dart';
|
import 'package:pweb/widgets/sidebar/side_menu.dart';
|
||||||
import 'package:pweb/widgets/sidebar/user.dart';
|
import 'package:pweb/widgets/sidebar/user.dart';
|
||||||
@@ -18,7 +22,7 @@ class PayoutSidebar extends StatelessWidget {
|
|||||||
|
|
||||||
final PayoutDestination selected;
|
final PayoutDestination selected;
|
||||||
final ValueChanged<PayoutDestination> onSelected;
|
final ValueChanged<PayoutDestination> onSelected;
|
||||||
final VoidCallback? onLogout;
|
final Future<void> Function()? onLogout;
|
||||||
|
|
||||||
final String? userName;
|
final String? userName;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
@@ -27,6 +31,15 @@ class PayoutSidebar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final accountName = context.select<AccountProvider, String?>(
|
||||||
|
(provider) => provider.account?.describable.name,
|
||||||
|
);
|
||||||
|
final accountAvatar = context.select<AccountProvider, String?>(
|
||||||
|
(provider) => provider.account?.avatarUrl,
|
||||||
|
);
|
||||||
|
final resolvedUserName = userName ?? accountName;
|
||||||
|
final resolvedAvatarUrl = avatarUrl ?? accountAvatar;
|
||||||
|
|
||||||
final menuItems = items ??
|
final menuItems = items ??
|
||||||
<PayoutDestination>[
|
<PayoutDestination>[
|
||||||
PayoutDestination.dashboard,
|
PayoutDestination.dashboard,
|
||||||
@@ -42,16 +55,16 @@ class PayoutSidebar extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
UserProfileCard(
|
UserProfileCard(
|
||||||
theme: theme,
|
theme: theme,
|
||||||
avatarUrl: avatarUrl,
|
avatarUrl: resolvedAvatarUrl,
|
||||||
userName: userName,
|
userName: resolvedUserName,
|
||||||
selected: selected,
|
selected: selected,
|
||||||
onSelected: onSelected
|
onSelected: onSelected
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SideMenuColumn(
|
SideMenuColumn(
|
||||||
theme: theme,
|
theme: theme,
|
||||||
avatarUrl: avatarUrl,
|
avatarUrl: resolvedAvatarUrl,
|
||||||
userName: userName,
|
userName: resolvedUserName,
|
||||||
items: menuItems,
|
items: menuItems,
|
||||||
selected: selected,
|
selected: selected,
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
|
|||||||
Reference in New Issue
Block a user