From 72553dbc2f81cc7e5144123a7e4d53ae27cb939a Mon Sep 17 00:00:00 2001 From: mytja Date: Sun, 24 Dec 2023 00:23:02 +0100 Subject: [PATCH] random big update --- backend/internal/consts/consts.go | 61 + backend/internal/httphandlers/auth.go | 145 ++- backend/internal/httphandlers/helpers.go | 17 + .../internal/httphandlers/implementation.go | 5 + backend/internal/httphandlers/tournament.go | 289 +++++ backend/internal/sql/schema.go | 37 + backend/internal/sql/sql.go | 23 + backend/internal/sql/tournament.go | 68 ++ .../internal/sql/tournament_participant.go | 56 + backend/internal/sql/tournament_round.go | 114 ++ backend/internal/sql/user.go | 4 +- backend/internal/ws/cards.go | 21 +- backend/internal/ws/gamestart.go | 1 + backend/internal/ws/logaritmi.go | 44 + backend/internal/ws/rating.go | 103 ++ backend/internal/ws/results.go | 33 + backend/internal/ws/types.go | 4 + backend/main.go | 5 + tarok/Dockerfile | 2 +- tarok/lib/game/game_controller.dart | 6 + tarok/lib/internationalization/languages.dart | 56 +- tarok/lib/lobby/lobby.dart | 1024 +++++++++-------- tarok/lib/lobby/lobby_controller.dart | 17 +- tarok/lib/main.dart | 6 +- tarok/lib/ui/main_page.dart | 8 + tarok/pubspec.lock | 8 + tarok/pubspec.yaml | 1 + 27 files changed, 1616 insertions(+), 542 deletions(-) create mode 100644 backend/internal/httphandlers/helpers.go create mode 100644 backend/internal/httphandlers/tournament.go create mode 100644 backend/internal/sql/tournament.go create mode 100644 backend/internal/sql/tournament_participant.go create mode 100644 backend/internal/sql/tournament_round.go create mode 100644 backend/internal/ws/rating.go diff --git a/backend/internal/consts/consts.go b/backend/internal/consts/consts.go index baab227..2663590 100644 --- a/backend/internal/consts/consts.go +++ b/backend/internal/consts/consts.go @@ -81,6 +81,67 @@ var CARDS = []Card{ {File: "/taroki/skis", Worth: 5, WorthOver: 32, Alt: "Škis"}, } +var CARDS_MAP = map[string]Card{ + "/kara/1": CARDS[0], + "/kara/2": CARDS[1], + "/kara/3": CARDS[2], + "/kara/4": CARDS[3], + "/kara/pob": CARDS[4], + "/kara/kaval": CARDS[5], + "/kara/dama": CARDS[6], + "/kara/kralj": CARDS[7], + + "/kriz/7": CARDS[8], + "/kriz/8": CARDS[9], + "/kriz/9": CARDS[10], + "/kriz/10": CARDS[11], + "/kriz/pob": CARDS[12], + "/kriz/kaval": CARDS[13], + "/kriz/dama": CARDS[14], + "/kriz/kralj": CARDS[15], + + "/pik/7": CARDS[16], + "/pik/8": CARDS[17], + "/pik/9": CARDS[18], + "/pik/10": CARDS[19], + "/pik/pob": CARDS[20], + "/pik/kaval": CARDS[21], + "/pik/dama": CARDS[22], + "/pik/kralj": CARDS[23], + + "/src/1": CARDS[24], + "/src/2": CARDS[25], + "/src/3": CARDS[26], + "/src/4": CARDS[27], + "/src/pob": CARDS[28], + "/src/kaval": CARDS[29], + "/src/dama": CARDS[30], + "/src/kralj": CARDS[31], + + "/taroki/pagat": CARDS[32], + "/taroki/2": CARDS[33], + "/taroki/3": CARDS[34], + "/taroki/4": CARDS[35], + "/taroki/5": CARDS[36], + "/taroki/6": CARDS[37], + "/taroki/7": CARDS[38], + "/taroki/8": CARDS[39], + "/taroki/9": CARDS[40], + "/taroki/10": CARDS[41], + "/taroki/11": CARDS[42], + "/taroki/12": CARDS[43], + "/taroki/13": CARDS[44], + "/taroki/14": CARDS[45], + "/taroki/15": CARDS[46], + "/taroki/16": CARDS[47], + "/taroki/17": CARDS[48], + "/taroki/18": CARDS[49], + "/taroki/19": CARDS[50], + "/taroki/20": CARDS[51], + "/taroki/mond": CARDS[52], + "/taroki/skis": CARDS[53], +} + func GetCardByID(id string) (Card, error) { for _, v := range CARDS { if v.File == id { diff --git a/backend/internal/httphandlers/auth.go b/backend/internal/httphandlers/auth.go index ae43b76..9502077 100644 --- a/backend/internal/httphandlers/auth.go +++ b/backend/internal/httphandlers/auth.go @@ -19,6 +19,79 @@ type TokenResponse struct { Name string `json:"name"` } +func (s *httpImpl) SendRegistrationMail(w http.ResponseWriter, email string) { + user, err := s.db.GetUserByEmail(email) + if err != nil { + s.sugared.Errorw("failed while retrieving the user from the database", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + user.EmailSentOn = int(time.Now().Unix()) + err = s.db.UpdateUser(user) + if err != nil { + s.sugared.Errorw("failed while updating the user", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + smtp := mail.NewSMTPClient() + smtp.Host = s.config.EmailServer + smtp.Port = s.config.EmailPort + smtp.Username = s.config.EmailAccount + smtp.Password = s.config.EmailPassword + smtp.Encryption = mail.EncryptionTLS + + smtpClient, err := smtp.Connect() + if err != nil { + s.sugared.Error(err) + } + + // Create email + msg := mail.NewMSG() + msg.SetFrom("Palčka ") + msg.AddTo(email) + msg.SetSubject("Registracija na tarok strežniku palcka.si") + + emailConfirmationText := fmt.Sprintf(` + + + + Tarok Palčka + + +
+

Registracija na Tarok strežniku Palčka

+ Uradna instanca palcka.si +

+ Zahvaljujemo se vam za registracijo na tarok strežniku Palčka. + Vaša registracijska koda je:

+ %s +

+ +

+ + Vaš račun lahko potrdite z obiskom strani https://palcka.si/email/confirm?email=%s®Code=%s. Pri tem se prepričajte, da vas pelje na uradno stran (na domeni palcka.si). + +

+ + Še enkrat hvala za registracijo in obilo užitka ob igri taroka vam želi + +

+ + Ekipa palcka.si + + +`, user.EmailConfirmation, user.Email, user.EmailConfirmation, user.Email, user.EmailConfirmation) + + msg.SetBody(mail.TextHTML, emailConfirmationText) + + err = msg.Send(smtpClient) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } +} + func (s *httpImpl) Registration(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") pass := r.FormValue("pass") @@ -83,7 +156,7 @@ func (s *httpImpl) Registration(w http.ResponseWriter, r *http.Request) { n := time.Now() user := sql.User{ - Email: r.FormValue("email"), + Email: email, Password: password, Role: role, Name: name, @@ -92,6 +165,7 @@ func (s *httpImpl) Registration(w http.ResponseWriter, r *http.Request) { Disabled: false, PasswordResetToken: "", PasswordResetInitiatedOn: n.Format(time.RFC3339), + EmailSentOn: 0, } err = s.db.InsertUser(user) @@ -101,64 +175,7 @@ func (s *httpImpl) Registration(w http.ResponseWriter, r *http.Request) { return } - go func() { - smtp := mail.NewSMTPClient() - smtp.Host = s.config.EmailServer - smtp.Port = s.config.EmailPort - smtp.Username = s.config.EmailAccount - smtp.Password = s.config.EmailPassword - smtp.Encryption = mail.EncryptionTLS - - smtpClient, err := smtp.Connect() - if err != nil { - s.sugared.Error(err) - } - - // Create email - msg := mail.NewMSG() - msg.SetFrom("Palčka ") - msg.AddTo(email) - msg.SetSubject("Registracija na tarok strežniku palcka.si") - - emailConfirmationText := fmt.Sprintf(` - - - - Tarok Palčka - - -

-

Registracija na Tarok strežniku Palčka

- Uradna instanca palcka.si -

- Zahvaljujemo se vam za registracijo na tarok strežniku Palčka. - Vaša registracijska koda je:

- %s -

- -

- - Vaš račun lahko potrdite z obiskom strani https://palcka.si/email/confirm?email=%s®Code=%s. Pri tem se prepričajte, da vas pelje na uradno stran (na domeni palcka.si). - -

- - Še enkrat hvala za registracijo in obilo užitka ob igri taroka vam želi - -

- - Ekipa palcka.si - - -`, emailConfirmationPassword, email, emailConfirmationPassword, email, emailConfirmationPassword) - - msg.SetBody(mail.TextHTML, emailConfirmationText) - - err = msg.Send(smtpClient) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - }() + go s.SendRegistrationMail(w, email) w.WriteHeader(http.StatusCreated) } @@ -179,11 +196,19 @@ func (s *httpImpl) Login(w http.ResponseWriter, r *http.Request) { return } - if user.Disabled || !user.EmailConfirmed { + if user.Disabled { w.WriteHeader(http.StatusForbidden) return } + if !user.EmailConfirmed { + if (user.EmailSentOn + 300) < int(time.Now().Unix()) { + go s.SendRegistrationMail(w, email) + } + w.WriteHeader(http.StatusAccepted) + return + } + var token string if user.LoginToken == "" { token, err = s.db.GetRandomToken(user) diff --git a/backend/internal/httphandlers/helpers.go b/backend/internal/httphandlers/helpers.go new file mode 100644 index 0000000..601cd1f --- /dev/null +++ b/backend/internal/httphandlers/helpers.go @@ -0,0 +1,17 @@ +package httphandlers + +import ( + "encoding/json" + "net/http" +) + +func DumpJSON(jsonstruct interface{}) []byte { + marshal, _ := json.Marshal(jsonstruct) + return marshal +} + +func WriteJSON(w http.ResponseWriter, jsonstruct interface{}, statusCode int) { + w.WriteHeader(statusCode) + w.Header().Set("Content-Type", "application/json") + w.Write(DumpJSON(jsonstruct)) +} diff --git a/backend/internal/httphandlers/implementation.go b/backend/internal/httphandlers/implementation.go index 03f7559..13407ee 100644 --- a/backend/internal/httphandlers/implementation.go +++ b/backend/internal/httphandlers/implementation.go @@ -30,6 +30,11 @@ type HTTPHandler interface { Logout(w http.ResponseWriter, r *http.Request) RequestPasswordReset(w http.ResponseWriter, r *http.Request) PasswordResetConfirm(w http.ResponseWriter, r *http.Request) + GetAllTournaments(w http.ResponseWriter, r *http.Request) + GetUpcomingTournaments(w http.ResponseWriter, r *http.Request) + NewTournament(w http.ResponseWriter, r *http.Request) + UpdateTournament(w http.ResponseWriter, r *http.Request) + DeleteTournament(w http.ResponseWriter, r *http.Request) } func NewHTTPHandler(db sql.SQL, config *consts.ServerConfig, sugared *zap.SugaredLogger) HTTPHandler { diff --git a/backend/internal/httphandlers/tournament.go b/backend/internal/httphandlers/tournament.go new file mode 100644 index 0000000..41e8e72 --- /dev/null +++ b/backend/internal/httphandlers/tournament.go @@ -0,0 +1,289 @@ +package httphandlers + +import ( + "encoding/json" + "github.com/mytja/Tarok/backend/internal/sql" + "goji.io/pat" + "net/http" + "strconv" + "time" +) + +type Tournament struct { + ID string `json:"id"` + CreatedBy []string `json:"created_by"` + Name string `json:"name"` + StartTime int `json:"start_time"` + Division int `json:"division"` + Rated bool `json:"rated"` + Private bool `json:"private"` + Registered bool `json:"registered"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (s *httpImpl) GetAllTournaments(w http.ResponseWriter, r *http.Request) { + user, err := s.db.CheckToken(r) + if err != nil { + w.WriteHeader(http.StatusForbidden) + return + } + + if user.Role != "admin" { + w.WriteHeader(http.StatusForbidden) + return + } + + tournaments, err := s.db.GetAllTournaments() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + t := make([]Tournament, 0) + for _, v := range tournaments { + var authors []string + err := json.Unmarshal([]byte(v.CreatedBy), &authors) + if err != nil { + continue + } + t = append(t, Tournament{ + ID: v.ID, + CreatedBy: authors, + Name: v.Name, + StartTime: v.StartTime, + Division: v.Division, + Rated: v.Rated, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + Private: v.Private, + }) + } + + WriteJSON(w, t, http.StatusOK) +} + +func (s *httpImpl) GetUpcomingTournaments(w http.ResponseWriter, r *http.Request) { + user, err := s.db.CheckToken(r) + if err != nil { + w.WriteHeader(http.StatusForbidden) + return + } + + tournaments, err := s.db.GetAllNotStartedTournaments() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + t := make([]Tournament, 0) + for _, v := range tournaments { + var authors []string + err := json.Unmarshal([]byte(v.CreatedBy), &authors) + if err != nil { + continue + } + _, err = s.db.GetTournamentParticipantByTournamentUser(v.ID, user.ID) + registered := err == nil + t = append(t, Tournament{ + ID: v.ID, + CreatedBy: authors, + Name: v.Name, + StartTime: v.StartTime, + Division: v.Division, + Rated: v.Rated, + Registered: registered, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + Private: v.Private, + }) + } + + WriteJSON(w, t, http.StatusOK) +} + +func (s *httpImpl) NewTournament(w http.ResponseWriter, r *http.Request) { + user, err := s.db.CheckToken(r) + if err != nil { + w.WriteHeader(http.StatusForbidden) + return + } + + if user.Role != "admin" { + w.WriteHeader(http.StatusForbidden) + return + } + + //tournamentId := pat.Param(r, "tournamentId") + + createdBy, err := json.Marshal([]string{user.Name}) + if err != nil { + s.sugared.Errorw("error while marshaling createdBy", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + name := r.FormValue("name") + if name == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + startTime, err := strconv.Atoi(r.FormValue("start_time")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + if startTime < int(time.Now().Unix()) { + w.WriteHeader(http.StatusBadRequest) + return + } + + division, err := strconv.Atoi(r.FormValue("division")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + if division < 1 || division > 4 { + w.WriteHeader(http.StatusBadRequest) + return + } + + tournament := sql.Tournament{ + CreatedBy: string(createdBy), + Name: name, + StartTime: startTime, + Division: division, + Rated: true, + Private: true, + } + + err = s.db.InsertTournament(tournament) + if err != nil { + s.sugared.Errorw("error while inserting tournament", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (s *httpImpl) UpdateTournament(w http.ResponseWriter, r *http.Request) { + user, err := s.db.CheckToken(r) + if err != nil { + w.WriteHeader(http.StatusForbidden) + return + } + + if user.Role != "admin" { + w.WriteHeader(http.StatusForbidden) + return + } + + tournamentId := pat.Param(r, "tournamentId") + tournament, err := s.db.GetTournament(tournamentId) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + var createdBy []string + err = json.Unmarshal([]byte(r.FormValue("created_by")), &createdBy) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + marshal, err := json.Marshal(createdBy) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + tournament.CreatedBy = string(marshal) + + name := r.FormValue("name") + if name != "" { + tournament.Name = name + } + + startTime, err := strconv.Atoi(r.FormValue("start_time")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + if startTime < int(time.Now().Unix()) { + w.WriteHeader(http.StatusBadRequest) + return + } + + tournament.StartTime = startTime + + division, err := strconv.Atoi(r.FormValue("division")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + if division < 1 || division > 4 { + w.WriteHeader(http.StatusBadRequest) + return + } + + tournament.Division = division + + rated, err := strconv.ParseBool(r.FormValue("rated")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + tournament.Rated = rated + + private, err := strconv.ParseBool(r.FormValue("private")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + tournament.Private = private + + err = s.db.UpdateTournament(tournament) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (s *httpImpl) DeleteTournament(w http.ResponseWriter, r *http.Request) { + user, err := s.db.CheckToken(r) + if err != nil { + w.WriteHeader(http.StatusForbidden) + return + } + + if user.Role != "admin" { + w.WriteHeader(http.StatusForbidden) + return + } + + tournamentId := pat.Param(r, "tournamentId") + _, err = s.db.GetTournament(tournamentId) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = s.db.DeleteTournament(tournamentId) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/backend/internal/sql/schema.go b/backend/internal/sql/schema.go index 3635501..65782ec 100644 --- a/backend/internal/sql/schema.go +++ b/backend/internal/sql/schema.go @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS users ( disabled BOOLEAN, password_reset_token VARCHAR(250), password_reset_initiated_on TIMESTAMP, + email_sent_on BIGINT, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() @@ -50,6 +51,42 @@ CREATE TABLE IF NOT EXISTS friends ( created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); +CREATE TABLE IF NOT EXISTS tournament ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by VARCHAR(250) NOT NULL, + name VARCHAR(250) NOT NULL, + start_time BIGINT NOT NULL, + division SMALLINT NOT NULL, + rated BOOLEAN NOT NULL, + private BOOLEAN NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS tournament_participant ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tournament_id VARCHAR(250) NOT NULL, + user_id VARCHAR(250) NOT NULL, + rated BOOLEAN NOT NULL, + rating_delta INTEGER DEFAULT 0, + + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS tournament_round ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tournament_id VARCHAR(250) NOT NULL, + cards1 JSON NOT NULL, + cards2 JSON NOT NULL, + cards3 JSON NOT NULL, + cards4 JSON NOT NULL, + talon JSON NOT NULL, + time INTEGER NOT NULL, + round_number INTEGER NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); CREATE OR REPLACE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE PROCEDURE update_changetimestamp_column(); diff --git a/backend/internal/sql/sql.go b/backend/internal/sql/sql.go index e056fd3..02d3af8 100644 --- a/backend/internal/sql/sql.go +++ b/backend/internal/sql/sql.go @@ -4,6 +4,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" + "github.com/mytja/Tarok/backend/internal/consts" "go.uber.org/zap" "net/http" ) @@ -52,6 +53,28 @@ type SQL interface { GetGamesByUserID(id string) (games []Game, err error) InsertGame(game Game) (err error) GetGames() (games []Game, err error) + + GetTournament(id string) (tournament Tournament, err error) + InsertTournament(tournament Tournament) (err error) + GetAllTournaments() (tournament []Tournament, err error) + GetAllNotStartedTournaments() (tournament []Tournament, err error) + UpdateTournament(tournament Tournament) error + DeleteTournament(id string) error + + GetTournamentParticipant(id string) (tournamentParticipant TournamentParticipant, err error) + GetTournamentParticipantByTournamentUser(tournamentId string, userId string) (tournamentParticipant TournamentParticipant, err error) + InsertTournamentParticipant(tournamentParticipant TournamentParticipant) (err error) + GetAllTournamentParticipants() (tournamentParticipant []TournamentParticipant, err error) + UpdateTournamentParticipant(tournamentParticipant TournamentParticipant) error + DeleteTournamentParticipant(id string) error + + GetTournamentRound(id string) (tournamentRound TournamentRound, err error) + GetTournamentRoundByTournamentNumber(tournamentId string, roundNumber int) (tournamentRound TournamentRound, err error) + InsertTournamentRound(tournamentRound TournamentRound) (err error) + GetAllTournamentRounds(tournamentId string) (tournamentRound []TournamentRound, err error) + UpdateTournamentRound(tournamentRound TournamentRound) error + DeleteTournamentRound(id string) error + GetTournamentCards(tournamentId string, roundNumber int, playerNumber int) (cards []consts.Card, err error) } func NewSQL(driver string, drivername string, logger *zap.SugaredLogger) (SQL, error) { diff --git a/backend/internal/sql/tournament.go b/backend/internal/sql/tournament.go new file mode 100644 index 0000000..3c0d36d --- /dev/null +++ b/backend/internal/sql/tournament.go @@ -0,0 +1,68 @@ +package sql + +import "time" + +type Tournament struct { + ID string + CreatedBy string `db:"created_by"` + Name string + StartTime int `db:"start_time"` + Division int + Rated bool + Private bool + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` +} + +func (db *sqlImpl) GetTournament(id string) (tournament Tournament, err error) { + err = db.db.Get(&tournament, "SELECT * FROM tournament WHERE id=$1", id) + return tournament, err +} + +func (db *sqlImpl) InsertTournament(tournament Tournament) (err error) { + s := `INSERT INTO tournament ( + created_by, + name, + start_time, + division, + rated, + private + ) VALUES ( + :created_by, + :name, + :start_time, + :division, + :rated, + :private + )` + _, err = db.db.NamedExec(s, tournament) + return err +} + +func (db *sqlImpl) GetAllTournaments() (tournament []Tournament, err error) { + err = db.db.Select(&tournament, "SELECT * FROM tournament ORDER BY start_time") + return tournament, err +} + +func (db *sqlImpl) GetAllNotStartedTournaments() (tournament []Tournament, err error) { + err = db.db.Select(&tournament, "SELECT * FROM tournament WHERE start_time>$1 AND private=false", time.Now().Unix()) + return tournament, err +} + +func (db *sqlImpl) UpdateTournament(tournament Tournament) error { + s := `UPDATE tournament SET + created_by=:created_by, + name=:name, + start_time=:start_time, + division=:division, + rated=:rated, + private=:private + WHERE id=:id` + _, err := db.db.NamedExec(s, tournament) + return err +} + +func (db *sqlImpl) DeleteTournament(id string) error { + _, err := db.db.Exec("DELETE FROM tournament WHERE id=$1", id) + return err +} diff --git a/backend/internal/sql/tournament_participant.go b/backend/internal/sql/tournament_participant.go new file mode 100644 index 0000000..b863085 --- /dev/null +++ b/backend/internal/sql/tournament_participant.go @@ -0,0 +1,56 @@ +package sql + +type TournamentParticipant struct { + ID string + TournamentID string `db:"tournament_id"` + UserID string `db:"user_id"` + Rated bool + RatingDelta int `db:"rating_delta"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` +} + +func (db *sqlImpl) GetTournamentParticipant(id string) (tournamentParticipant TournamentParticipant, err error) { + err = db.db.Get(&tournamentParticipant, "SELECT * FROM tournament_participant WHERE id=$1", id) + return tournamentParticipant, err +} + +func (db *sqlImpl) GetTournamentParticipantByTournamentUser(tournamentId string, userId string) (tournamentParticipant TournamentParticipant, err error) { + err = db.db.Get(&tournamentParticipant, "SELECT * FROM tournament_participant WHERE tournamnet_id=$1 AND user_id=$2", tournamentId, userId) + return tournamentParticipant, err +} + +func (db *sqlImpl) InsertTournamentParticipant(tournamentParticipant TournamentParticipant) (err error) { + s := `INSERT INTO tournament_participant ( + tournament_id, + user_id, + rated, + rating_delta + ) VALUES ( + :tournament_id, + :user_id, + :rated, + :rating_delta + )` + _, err = db.db.NamedExec(s, tournamentParticipant) + return err +} + +func (db *sqlImpl) GetAllTournamentParticipants() (tournamentParticipant []TournamentParticipant, err error) { + err = db.db.Select(&tournamentParticipant, "SELECT * FROM tournament_participant ORDER BY created_at DESC") + return tournamentParticipant, err +} + +func (db *sqlImpl) UpdateTournamentParticipant(tournamentParticipant TournamentParticipant) error { + s := `UPDATE tournament_participant SET + rated=:rated, + rating_delta=:rating_delta + WHERE id=:id` + _, err := db.db.NamedExec(s, tournamentParticipant) + return err +} + +func (db *sqlImpl) DeleteTournamentParticipant(id string) error { + _, err := db.db.Exec("DELETE FROM tournament_participant WHERE id=$1", id) + return err +} diff --git a/backend/internal/sql/tournament_round.go b/backend/internal/sql/tournament_round.go new file mode 100644 index 0000000..3f7f87d --- /dev/null +++ b/backend/internal/sql/tournament_round.go @@ -0,0 +1,114 @@ +package sql + +import ( + "encoding/json" + "github.com/mytja/Tarok/backend/internal/consts" +) + +type TournamentRound struct { + ID string + TournamentID string `db:"tournament_id"` + Cards1 string `db:"cards1"` + Cards2 string `db:"cards2"` + Cards3 string `db:"cards3"` + Cards4 string `db:"cards4"` + Talon string `db:"talon"` + RoundNumber int `db:"round_number"` + Time int `db:"time"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` +} + +func (db *sqlImpl) GetTournamentRound(id string) (tournamentRound TournamentRound, err error) { + err = db.db.Get(&tournamentRound, "SELECT * FROM tournament_round WHERE id=$1", id) + return tournamentRound, err +} + +func (db *sqlImpl) GetTournamentRoundByTournamentNumber(tournamentId string, roundNumber int) (tournamentRound TournamentRound, err error) { + err = db.db.Get(&tournamentRound, "SELECT * FROM tournament_round WHERE tournament_id=$1 AND round_number=$2", tournamentId, roundNumber) + return tournamentRound, err +} + +func (db *sqlImpl) InsertTournamentRound(tournamentRound TournamentRound) (err error) { + s := `INSERT INTO tournament_round ( + tournament_id, + cards1, + cards2, + cards3, + cards4, + talon, + round_number, + time + ) VALUES ( + :tournament_id, + :cards1, + :cards2, + :cards3, + :cards4, + :talon, + :round_number, + :time + )` + _, err = db.db.NamedExec(s, tournamentRound) + return err +} + +func (db *sqlImpl) GetAllTournamentRounds(tournamentId string) (tournamentRound []TournamentRound, err error) { + err = db.db.Select(&tournamentRound, "SELECT * FROM tournament_round WHERE tournament_id=$1 ORDER BY round_number", tournamentId) + return tournamentRound, err +} + +func (db *sqlImpl) UpdateTournamentRound(tournamentRound TournamentRound) error { + s := `UPDATE tournament_round SET + cards1=:cards1, + cards2=:cards2, + cards3=:cards3, + cards4=:cards4, + talon=:talon, + round_number=:round_number, + time=:time + WHERE id=:id` + _, err := db.db.NamedExec(s, tournamentRound) + return err +} + +func (db *sqlImpl) DeleteTournamentRound(id string) error { + _, err := db.db.Exec("DELETE FROM tournament_round WHERE id=$1", id) + return err +} + +func (db *sqlImpl) GetTournamentCards(tournamentId string, roundNumber int, playerNumber int) (cards []consts.Card, err error) { + round, err := db.GetTournamentRoundByTournamentNumber(tournamentId, roundNumber) + if err != nil { + return nil, err + } + rn := round.Cards1 + if playerNumber == 2 { + rn = round.Cards2 + } else if playerNumber == 3 { + rn = round.Cards3 + } else if playerNumber == 4 { + rn = round.Cards4 + } else if playerNumber == -1 { + rn = round.Talon + } + + var karte []string + err = json.Unmarshal([]byte(rn), &karte) + if err != nil { + return nil, err + } + + cards = make([]consts.Card, 0) + + for _, karta := range karte { + c, exists := consts.CARDS_MAP[karta] + if !exists { + db.logger.Warnw("neveljavna karta", "karta", karta, "tournamentId", tournamentId, "roundNumber", roundNumber, "playerNumber", playerNumber) + continue + } + cards = append(cards, c) + } + + return cards, nil +} diff --git a/backend/internal/sql/user.go b/backend/internal/sql/user.go index 7170c88..2aeedc7 100644 --- a/backend/internal/sql/user.go +++ b/backend/internal/sql/user.go @@ -13,6 +13,7 @@ type User struct { Disabled bool PasswordResetToken string `db:"password_reset_token"` PasswordResetInitiatedOn string `db:"password_reset_initiated_on"` + EmailSentOn int `db:"mail_sent_on"` CreatedAt string `db:"created_at"` UpdatedAt string `db:"updated_at"` @@ -90,7 +91,8 @@ func (db *sqlImpl) UpdateUser(user User) error { email_confirmed=:email_confirmed, disabled=:disabled, password_reset_token=:password_reset_token, - password_reset_initiated_on=:password_reset_initiated_on + password_reset_initiated_on=:password_reset_initiated_on, + mail_sent_on=:mail_sent_on WHERE id=:id` _, err := db.db.NamedExec(s, user) return err diff --git a/backend/internal/ws/cards.go b/backend/internal/ws/cards.go index fec121d..343db4d 100644 --- a/backend/internal/ws/cards.go +++ b/backend/internal/ws/cards.go @@ -48,13 +48,22 @@ func (s *serverImpl) BotGoroutineCards(gameId string, playing string) { // tole mora biti tukaj, drugače se lahko boti prehitevajo if player.GetBotStatus() { - time.Sleep(500 * time.Millisecond) + if !(game.TournamentID == "" || !game.TimeoutReached) { + // v primeru tournamenta ob timeoutu ne potrebujemo še botov zaustavljati + time.Sleep(500 * time.Millisecond) + } s.logger.Debugw("time exceeded by bot") go s.BotCard(gameId, playing) done = true } + if game.TournamentID != "" && game.TimeoutReached { + s.logger.Debugw("time exceeded by tournament timeout") + go s.BotCard(gameId, playing) + done = true + } + for { game, exists = s.games[gameId] if !exists { @@ -73,6 +82,16 @@ func (s *serverImpl) BotGoroutineCards(gameId string, playing string) { player.SetTimer(math.Max(timer-time.Now().Sub(t).Seconds(), 0) + game.AdditionalTime) s.EndTimerBroadcast(gameId, playing, player.GetTimer()) return + case m := <-game.TournamentMessaging: + ss := strings.Split(m, " ") + if len(ss) == 0 { + continue + } + mes := ss[0] + if mes == "tournamentTimeout" { + game.TimeoutReached = true + go func() { game.EndTimer <- true }() + } case <-time.After(1 * time.Second): game, exists = s.games[gameId] if !exists { diff --git a/backend/internal/ws/gamestart.go b/backend/internal/ws/gamestart.go index 024769f..1a1284e 100644 --- a/backend/internal/ws/gamestart.go +++ b/backend/internal/ws/gamestart.go @@ -58,6 +58,7 @@ func (s *serverImpl) StartGame(gameId string) { game.VotedAdditionOfGames = -1 game.WaitingFor = "" game.EarlyGameStart = make([]string, 0) + game.TimeoutReached = false game.GameCount++ diff --git a/backend/internal/ws/logaritmi.go b/backend/internal/ws/logaritmi.go index 3cff22e..d1590a3 100644 --- a/backend/internal/ws/logaritmi.go +++ b/backend/internal/ws/logaritmi.go @@ -19,6 +19,17 @@ func Shuffle[T any](slc []T) []T { return slc } +func (s *serverImpl) TransformCards(cards []consts.Card, userId string) []Card { + c := make([]Card, 0) + for _, v := range cards { + c = append(c, Card{ + id: v.File, + userId: userId, + }) + } + return c +} + func (s *serverImpl) ShuffleCards(gameId string) { for { game, exists := s.games[gameId] @@ -27,6 +38,39 @@ func (s *serverImpl) ShuffleCards(gameId string) { return } + if game.TournamentID != "" { + for kk, userId := range game.Starts { + cs, err := s.db.GetTournamentCards(game.TournamentID, game.GameCount, kk) + if err != nil { + s.logger.Errorw("could not transform cards inside tournament", "err", err) + return + } + cards := s.TransformCards(cs, userId) + for _, card := range cards { + game.Players[userId].AddCard(card) + game.Players[userId].BroadcastToClients(&messages.Message{ + PlayerId: userId, + Data: &messages.Message_Card{ + Card: &messages.Card{ + Id: card.id, + UserId: userId, + Type: &messages.Card_Receive{ + Receive: &messages.Receive{}, + }, + }, + }, + }) + } + } + tl, err := s.db.GetTournamentCards(game.TournamentID, game.GameCount, -1) + if err != nil { + s.logger.Errorw("could not transform cards inside tournament", "err", err) + return + } + game.Talon = s.TransformCards(tl, "") + break + } + cards := make([]consts.Card, 0) cards = append(cards, consts.CARDS...) cards = Shuffle(cards) diff --git a/backend/internal/ws/rating.go b/backend/internal/ws/rating.go new file mode 100644 index 0000000..09d3fb7 --- /dev/null +++ b/backend/internal/ws/rating.go @@ -0,0 +1,103 @@ +package ws + +import ( + "math" + "sort" +) + +type UserRating struct { + UserID string + Rank float64 + OldRating int + Seed float64 + NewRating int + Delta int +} + +func NewUserRating(userId string, rank float64, oldRating int) UserRating { + return UserRating{ + UserID: userId, + Rank: rank, + OldRating: oldRating, + Seed: 1.0, + NewRating: 0, + } +} + +func CalP(userA UserRating, userB UserRating) float64 { + return 1.0 / (1.0 + math.Pow(10, float64(userB.OldRating-userA.OldRating)/400.0)) +} + +// da ne kopiramo zaman, dam pointer sem. nil ptr dereference incoming +func GetExSeed(userList *[]UserRating, rating int, ownUser *UserRating) float64 { + exUser := NewUserRating(ownUser.UserID, 0.0, rating) + result := 1.0 + for _, user := range *userList { + if user.UserID == ownUser.UserID { + continue + } + result += CalP(user, exUser) + } + return result +} + +func CalcRat(userList *[]UserRating, rank float64, user *UserRating) int { + left := 1 + right := 8000 + for right-left > 1 { + mid := int((left + right) / 2) + if GetExSeed(userList, mid, user) < rank { + right = mid + } else { + left = mid + } + } + return left +} + +func CalculateRating(userList *[]UserRating) { + // Calculate seed + for i := range *userList { + (*userList)[i].Seed = 1.0 + for j := range *userList { + if i == j { + continue + } + (*userList)[i].Seed += CalP((*userList)[j], (*userList)[i]) + } + } + + // Calculate initial delta and sum_delta + sum_delta := 0 + for _, user := range *userList { + user.Delta = (CalcRat(userList, math.Sqrt(user.Rank*user.Seed), &user) - user.OldRating) / 2 + sum_delta += user.Delta + } + + // Calculate first inc + inc := int(-sum_delta/len(*userList)) - 1 + for _, user := range *userList { + user.Delta += inc + } + + // Calculate second inc + sort.Slice(*userList, func(i, j int) bool { + return (*userList)[i].OldRating > (*userList)[j].OldRating + }) + s := int(math.Min(float64(len(*userList)), 4*math.Round(math.Sqrt(float64(len(*userList)))))) + sumS := 0 + for i := 0; i < s; i++ { + sumS += (*userList)[i].Delta + } + inc = int(math.Min(math.Max(math.Floor(float64(-sumS/s)), -10), 0)) + + // Calculate new rating + for _, user := range *userList { + user.Delta += inc + user.NewRating = user.OldRating + user.Delta + } + + sort.Slice(*userList, func(i, j int) bool { + return (*userList)[i].Rank < (*userList)[j].Rank + }) +} diff --git a/backend/internal/ws/results.go b/backend/internal/ws/results.go index 5e7c414..12d2230 100644 --- a/backend/internal/ws/results.go +++ b/backend/internal/ws/results.go @@ -2,6 +2,8 @@ package ws import ( "github.com/mytja/Tarok/backend/internal/messages" + "strconv" + "strings" "time" ) @@ -98,6 +100,37 @@ func (s *serverImpl) Results(gameId string) { s.logger.Debugw("radelci dodani vsem udeležencem igre") go func() { + if game.TournamentID != "" { + for { + select { + case m := <-game.TournamentMessaging: + ss := strings.Split(m, " ") + if len(ss) == 0 { + continue + } + mes := ss[0] + if mes == "tournamentEnd" { + s.EndGame(gameId) + return + } else if mes == "tournamentNewGame" { + s.StartGame(gameId) + return + } else if mes == "tournamentCountdown" { + cnt, err := strconv.ParseInt(ss[1], 10, 32) + if err != nil { + continue + } + s.Broadcast( + "", + gameId, + &messages.Message{ + Data: &messages.Message_GameStartCountdown{GameStartCountdown: &messages.GameStartCountdown{Countdown: int32(cnt)}}, + }, + ) + } + } + } + } for i := 0; i <= 15; i++ { s.Broadcast( "", diff --git a/backend/internal/ws/types.go b/backend/internal/ws/types.go index 44a81cc..b92d15c 100644 --- a/backend/internal/ws/types.go +++ b/backend/internal/ws/types.go @@ -155,6 +155,10 @@ type Game struct { SkisRunda bool CanExtendGame bool ResultsArchive []*messages.Results + + TournamentID string + TournamentMessaging chan string + TimeoutReached bool } type Predictions struct { diff --git a/backend/main.go b/backend/main.go index 916a226..bb6eccb 100644 --- a/backend/main.go +++ b/backend/main.go @@ -357,6 +357,11 @@ func run(config *consts.ServerConfig) { mux.HandleFunc(pat.Post("/email/confirm"), httpServer.ConfirmEmail) mux.HandleFunc(pat.Post("/password/reset_request"), httpServer.RequestPasswordReset) mux.HandleFunc(pat.Post("/password/reset"), httpServer.PasswordResetConfirm) + mux.HandleFunc(pat.Get("/tournaments/all"), httpServer.GetAllTournaments) + mux.HandleFunc(pat.Get("/tournaments/upcoming"), httpServer.GetUpcomingTournaments) + mux.HandleFunc(pat.Post("/tournaments"), httpServer.NewTournament) + mux.HandleFunc(pat.Patch("/tournament/:tournamentId"), httpServer.UpdateTournament) + mux.HandleFunc(pat.Delete("/tournament/:tournamentId"), httpServer.DeleteTournament) c := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, // All origins diff --git a/tarok/Dockerfile b/tarok/Dockerfile index e10162d..e82d5bf 100644 --- a/tarok/Dockerfile +++ b/tarok/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get install -y curl git wget unzip libgconf-2-4 gdb libstdc++6 libglu1-m RUN apt-get clean # Clone the flutter repo -RUN git clone --branch stable https://github.com/flutter/flutter.git /usr/local/flutter +RUN git clone --branch beta https://github.com/flutter/flutter.git /usr/local/flutter # Set flutter path # RUN /usr/local/flutter/bin/flutter doctor -v diff --git a/tarok/lib/game/game_controller.dart b/tarok/lib/game/game_controller.dart index 456cb9a..411d047 100644 --- a/tarok/lib/game/game_controller.dart +++ b/tarok/lib/game/game_controller.dart @@ -24,6 +24,7 @@ import 'dart:math'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_initicon/flutter_initicon.dart'; import 'package:get/get.dart'; import 'package:rounded_background_text/rounded_background_text.dart'; @@ -126,6 +127,11 @@ class GameController extends GetxController { playingCount.value = playing; bots = bbots; + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + if (!kIsWeb && (Platform.isLinux || Platform.isWindows)) { rpc.updatePresence( DiscordPresence( diff --git a/tarok/lib/internationalization/languages.dart b/tarok/lib/internationalization/languages.dart index af8e621..bb04a56 100644 --- a/tarok/lib/internationalization/languages.dart +++ b/tarok/lib/internationalization/languages.dart @@ -8,7 +8,7 @@ class Messages extends Translations { "email": "Email address", "password": "Password", "registration": "Registration", - "guest_access": "Guest access", + "guest_access": "Offline with bots", "official_discord": "An official Discord server", "source_code": "Source code", "palcka": "Palčka", @@ -18,9 +18,12 @@ class Messages extends Translations { "account_login_403": "Trouble logging into your account", "account_login_403_desc": "Your account wasn't activated yet or the administrator has locked/disabled it.", + "account_login_202_error": "The account is waiting for activation", + "account_login_202_error_desc": + "You should receive an activation email to your specified email address. Had it not been sent, try again later. You may resend activation emails every 5 minutes.", "account_login_unknown_error": "Unknown error while logging in", "account_login_unknown_error_desc": - "Please recheck your login credentials.", + "Please recheck your login credentials. Had you recently sent an activation email, but not received it, you may resend it in 5 minutes.", "password_mismatch": "Passwords don't match", "ok": "OK", "registration_success": @@ -142,7 +145,7 @@ class Messages extends Translations { "player": "Player", "new_game": "Create a new game", "welcome_message": "Welcome to Palčka tarock program.", - "using_guest_access": "You're using guest access", + "using_guest_access": "You're using offline access", "games_available": "Games available", "with_players": "With players", "in_three": "In three", @@ -263,15 +266,37 @@ class Messages extends Translations { "enable_discord_rpc": "Enables Discord Rich Presence. Your in-game status will be shown on your profile.", "talon_picked": "Picked talon: @talon", + "tournaments": "Tournaments", + "modify_game": "Modify your game", + "new_tournament": "New tournament", + "select_start": "Select the start of the tournament", + "division": "Divison", + "create_new_tournament": "Create new tournament", + "start_at": "Start of the tournament", + "show_participants": "Show registered contestants", + "edit_rounds": "Edit rounds", + "edit_tournament": "Edit tournament", + "invite_tournament_singular": + "@who is inviting you to the official Palčka tournament ", + "invite_tournament_dual": + "@who are inviting you to the official Palčka tournament ", + "invite_tournament_plural": + "@who are inviting you to the official Palčka tournament ", + "tournament_rated": + ". Tournament is@israted counting towards your rating.", + "not_space": " not", }, 'sl_SI': { "login": "Prijava", "account_login_403": "Težava s prijavo v vaš uporabniški profil", "account_login_403_desc": "Vaš uporabniški profil ni še bil aktiviran ali pa ga je administrator zaklenil.", + "account_login_202_error": "Račun čaka na aktivacijo", + "account_login_202_error_desc": + "Na vaš elektronski naslov bi moralo biti poslano aktivacijsko elektronsko sporočilo. Če to ni bilo poslano, poskusite znova malo kasneje. Aktivacijska elektronska sporočila lahko ponovno pošljete v roku 5 minut.", "account_login_unknown_error": "Neznana napaka pri prijavi", "account_login_unknown_error_desc": - "Prosimo, ponovno preverite prijavne podatke.", + "Prosimo, ponovno preverite prijavne podatke. Če ste nedavno poslali aktivacijsko sporočilo in ga ne dobite, ga lahko ponovno pošljete v roku 5 minut.", "registration_success": "Registracija je bila uspešna. Na vaš elektronski naslov bi moralo priti sporočilo z registracijsko kodo. Dokler ne aktivirate računa, se ne boste mogli prijaviti", "user_id": "Uporabniški ID", @@ -331,7 +356,7 @@ class Messages extends Translations { "email": "Elektronski naslov", "password": "Geslo", "registration": "Registracija", - "guest_access": "Gostujoči dostop", + "guest_access": "Dostop brez povezave z boti", "official_discord": "Uradni Discord strežnik", "source_code": "Izvorna koda", "palcka": "Palčka", @@ -403,7 +428,7 @@ class Messages extends Translations { "klop_bots": "Klop boti", "tarock_palcka": "Tarok Palčka", "copyright": "Copyright 2023 Mitja Ševerkar, vse pravice pridržane", - "licensed_under": "Licencirano z AGPLv3 licenco.", + "licensed_under": "Licencirano pod AGPLv3 licenco.", "version": "Različica @version", "old_password": "Staro geslo", "new_password": "Novo geslo", @@ -418,7 +443,7 @@ class Messages extends Translations { "invite": "Povabi", "add_friend": "Dodaj prijatelja", "new_game": "Ustvari novo igro", - "using_guest_access": "Uporabljate gostujoči dostop", + "using_guest_access": "Uporabljate dostop brez povezave", "games_available": "Igre na voljo", "with_players": "Z igralci", "in_three": "V tri", @@ -524,6 +549,23 @@ class Messages extends Translations { "enable_discord_rpc": "Vključi Discordovo bogato prisotnost (Rich Presence). Vaš status znotraj igre bo prikazan na vašem Discord profilu.", "talon_picked": "Izbran talon: @talon", + "tournaments": "Turnirji", + "modify_game": "Prilagodite si igro", + "new_tournament": "Nov turnir", + "select_start": "Izberite čas začetka turnirja", + "division": "Divizija (težavnost)", + "create_new_tournament": "Ustvari nov turnir", + "start_at": "Začetek turnirja", + "show_participants": "Pokaži registrirane udeležence", + "edit_rounds": "Uredi runde", + "edit_tournament": "Uredi turnir", + "invite_tournament_singular": + "@who vas vabi na uradni Palčka turnir ", + "invite_tournament_dual": "@who vas vabita na uradni Palčka turnir ", + "invite_tournament_plural": + "@who vas vabijo na uradni Palčka turnir ", + "tournament_rated": ". Turnir se@israted šteje k vašemu rejtingu.", + "not_space": " ne", } }; } diff --git a/tarok/lib/lobby/lobby.dart b/tarok/lib/lobby/lobby.dart index 66204db..ac7c5af 100644 --- a/tarok/lib/lobby/lobby.dart +++ b/tarok/lib/lobby/lobby.dart @@ -47,507 +47,597 @@ class Lobby extends StatelessWidget { child: const Icon(Icons.add), ), child: Obx( - () => ListView( - shrinkWrap: true, - children: [ - Center( - child: Text( - "welcome_message".tr, - style: const TextStyle(fontSize: 40), - ), - ), - if (controller.guest.value) - Center( - child: Text( - "using_guest_access".tr, - style: const TextStyle(fontSize: 20), - ), - ), - Center( - child: Text( - "games_available".tr, - style: const TextStyle(fontSize: 30), - ), - ), - if (!controller.guest.value) - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("with_players".tr), - const SizedBox( - width: 10, - ), - ElevatedButton.icon( - onPressed: () => controller.quickGameFind(3, "normal"), - label: Text( - "in_three".tr, - style: const TextStyle( - fontSize: 20, - ), - ), - icon: const Icon(Icons.face), - ), - ElevatedButton.icon( - onPressed: () => controller.quickGameFind(4, "normal"), - label: Text( - "in_four".tr, - style: const TextStyle( - fontSize: 20, - ), + () => Column( + children: [ + ...controller.tournaments.map((e) => Card( + child: Container( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text(e["created_by"].length == 1 + ? "invite_tournament_singular".trParams({ + "who": + (e["created_by"] as List).join(", "), + }) + : e["created_by"].length == 2 + ? "invite_tournament_dual".trParams({ + "who": (e["created_by"] as List) + .join(", "), + }) + : "invite_tournament_plural".trParams({ + "who": (e["created_by"] as List) + .join(", "), + })), + Text( + "${e["name"]} (Div.${e["division"]})", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + Text("tournament_rated".trParams({ + "israted": e["rated"] ? "" : "not_space".tr, + })), + const SizedBox( + width: 10, + ), + // grozote + Text( + "${DateTime.fromMillisecondsSinceEpoch(e["start_time"]).day}. ${DateTime.fromMillisecondsSinceEpoch(e["start_time"]).month}. ${DateTime.fromMillisecondsSinceEpoch(e["start_time"]).year} — ${DateTime.fromMillisecondsSinceEpoch(e["start_time"]).hour}.${DateTime.fromMillisecondsSinceEpoch(e["start_time"]).minute < 10 ? "0${DateTime.fromMillisecondsSinceEpoch(e["start_time"]).minute}" : DateTime.fromMillisecondsSinceEpoch(e["start_time"]).minute}", + style: const TextStyle(fontSize: 18), + ), + const Spacer(), + IconButton( + onPressed: () async {}, + icon: e["registered"] + ? const Icon(Icons.check) + : const Icon(Icons.cancel), + ), + ], + ), + const SizedBox( + height: 20, + ), + ]), ), - icon: const Icon(Icons.face), - ), - ]), - if (!controller.guest.value) - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("chatroom".tr), - const SizedBox( - width: 10, - ), - ElevatedButton.icon( - onPressed: () => controller.quickGameFind(3, "klepetalnica"), - label: Text( - "in_three".tr, - style: const TextStyle( - fontSize: 20, + )), + Expanded( + child: Center( + child: ListView( + shrinkWrap: true, + children: [ + Center( + child: Text( + "welcome_message".tr, + style: const TextStyle(fontSize: 40), + ), ), - ), - icon: const Icon(Icons.face), - ), - ElevatedButton.icon( - onPressed: () => controller.quickGameFind(4, "klepetalnica"), - label: Text( - "in_four".tr, - style: const TextStyle( - fontSize: 20, + if (controller.guest.value) + Center( + child: Text( + "using_guest_access".tr, + style: const TextStyle(fontSize: 20), + ), + ), + Center( + child: Text( + "games_available".tr, + style: const TextStyle(fontSize: 30), + ), ), - ), - icon: const Icon(Icons.face), - ), - ]), - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("with_bots".tr), - const SizedBox( - width: 10, - ), - ElevatedButton.icon( - onPressed: () => controller.botGame(3), - label: Text( - "in_three".tr, - style: const TextStyle( - fontSize: 20, - ), - ), - icon: const Icon(Icons.smart_toy), - ), - ElevatedButton.icon( - onPressed: () => controller.botGame(4), - label: Text( - "in_four".tr, - style: const TextStyle( - fontSize: 20, - ), - ), - icon: const Icon(Icons.smart_toy), - ), - ]), - if (!controller.guest.value) - const SizedBox( - height: 10, - ), - if (!controller.guest.value) - Center( - child: ElevatedButton( - onPressed: () => Get.defaultDialog( - title: "replay".tr, - content: SingleChildScrollView( - child: Obx( - () => Column(children: [ - Text("replay_desc".tr), - const SizedBox( - height: 10, - ), - Row(children: [ - Expanded( - child: TextField( - controller: controller.replayController.value, - decoration: InputDecoration( - border: const UnderlineInputBorder(), - labelText: "replay_link".tr, + if (!controller.guest.value) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("with_players".tr), + const SizedBox( + width: 10, + ), + ElevatedButton.icon( + onPressed: () => + controller.quickGameFind(3, "normal"), + label: Text( + "in_three".tr, + style: const TextStyle( + fontSize: 20, + ), + ), + icon: const Icon(Icons.face), + ), + ElevatedButton.icon( + onPressed: () => + controller.quickGameFind(4, "normal"), + label: Text( + "in_four".tr, + style: const TextStyle( + fontSize: 20, ), ), + icon: const Icon(Icons.face), ), ]), - ]), + if (!controller.guest.value) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("chatroom".tr), + const SizedBox( + width: 10, + ), + ElevatedButton.icon( + onPressed: () => + controller.quickGameFind(3, "klepetalnica"), + label: Text( + "in_three".tr, + style: const TextStyle( + fontSize: 20, + ), + ), + icon: const Icon(Icons.face), + ), + ElevatedButton.icon( + onPressed: () => + controller.quickGameFind(4, "klepetalnica"), + label: Text( + "in_four".tr, + style: const TextStyle( + fontSize: 20, + ), + ), + icon: const Icon(Icons.face), + ), + ]), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text("with_bots".tr), + const SizedBox( + width: 10, ), - ), - actions: [ - TextButton( - onPressed: () { - Get.back(); - }, - child: Text("cancel".tr), + ElevatedButton.icon( + onPressed: () => controller.botGame(3), + label: Text( + "in_three".tr, + style: const TextStyle( + fontSize: 20, + ), + ), + icon: const Icon(Icons.smart_toy), ), - TextButton( - onPressed: () { - joinReplay(controller.replayController.value.text); - }, - child: Text("ok".tr), + ElevatedButton.icon( + onPressed: () => controller.botGame(4), + label: Text( + "in_four".tr, + style: const TextStyle( + fontSize: 20, + ), + ), + icon: const Icon(Icons.smart_toy), ), - ], - ), - child: Text("replay".tr), - ), - ), - const SizedBox( - height: 10, - ), - Center( - child: ElevatedButton( - onPressed: () => showDialog( - context: context, - builder: (context) { - return StatefulBuilder(builder: (context, setState) { - return AlertDialog( - title: Text("modify_bots".tr), - content: SingleChildScrollView( - child: Obx( - () => Column(children: [ - Text("modify_bots_desc".tr), - const SizedBox( - height: 10, - ), - FutureBuilder( - future: controller.getBots(), - builder: (BuildContext context, - AsyncSnapshot snapshot) { - if (controller.botNames.isNotEmpty) { - return DataTable( - columns: [ - DataColumn( - label: Expanded( - child: Text( - "bot".tr, - style: const TextStyle( - fontStyle: - FontStyle.italic), - ), - ), - ), - DataColumn( - label: Expanded( - child: Text( - "name".tr, - style: const TextStyle( - fontStyle: - FontStyle.italic), + ]), + if (!controller.guest.value) + const SizedBox( + height: 10, + ), + if (!controller.guest.value) + Center( + child: ElevatedButton( + onPressed: () => Get.defaultDialog( + title: "replay".tr, + content: SingleChildScrollView( + child: Obx( + () => Column(children: [ + Text("replay_desc".tr), + const SizedBox( + height: 10, + ), + Row(children: [ + Expanded( + child: TextField( + controller: + controller.replayController.value, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: "replay_link".tr, + ), + ), + ), + ]), + ]), + ), + ), + actions: [ + TextButton( + onPressed: () { + Get.back(); + }, + child: Text("cancel".tr), + ), + TextButton( + onPressed: () { + joinReplay( + controller.replayController.value.text); + }, + child: Text("ok".tr), + ), + ], + ), + child: Text("replay".tr), + ), + ), + const SizedBox( + height: 10, + ), + Center( + child: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: Text("modify_bots".tr), + content: SingleChildScrollView( + child: Obx( + () => Column(children: [ + Text("modify_bots_desc".tr), + const SizedBox( + height: 10, + ), + FutureBuilder( + future: controller.getBots(), + builder: (BuildContext context, + AsyncSnapshot snapshot) { + if (controller + .botNames.isNotEmpty) { + return DataTable( + columns: [ + DataColumn( + label: Expanded( + child: Text( + "bot".tr, + style: const TextStyle( + fontStyle: FontStyle + .italic), + ), + ), + ), + DataColumn( + label: Expanded( + child: Text( + "name".tr, + style: const TextStyle( + fontStyle: FontStyle + .italic), + ), + ), + ), + DataColumn( + label: Expanded( + child: Text( + "remove".tr, + style: const TextStyle( + fontStyle: FontStyle + .italic), + ), + ), + ), + ], + rows: [ + ...controller.botNames.map( + (e) => DataRow( + cells: [ + DataCell( + Text(e["type"])), + DataCell( + Text(e["name"])), + DataCell( + IconButton( + icon: const Icon( + Icons.delete), + onPressed: + () async { + await controller + .deleteBot( + e["name"], + e["type"], + ); + await controller + .getBots(); + setState(() {}); + }, + ), + ), + ], + ), + ), + ], + ); + } + return const SizedBox(); + }, + ), + Row(children: [ + Expanded( + child: TextField( + controller: controller + .playerNameController.value, + decoration: InputDecoration( + border: + const UnderlineInputBorder(), + labelText: "bot_name".tr, ), ), ), - DataColumn( - label: Expanded( - child: Text( - "remove".tr, - style: const TextStyle( - fontStyle: - FontStyle.italic), - ), - ), + IconButton( + icon: const Icon( + Icons.replay_outlined), + onPressed: () async { + String botName = + controller.randomBotName(); + controller.playerNameController + .value.text = botName; + setState(() {}); + }, ), - ], - rows: [ - ...controller.botNames.map( - (e) => DataRow( - cells: [ - DataCell(Text(e["type"])), - DataCell(Text(e["name"])), - DataCell( - IconButton( - icon: const Icon( - Icons.delete), - onPressed: () async { - await controller - .deleteBot( - e["name"], - e["type"], - ); - await controller - .getBots(); - setState(() {}); - }, - ), + ]), + const SizedBox( + height: 10, + ), + Center( + child: SegmentedButton( + segments: >[ + ...BOTS.map( + (e) => ButtonSegment( + value: e, + label: Text( + e["name"].toString()), ), - ], + ) + ], + selected: { + controller.dropdownValue + }, + onSelectionChanged: + (Set newSelection) { + controller.dropdownValue = + newSelection.first; + setState(() {}); + }, + ), + ), + const SizedBox( + height: 10, + ), + ElevatedButton.icon( + onPressed: () async { + await controller.newBot( + controller.playerNameController + .value.text, + controller.dropdownValue["type"] + as String, + ); + setState(() {}); + }, + label: Text( + "add_bot".tr, + style: const TextStyle( + fontSize: 20, ), ), - ], - ); - } - return const SizedBox(); - }, - ), - Row(children: [ - Expanded( - child: TextField( - controller: - controller.playerNameController.value, - decoration: InputDecoration( - border: const UnderlineInputBorder(), - labelText: "bot_name".tr, - ), - ), - ), - IconButton( - icon: const Icon(Icons.replay_outlined), - onPressed: () async { - String botName = - controller.randomBotName(); - controller.playerNameController.value - .text = botName; - setState(() {}); - }, - ), - ]), - const SizedBox( - height: 10, - ), - Center( - child: SegmentedButton( - segments: >[ - ...BOTS.map( - (e) => ButtonSegment( - value: e, - label: Text(e["name"].toString()), + icon: const Icon(Icons.add), ), - ) - ], - selected: {controller.dropdownValue}, - onSelectionChanged: - (Set newSelection) { - controller.dropdownValue = - newSelection.first; - setState(() {}); - }, - ), - ), - const SizedBox( - height: 10, - ), - ElevatedButton.icon( - onPressed: () async { - await controller.newBot( - controller - .playerNameController.value.text, - controller.dropdownValue["type"] - as String, - ); - setState(() {}); - }, - label: Text( - "add_bot".tr, - style: const TextStyle( - fontSize: 20, + ]), ), ), - icon: const Icon(Icons.add), - ), - ]), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text("finish_list_editing".tr), - ), - ], - ); - }); - }), - child: Text("customize_bots".tr), - ), - ), - - const SizedBox( - height: 10, - ), - Align( - alignment: Alignment.center, - child: IntrinsicWidth( - child: Card( - child: ListTile( - leading: const FaIcon(FontAwesomeIcons.discord), - title: Text("discord".tr), - subtitle: Text("discord_desc".tr), - onTap: () async { - await launchUrl( - Uri.parse("https://discord.gg/fzeN4Cnbr3")); - }, - ), - ), - ), - ), - - const SizedBox( - height: 10, - ), - - // PRIORITY QUEUE - GridView.count( - childAspectRatio: 0.75, - crossAxisCount: 4, - physics: - const NeverScrollableScrollPhysics(), // to disable GridView's scrolling - shrinkWrap: true, - children: [ - ...controller.priorityQueue.map( - (e) => GestureDetector( - onTap: () async { - debugPrint("Priority"); - await Get.toNamed("/game", parameters: { - "playing": e.requiredPlayers.toString(), - "gameId": e.id, - "bots": "false", - }); - }, - child: Card( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Text( - "game".trParams({ - "type": e.type == "klepetalnica" - ? "(klepetalnica)" - : "" - }), - ), - ), - if (e.mondfangRadelci) - Center( - child: Text("mondfang_radelci".tr), - ), - if (e.skisfang) - Center( - child: Text("skisfang".tr), - ), - if (e.napovedanMondfang) - Center( - child: Text("predicted_mondfang".tr), - ), - const SizedBox( - height: 10, - ), - ...e.user.map( - (k) => SizedBox( - height: 40, - child: Text( - k.name, - style: const TextStyle( - fontSize: 25, - ), - ), - ), - ), - ListView.builder( - shrinkWrap: true, - itemCount: e.requiredPlayers - e.user.length, - itemBuilder: (BuildContext context, int index) { - return Center( - child: SizedBox( - height: 40, - child: Text( - "join_game".tr, - style: const TextStyle( - fontSize: 25, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text("finish_list_editing".tr), ), - ), - ), - ); + ], + ); + }); + }), + child: Text("customize_bots".tr), + ), + ), + + const SizedBox( + height: 10, + ), + Align( + alignment: Alignment.center, + child: IntrinsicWidth( + child: Card( + child: ListTile( + leading: const FaIcon(FontAwesomeIcons.discord), + title: Text("discord".tr), + subtitle: Text("discord_desc".tr), + onTap: () async { + await launchUrl( + Uri.parse("https://discord.gg/fzeN4Cnbr3")); }, ), - ], + ), ), ), - ), - ), - ], - ), - // QUEUE - GridView.count( - crossAxisCount: 4, - physics: - const NeverScrollableScrollPhysics(), // to disable GridView's scrolling - shrinkWrap: true, - children: [ - ...controller.queue.map( - (e) => GestureDetector( - onTap: () async { - await Get.toNamed("/game", parameters: { - "playing": e.requiredPlayers.toString(), - "gameId": e.id, - "bots": "false", - }); - }, - child: Card( - child: Column( - children: [ - Center( - child: Text( - "game".trParams({ - "type": e.type == "klepetalnica" - ? "(klepetalnica)" - : "" - }), - ), - ), - if (e.mondfangRadelci) - Center( - child: Text("mondfang_radelci".tr), - ), - if (e.skisfang) - Center( - child: Text("skisfang".tr), - ), - if (e.napovedanMondfang) - Center( - child: Text("predicted_mondfang".tr), - ), - const SizedBox( - height: 10, - ), - ...e.user.map( - (k) => SizedBox( - height: 40, - child: Text( - k.name, - style: const TextStyle( - fontSize: 25, - ), + const SizedBox( + height: 10, + ), + + // PRIORITY QUEUE + GridView.count( + childAspectRatio: 0.75, + crossAxisCount: 4, + physics: + const NeverScrollableScrollPhysics(), // to disable GridView's scrolling + shrinkWrap: true, + children: [ + ...controller.priorityQueue.map( + (e) => GestureDetector( + onTap: () async { + debugPrint("Priority"); + await Get.toNamed("/game", parameters: { + "playing": e.requiredPlayers.toString(), + "gameId": e.id, + "bots": "false", + }); + }, + child: Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Text( + "game".trParams({ + "type": e.type == "klepetalnica" + ? "(klepetalnica)" + : "" + }), + ), + ), + if (e.mondfangRadelci) + Center( + child: Text("mondfang_radelci".tr), + ), + if (e.skisfang) + Center( + child: Text("skisfang".tr), + ), + if (e.napovedanMondfang) + Center( + child: Text("predicted_mondfang".tr), + ), + const SizedBox( + height: 10, + ), + ...e.user.map( + (k) => SizedBox( + height: 40, + child: Text( + k.name, + style: const TextStyle( + fontSize: 25, + ), + ), + ), + ), + ListView.builder( + shrinkWrap: true, + itemCount: + e.requiredPlayers - e.user.length, + itemBuilder: + (BuildContext context, int index) { + return Center( + child: SizedBox( + height: 40, + child: Text( + "join_game".tr, + style: const TextStyle( + fontSize: 25, + ), + ), + ), + ); + }, + ), + ], ), ), ), - ListView.builder( - shrinkWrap: true, - itemCount: e.requiredPlayers - e.user.length, - itemBuilder: (BuildContext context, int index) { - return Center( - child: SizedBox( - height: 40, - child: Text( - "join_game".tr, - style: const TextStyle( - fontSize: 25, + ), + ], + ), + + // QUEUE + GridView.count( + crossAxisCount: 4, + physics: + const NeverScrollableScrollPhysics(), // to disable GridView's scrolling + shrinkWrap: true, + children: [ + ...controller.queue.map( + (e) => GestureDetector( + onTap: () async { + await Get.toNamed("/game", parameters: { + "playing": e.requiredPlayers.toString(), + "gameId": e.id, + "bots": "false", + }); + }, + child: Card( + child: Column( + children: [ + Center( + child: Text( + "game".trParams({ + "type": e.type == "klepetalnica" + ? "(klepetalnica)" + : "" + }), ), ), - ), - ); - }, + if (e.mondfangRadelci) + Center( + child: Text("mondfang_radelci".tr), + ), + if (e.skisfang) + Center( + child: Text("skisfang".tr), + ), + if (e.napovedanMondfang) + Center( + child: Text("predicted_mondfang".tr), + ), + const SizedBox( + height: 10, + ), + ...e.user.map( + (k) => SizedBox( + height: 40, + child: Text( + k.name, + style: const TextStyle( + fontSize: 25, + ), + ), + ), + ), + ListView.builder( + shrinkWrap: true, + itemCount: + e.requiredPlayers - e.user.length, + itemBuilder: + (BuildContext context, int index) { + return Center( + child: SizedBox( + height: 40, + child: Text( + "join_game".tr, + style: const TextStyle( + fontSize: 25, + ), + ), + ), + ); + }, + ), + ], + ), + ), ), - ], - ), + ), + ], ), - ), + ], ), - ], + ), ), ], ), diff --git a/tarok/lib/lobby/lobby_controller.dart b/tarok/lib/lobby/lobby_controller.dart index a8cbed9..0b7a471 100644 --- a/tarok/lib/lobby/lobby_controller.dart +++ b/tarok/lib/lobby/lobby_controller.dart @@ -120,6 +120,7 @@ class LobbyController extends GetxController { var prijatelji = [].obs; var emailController = TextEditingController().obs; var replays = [].obs; + var tournaments = [].obs; Map dropdownValue = BOTS.first; @@ -136,7 +137,7 @@ class LobbyController extends GetxController { Get.dialog( AlertDialog( scrollable: true, - title: const Text('Prilagodite si igro'), + title: Text('modify_game'.tr), content: Obx( () => SizedBox( width: double.maxFinite, @@ -234,6 +235,16 @@ class LobbyController extends GetxController { ); } + Future fetchTournaments() async { + final response = await dio.get( + '$BACKEND_URL/tournaments/upcoming', + options: Options( + headers: {"X-Login-Token": await storage.read(key: "token")}, + ), + ); + tournaments.value = jsonDecode(response.data); + } + Future newGame(int players) async { final token = await storage.read(key: "token"); if (token == null) return; @@ -429,6 +440,10 @@ class LobbyController extends GetxController { // eh, nič zato, smo čist v redu } + try { + await fetchTournaments(); + } catch (e) {} + super.onInit(); } diff --git a/tarok/lib/main.dart b/tarok/lib/main.dart index 419451e..f4ad20c 100644 --- a/tarok/lib/main.dart +++ b/tarok/lib/main.dart @@ -39,6 +39,7 @@ import 'package:tarok/login/register.dart'; import 'package:tarok/replay.dart'; import 'package:tarok/settings.dart'; import 'package:tarok/sounds.dart'; +import 'package:tarok/tms/tournaments.dart'; import 'package:tarok/user/user.dart'; import 'package:url_strategy/url_strategy.dart'; @@ -92,10 +93,6 @@ void main() async { String? locale = prefs.getString("locale"); parseLocale(locale); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); if (THEME == "light") { @@ -133,6 +130,7 @@ void main() async { GetPage(name: '/replays', page: () => const Replays()), GetPage(name: '/users', page: () => const Users()), GetPage(name: '/profile', page: () => const Profile()), + GetPage(name: '/tournaments', page: () => const Tournaments()), GetPage( name: '/account/reset', page: () => const PasswordResetRequest()), GetPage( diff --git a/tarok/lib/ui/main_page.dart b/tarok/lib/ui/main_page.dart index 106e442..809bdc0 100644 --- a/tarok/lib/ui/main_page.dart +++ b/tarok/lib/ui/main_page.dart @@ -83,6 +83,14 @@ class PalckaHome extends StatelessWidget { }, ), if (controller.isAdmin.value) const Divider(), + if (controller.isAdmin.value) + ListTile( + leading: const Icon(Icons.emoji_events), + title: Text("tournaments".tr), + onTap: () async { + await Get.toNamed("/tournaments"); + }, + ), if (controller.isAdmin.value) ListTile( leading: const Icon(Icons.account_box), diff --git a/tarok/pubspec.lock b/tarok/pubspec.lock index 75e82d6..d619f93 100644 --- a/tarok/pubspec.lock +++ b/tarok/pubspec.lock @@ -618,6 +618,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.1" + omni_datetime_picker: + dependency: "direct main" + description: + name: omni_datetime_picker + sha256: d13f237b7fe8a849203563e4e95ca2a43fe3b357364ad9b224899e0710fd72fb + url: "https://pub.dev" + source: hosted + version: "1.0.9" package_config: dependency: transitive description: diff --git a/tarok/pubspec.yaml b/tarok/pubspec.yaml index fe21afc..5af1389 100644 --- a/tarok/pubspec.yaml +++ b/tarok/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: dart_discord_rpc: git: url: https://github.com/alexmercerind/dart_discord_rpc + omni_datetime_picker: ^1.0.9 dev_dependencies: flutter_test: