Compare commits
12 Commits
icon
...
ba1a67282a
| Author | SHA1 | Date | |
|---|---|---|---|
| ba1a67282a | |||
| 2aa58f1b65 | |||
| 2dd06d4af3 | |||
| 5a20e76df2 | |||
|
|
02c5c6ea68 | ||
|
|
9cbb8756d1 | ||
|
|
604b92b3b3 | ||
|
|
c7236394d9 | ||
|
|
720ef83c2e | ||
|
|
5b1d107cac | ||
|
|
662257ebc5 | ||
|
|
ad48882bca |
1
backend/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
*.db3
|
||||
*.db3-*
|
||||
*.db3.*
|
||||
/allowance_planner
|
||||
|
||||
@@ -2,10 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gavv/httpexpect/v2"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gavv/httpexpect/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -914,3 +915,88 @@ func createTestAllowance(e *httpexpect.Expect, name string, target float64, weig
|
||||
func createTestTask(e *httpexpect.Expect) int {
|
||||
return createTestTaskWithAmount(e, 100)
|
||||
}
|
||||
|
||||
// Transfer tests
|
||||
func TestTransferSuccessful(t *testing.T) {
|
||||
e := startServer(t)
|
||||
|
||||
// Create two allowances for user 1
|
||||
createTestAllowance(e, "From Allowance", 100, 1)
|
||||
createTestAllowance(e, "To Allowance", 100, 1)
|
||||
|
||||
// Add 30 to allowance 1
|
||||
req := map[string]interface{}{"amount": 30, "description": "funds"}
|
||||
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
|
||||
|
||||
// Transfer 10 from 1 to 2
|
||||
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
|
||||
e.POST("/transfer").WithJSON(transfer).Expect().Status(200).JSON().Object().Value("message").IsEqual("Transfer successful")
|
||||
|
||||
// Verify balances
|
||||
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
|
||||
allowances.Value(1).Object().Value("progress").Number().InDelta(20.0, 0.01)
|
||||
allowances.Value(2).Object().Value("progress").Number().InDelta(10.0, 0.01)
|
||||
}
|
||||
|
||||
func TestTransferCapsAtTarget(t *testing.T) {
|
||||
e := startServer(t)
|
||||
|
||||
// Create two allowances
|
||||
createTestAllowance(e, "From Allowance", 100, 1)
|
||||
createTestAllowance(e, "To Allowance", 5, 1)
|
||||
|
||||
// Add 10 to allowance 1
|
||||
req := map[string]interface{}{"amount": 10, "description": "funds"}
|
||||
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
|
||||
|
||||
// Transfer 10 from 1 to 2, but to only needs 5
|
||||
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
|
||||
e.POST("/transfer").WithJSON(transfer).Expect().Status(200)
|
||||
|
||||
// Verify capped transfer
|
||||
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
|
||||
allowances.Value(1).Object().Value("progress").Number().InDelta(5.0, 0.01) // from had 10, transferred 5 -> left 5
|
||||
allowances.Value(2).Object().Value("progress").Number().InDelta(5.0, 0.01) // to reached target
|
||||
}
|
||||
|
||||
func TestTransferDifferentUsersFails(t *testing.T) {
|
||||
e := startServer(t)
|
||||
|
||||
// Create allowance for user 1 and user 2
|
||||
createTestAllowance(e, "User1 Allowance", 100, 1)
|
||||
// create for user 2
|
||||
e.POST("/user/2/allowance").WithJSON(CreateAllowanceRequest{Name: "User2 Allowance", Target: 100, Weight: 1}).Expect().Status(201)
|
||||
|
||||
// Add to user1 allowance
|
||||
req := map[string]interface{}{"amount": 10, "description": "funds"}
|
||||
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
|
||||
|
||||
// Attempt transfer between different users
|
||||
transfer := map[string]interface{}{"from": 1, "to": 1 /* wrong id to simulate different user's id? */}
|
||||
// To ensure different user, fetch the allowance id for user2 (it's 1 for user2 in its own context but global id will be 2)
|
||||
// Create above for user2 produced global id 2, so use that
|
||||
transfer = map[string]interface{}{"from": 1, "to": 2, "amount": 5}
|
||||
e.POST("/transfer").WithJSON(transfer).Expect().Status(400)
|
||||
}
|
||||
|
||||
func TestTransferInsufficientFunds(t *testing.T) {
|
||||
e := startServer(t)
|
||||
|
||||
// Create two allowances
|
||||
createTestAllowance(e, "From Allowance", 100, 1)
|
||||
createTestAllowance(e, "To Allowance", 100, 1)
|
||||
|
||||
// Ensure from has 0 balance
|
||||
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
|
||||
resp := e.POST("/transfer").WithJSON(transfer).Expect().Status(400).JSON().Object()
|
||||
// Error text should mention insufficient funds
|
||||
resp.Value("error").String().Contains("insufficient")
|
||||
}
|
||||
|
||||
func TestTransferNotFound(t *testing.T) {
|
||||
e := startServer(t)
|
||||
|
||||
// No allowances exist yet (only user rows). Attempt transfer with non-existent IDs
|
||||
transfer := map[string]interface{}{"from": 999, "to": 1000, "amount": 1}
|
||||
e.POST("/transfer").WithJSON(transfer).Expect().Status(404)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/adhocore/gronx"
|
||||
|
||||
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
||||
)
|
||||
|
||||
@@ -631,3 +633,74 @@ func (db *Db) AddAllowanceAmount(userId int, allowanceId int, request AddAllowan
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TransferAllowance transfers amount from one allowance goal to another.
|
||||
// Both allowance ids must exist and belong to the same user. The transfer
|
||||
// will not move more than the 'to' goal still needs (target - balance).
|
||||
func (db *Db) TransferAllowance(fromId int, toId int, amount float64) error {
|
||||
if fromId == toId {
|
||||
return nil
|
||||
}
|
||||
amountCents := int(math.Round(amount * 100.0))
|
||||
if amountCents <= 0 {
|
||||
return fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
tx, err := db.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.MustRollback()
|
||||
|
||||
// Fetch from allowance (user_id, balance)
|
||||
var fromUserId int
|
||||
var fromBalance int
|
||||
err = tx.Query("select user_id, balance from allowances where id = ?").Bind(fromId).ScanSingle(&fromUserId, &fromBalance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fetch to allowance (user_id, target, balance)
|
||||
var toUserId int
|
||||
var toTarget int
|
||||
var toBalance int
|
||||
err = tx.Query("select user_id, target, balance from allowances where id = ?").Bind(toId).ScanSingle(&toUserId, &toTarget, &toBalance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure same owner
|
||||
if fromUserId != toUserId {
|
||||
return fmt.Errorf("allowances do not belong to the same user")
|
||||
}
|
||||
|
||||
// Calculate how much the 'to' goal still needs
|
||||
remainingTo := toTarget - toBalance
|
||||
if remainingTo <= 0 {
|
||||
// Nothing to transfer
|
||||
return fmt.Errorf("target already reached")
|
||||
}
|
||||
|
||||
// Limit transfer to what 'to' still needs
|
||||
transfer := amountCents
|
||||
if transfer > remainingTo {
|
||||
transfer = remainingTo
|
||||
}
|
||||
|
||||
// Ensure 'from' has enough balance
|
||||
if fromBalance < transfer {
|
||||
return fmt.Errorf("insufficient funds in source allowance")
|
||||
}
|
||||
|
||||
// Perform updates
|
||||
err = tx.Query("update allowances set balance = balance - ? where id = ? and user_id = ?").Bind(transfer, fromId, fromUserId).Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").Bind(transfer, toId, toUserId).Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -78,3 +78,10 @@ type AddAllowanceAmountRequest struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TransferRequest represents a request to transfer amount between two goals.
|
||||
type TransferRequest struct {
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ go 1.24.2
|
||||
|
||||
require (
|
||||
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/gavv/httpexpect/v2 v2.17.0
|
||||
github.com/gin-contrib/cors v1.7.5
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -47,7 +49,6 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sanity-io/litter v1.5.8 // indirect
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
|
||||
@@ -2,6 +2,8 @@ gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW
|
||||
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
|
||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
|
||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
|
||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
|
||||
@@ -4,13 +4,16 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gitea.seeseepuff.be/seeseemelk/mysqlite"
|
||||
"github.com/adhocore/gronx"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -43,6 +46,11 @@ type ServerConfig struct {
|
||||
Started chan bool
|
||||
}
|
||||
|
||||
const DefaultDomain = "localhost:8080"
|
||||
|
||||
// The domain that the server is reachable at.
|
||||
var domain = DefaultDomain
|
||||
|
||||
func getUsers(c *gin.Context) {
|
||||
users, err := db.GetUsers()
|
||||
if err != nil {
|
||||
@@ -431,6 +439,14 @@ func createTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if taskRequest.Schedule != nil {
|
||||
valid := gronx.IsValid(*taskRequest.Schedule)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid cron schedule: %s", *taskRequest.Schedule)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If assigned is not nil, check if user exists
|
||||
if taskRequest.Assigned != nil {
|
||||
exists, err := db.UserExists(*taskRequest.Assigned)
|
||||
@@ -508,6 +524,11 @@ func putTask(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Error getting task: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
|
||||
return
|
||||
}
|
||||
|
||||
err = db.UpdateTask(taskId, &taskRequest)
|
||||
if err != nil {
|
||||
@@ -706,5 +727,10 @@ func main() {
|
||||
config.Datasource = "allowance_planner.db3"
|
||||
log.Printf("Warning: No DB_PATH set, using default of %s", config.Datasource)
|
||||
}
|
||||
domain = os.Getenv("DOMAIN")
|
||||
if domain == "" {
|
||||
domain = DefaultDomain
|
||||
log.Printf("Warning: No DOMAIN set, using default of %s", domain)
|
||||
}
|
||||
start(context.Background(), &config)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
@@ -26,11 +27,22 @@ func loadWebEndpoints(router *gin.Engine) {
|
||||
router.GET("/completeAllowance", renderCompleteAllowance)
|
||||
}
|
||||
|
||||
func redirectToPage(c *gin.Context, page string) {
|
||||
redirectToPageStatus(c, page, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func redirectToPageStatus(c *gin.Context, page string, status int) {
|
||||
scheme := c.Request.URL.Scheme
|
||||
target := scheme + domain + page
|
||||
c.Redirect(status, target)
|
||||
}
|
||||
|
||||
func renderLogin(c *gin.Context) {
|
||||
if c.Query("user") != "" {
|
||||
c.SetCookie("user", c.Query("user"), 3600, "/", "localhost", false, true)
|
||||
log.Println("Set cookie for user:", c.Query("user"))
|
||||
c.SetCookie("user", c.Query("user"), 3600, "", "", false, true)
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
redirectToPage(c, "/")
|
||||
}
|
||||
|
||||
func renderIndex(c *gin.Context) {
|
||||
@@ -68,7 +80,7 @@ func renderCreateTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
redirectToPageStatus(c, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func renderCompleteTask(c *gin.Context) {
|
||||
@@ -85,7 +97,7 @@ func renderCompleteTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
redirectToPageStatus(c, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func renderCreateAllowance(c *gin.Context) {
|
||||
@@ -122,7 +134,7 @@ func renderCreateAllowance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
redirectToPageStatus(c, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func renderCompleteAllowance(c *gin.Context) {
|
||||
@@ -144,11 +156,12 @@ func renderCompleteAllowance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
redirectToPageStatus(c, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func getCurrentUser(c *gin.Context) *int {
|
||||
currentUserStr, err := c.Cookie("user")
|
||||
log.Println("Cookie string:", currentUserStr)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
renderNoUser(c)
|
||||
return nil
|
||||
@@ -172,7 +185,7 @@ func getCurrentUser(c *gin.Context) *int {
|
||||
|
||||
func unsetUserCookie(c *gin.Context) {
|
||||
c.SetCookie("user", "", -1, "/", "localhost", false, true)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
redirectToPageStatus(c, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func renderNoUser(c *gin.Context) {
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<head>
|
||||
<title>Allowance Planner 2000</title>
|
||||
<style>
|
||||
<!--
|
||||
tr:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
-->
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -27,7 +29,7 @@
|
||||
{{if ne .CurrentUser 0}}
|
||||
<h2>Allowances</h2>
|
||||
<form action="/createAllowance" method="post">
|
||||
<table border="1">
|
||||
<table border=1>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -43,7 +45,7 @@
|
||||
<td></td>
|
||||
<td><label><input type="number" name="target" placeholder="Target"></label></td>
|
||||
<td><label><input type="number" name="weight" placeholder="Weight"></label></td>
|
||||
<td><button>Create</button></td>
|
||||
<td><input type="submit" value="Create"></td>
|
||||
</tr>
|
||||
{{range .Allowances}}
|
||||
{{if eq .ID 0}}
|
||||
@@ -103,7 +105,7 @@
|
||||
<td><label><input type="text" name="name" placeholder="Name"></label></td>
|
||||
<td></td>
|
||||
<td><label><input type="number" name="reward" placeholder="Reward"></label></td>
|
||||
<td><button>Create</button></td>
|
||||
<td><input type="submit" value="Create"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -409,6 +409,59 @@ paths:
|
||||
404:
|
||||
description: The task could not be found.
|
||||
|
||||
/api/transfer:
|
||||
post:
|
||||
summary: Transfer amount between allowances
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
from:
|
||||
type: integer
|
||||
description: Source allowance ID
|
||||
to:
|
||||
type: integer
|
||||
description: Destination allowance ID
|
||||
amount:
|
||||
type: number
|
||||
format: float
|
||||
description: Amount to transfer
|
||||
required:
|
||||
- from
|
||||
- to
|
||||
- amount
|
||||
responses:
|
||||
'200':
|
||||
description: Transfer successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'400':
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
'404':
|
||||
description: Allowance not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
|
||||
components:
|
||||
schemas:
|
||||
task:
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 296 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 408 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1006 B |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">allowance-planner-v2</string>
|
||||
<string name="title_activity_main">allowance-planner-v2</string>
|
||||
<string name="app_name">Allowance Planner V2</string>
|
||||
<string name="title_activity_main">Allowance Planner V2</string>
|
||||
<string name="package_name">io.ionic.starter</string>
|
||||
<string name="custom_url_scheme">io.ionic.starter</string>
|
||||
</resources>
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
BIN
frontend/allowance-planner-v2/assets/splash.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
@@ -2,7 +2,7 @@ import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'io.ionic.starter',
|
||||
appName: 'allowance-planner-v2',
|
||||
appName: 'Allowance Planner V2',
|
||||
webDir: 'www'
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@ form,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 1px solid var(--ion-color-primary);
|
||||
border-radius: 5px;
|
||||
width: 250px;
|
||||
height: 40px;
|
||||
padding-inline: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--ion-color-primary);
|
||||
margin-top: 25px;
|
||||
@@ -30,8 +40,7 @@ button {
|
||||
color: white;
|
||||
padding: 10px;
|
||||
width: 250px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 50px;
|
||||
margin-top: 100px;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
|
||||
@@ -36,7 +36,7 @@ export class AllowancePage implements ViewWillEnter {
|
||||
allowance[0].name = 'Main Allowance';
|
||||
this.allowance$.next(allowance);
|
||||
})
|
||||
}, 50);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
canFinishGoal(allowance: Allowance): boolean {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EditAllowancePageRoutingModule } from './edit-allowance-routing.module'
|
||||
|
||||
import { EditAllowancePage } from './edit-allowance.page';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -16,7 +17,8 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
IonicModule,
|
||||
EditAllowancePageRoutingModule,
|
||||
ReactiveFormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
MatSelectModule
|
||||
],
|
||||
declarations: [EditAllowancePage]
|
||||
})
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
<ion-title *ngIf="isAddMode">Create Goal</ion-title>
|
||||
<ion-title *ngIf="!isAddMode && goalId != 0">Edit Goal</ion-title>
|
||||
<ion-title *ngIf="!isAddMode && goalId == 0">Edit Allowance</ion-title>
|
||||
<button
|
||||
*ngIf="!isAddMode && goalId !=0"
|
||||
class="remove-button"
|
||||
(click)="deleteAllowance()"
|
||||
>Delete Goal</button>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
@@ -33,9 +28,9 @@
|
||||
|
||||
<div class="item" *ngIf="isAddMode || goalId != 0">
|
||||
<label>Colour</label>
|
||||
<select formControlName="color">
|
||||
<option *ngFor="let color of possibleColors" [value]="color" [style.--background]="color">{{color}}</option>
|
||||
</select>
|
||||
<mat-select [(value)]="selectedColor" formControlName="color" [style.--color]="selectedColor">
|
||||
<mat-option *ngFor="let color of possibleColors" [value]="color" [style.--background]="color">{{color}}</mat-option>
|
||||
</mat-select>
|
||||
</div>
|
||||
|
||||
<button type="button" [disabled]="!form.valid" (click)="submit()">
|
||||
@@ -43,5 +38,10 @@
|
||||
<span *ngIf="!isAddMode && goalId != 0">Update Goal</span>
|
||||
<span *ngIf="!isAddMode && goalId == 0">Update Allowance</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!isAddMode && goalId !=0"
|
||||
class="remove-button"
|
||||
(click)="deleteAllowance()"
|
||||
>Delete Goal</button>
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-color: var(--ion-color-primary);
|
||||
margin-right: 15px;
|
||||
width: 100px;
|
||||
margin-bottom: 0;
|
||||
margin-top: 10px;
|
||||
background-color: var(--negative-amount-color);
|
||||
}
|
||||
|
||||
form {
|
||||
@@ -28,17 +26,23 @@ label {
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
mat-select {
|
||||
--color: black;
|
||||
color: var(--color);
|
||||
border: 1px solid var(--ion-color-primary);
|
||||
border-radius: 5px;
|
||||
width: 250px;
|
||||
height: 40px;
|
||||
padding-inline: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: (--ion-font-family);
|
||||
}
|
||||
|
||||
option {
|
||||
mat-option {
|
||||
--background: white;
|
||||
background-color: var(--background);
|
||||
color: var(--background);
|
||||
font-family: var(--ion-font-family);
|
||||
font-family: (--ion-font-family);
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -47,8 +51,7 @@ button {
|
||||
color: white;
|
||||
padding: 10px;
|
||||
width: 250px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 50px;
|
||||
margin-top: 100px;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
|
||||
@@ -15,6 +15,7 @@ export class EditAllowancePage implements OnInit {
|
||||
public goalId: number;
|
||||
public userId: number;
|
||||
public isAddMode: boolean;
|
||||
public selectedColor: string = '';
|
||||
public possibleColors: Array<string> = [
|
||||
'#6199D9',
|
||||
'#D98B61',
|
||||
@@ -73,6 +74,7 @@ export class EditAllowancePage implements OnInit {
|
||||
weight: allowance.weight,
|
||||
color: allowance.colour
|
||||
});
|
||||
this.selectedColor = this.form.value.color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EditTaskPageRoutingModule } from './edit-task-routing.module';
|
||||
|
||||
import { EditTaskPage } from './edit-task.page';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -16,7 +17,8 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
IonicModule,
|
||||
EditTaskPageRoutingModule,
|
||||
ReactiveFormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
MatSelectModule
|
||||
],
|
||||
declarations: [EditTaskPage]
|
||||
})
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
</div>
|
||||
<ion-title *ngIf="isAddMode">Create Task</ion-title>
|
||||
<ion-title *ngIf="!isAddMode">Edit Task</ion-title>
|
||||
<button
|
||||
*ngIf="!isAddMode"
|
||||
class="remove-button"
|
||||
(click)="deleteTask()"
|
||||
>Delete task</button>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
@@ -24,13 +19,18 @@
|
||||
<input id="reward" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="reward"/>
|
||||
|
||||
<label>Assigned</label>
|
||||
<select formControlName="assigned">
|
||||
<option *ngFor="let user of users" [value]="user.id">{{ user.name }}</option>
|
||||
</select>
|
||||
<mat-select formControlName="assigned">
|
||||
<mat-option *ngFor="let user of users" [value]="user.id">{{ user.name }}</mat-option>
|
||||
</mat-select>
|
||||
|
||||
<button type="button" [disabled]="!form.valid" (click)="submit()">
|
||||
<span *ngIf="isAddMode">Add Task</span>
|
||||
<span *ngIf="!isAddMode">Update Task</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="!isAddMode"
|
||||
class="remove-button"
|
||||
(click)="deleteTask()"
|
||||
>Delete task</button>
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-color: var(--ion-color-primary);
|
||||
margin-right: 15px;
|
||||
width: 95px;
|
||||
margin-bottom: 0;
|
||||
margin-top: 10px;
|
||||
background-color: var(--negative-amount-color);
|
||||
}
|
||||
|
||||
form {
|
||||
@@ -24,10 +22,15 @@ label {
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
mat-select {
|
||||
border: 1px solid var(--ion-color-primary);
|
||||
border-radius: 5px;
|
||||
width: 250px;
|
||||
height: 40px;
|
||||
padding-inline: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: (--ion-font-family);
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -36,8 +39,7 @@ button {
|
||||
color: white;
|
||||
padding: 10px;
|
||||
width: 250px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 50px;
|
||||
margin-top: 100px;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
.left {
|
||||
width: 70%;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.date {
|
||||
|
||||
@@ -18,8 +18,12 @@
|
||||
<div class="task" *ngFor="let task of tasks$ | async">
|
||||
<button (click)="completeTask(task.id)">Done</button>
|
||||
<div (click)="updateTask(task.id)" class="item">
|
||||
<div class="name">{{ task.name }}</div>
|
||||
<div class="assigned">{{ usernames[task.assigned ? task.assigned : 0] }}</div>
|
||||
<div class="text">
|
||||
<div class="name">
|
||||
{{ task.name }}
|
||||
<span class="assigned">{{ usernames[task.assigned ? task.assigned : 0] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="reward"
|
||||
[ngClass]="{ 'negative': task.reward < 0 }"
|
||||
|
||||
@@ -31,6 +31,8 @@ mat-icon {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line-color);
|
||||
padding: 5px;
|
||||
padding-block: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.item {
|
||||
@@ -41,7 +43,6 @@ mat-icon {
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-left: 10px;
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ mat-icon {
|
||||
margin-left: auto;
|
||||
margin-right: 15px;
|
||||
color: var(--positive-amount-color);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.negative {
|
||||
@@ -56,21 +58,28 @@ mat-icon {
|
||||
}
|
||||
|
||||
button {
|
||||
width: 57px;
|
||||
height: 30px;
|
||||
height: 45px;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
background: var(--confirm-button-color);
|
||||
padding-inline: 15px;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
background-color: var(--ion-color-primary);
|
||||
margin-right: 15px;
|
||||
width: 75px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.assigned {
|
||||
color: var(--line-color);
|
||||
margin-left: 3px;
|
||||
font-size: 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 60%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export class TasksPage implements ViewWillEnter {
|
||||
this.taskService.getTaskList().subscribe(tasks => {
|
||||
this.tasks$.next(tasks);
|
||||
});
|
||||
}, 50);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
createTask() {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
|
||||
ion-title {
|
||||
color: var(--ion-color-primary);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
ion-header {
|
||||
@@ -46,4 +47,25 @@ ion-header {
|
||||
|
||||
button {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
ion-header.md {
|
||||
ion-toolbar:first-child {
|
||||
--padding-top: 30px;
|
||||
--padding-bottom: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
ion-alert .alert-wrapper.sc-ion-alert-md {
|
||||
background-color: var(--ion-background-color) !important;
|
||||
--background: unset !important;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
ion-alert .alert-tappable.sc-ion-alert-md {
|
||||
background-color: var(--test-color);
|
||||
}
|
||||