Compare commits
	
		
			30 Commits
		
	
	
		
			d81796fde2
			...
			schedules
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1e463fec55 | |||
| 5a20e76df2 | |||
|   | 02c5c6ea68 | ||
|   | 9cbb8756d1 | ||
|   | 604b92b3b3 | ||
|   | c7236394d9 | ||
|   | 720ef83c2e | ||
|   | 5b1d107cac | ||
|   | 662257ebc5 | ||
|   | ad48882bca | ||
|   | 89d31fe150 | ||
|   | 305566c911 | ||
|   | 8c2af22c85 | ||
| a0d0c37fdb | |||
| 2714f550a4 | |||
|   | 344f7a7eef | ||
| 8380e95217 | |||
| db2f518cc2 | |||
|   | 56a19acd0f | ||
|   | 8fa4918743 | ||
|   | 11913d72aa | ||
|   | 45f40a7976 | ||
|   | 63982115a7 | ||
|   | e7b4adfa95 | ||
|   | 550933db11 | ||
|   | daebcdeccd | ||
| 302ceaa629 | |||
| 8cbfff81f6 | |||
| f9fb956efd | |||
| 5a233073c7 | 
| @@ -1,6 +1,8 @@ | ||||
| name: Backend Deploy | ||||
| on: | ||||
|   push: [main] | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
| @@ -17,9 +19,9 @@ jobs: | ||||
|       - name: Build | ||||
|         run: | | ||||
|           cd backend | ||||
|           docker build -t gitea.seeseepuff.be/seeseemelk/wolproxy:$(git rev-parse --short HEAD) . | ||||
|           docker build -t gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD) . | ||||
|  | ||||
|       - name: Push | ||||
|         run: | | ||||
|           cd backend | ||||
|           docker push gitea.seeseepuff.be/seeseemelk/wolproxy:$(git rev-parse --short HEAD) | ||||
|           docker push gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD) | ||||
|   | ||||
							
								
								
									
										1
									
								
								backend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,3 +1,4 @@ | ||||
| *.db3 | ||||
| *.db3-* | ||||
| *.db3.* | ||||
| /allowance_planner | ||||
|   | ||||
| @@ -9,14 +9,15 @@ import ( | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	TestAllowanceName = "Test History" | ||||
| 	TestHistoryName = "Test History" | ||||
| ) | ||||
|  | ||||
| func startServer(t *testing.T) *httpexpect.Expect { | ||||
| 	config := ServerConfig{ | ||||
| 		Datasource: ":memory:", | ||||
| 		Addr:       ":0", | ||||
| 		Started:    make(chan bool), | ||||
| 		//Datasource: "test.db", | ||||
| 		Addr:    ":0", | ||||
| 		Started: make(chan bool), | ||||
| 	} | ||||
| 	go start(t.Context(), &config) | ||||
| 	<-config.Started | ||||
| @@ -62,7 +63,7 @@ func TestGetUserAllowance(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"name":   TestHistoryName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 	} | ||||
| @@ -73,7 +74,7 @@ func TestGetUserAllowance(t *testing.T) { | ||||
| 	result.Length().IsEqual(2) | ||||
| 	item := result.Value(1).Object() | ||||
| 	item.Value("id").IsEqual(1) | ||||
| 	item.Value("name").IsEqual(TestAllowanceName) | ||||
| 	item.Value("name").IsEqual(TestHistoryName) | ||||
| 	item.Value("target").IsEqual(5000) | ||||
| 	item.Value("weight").IsEqual(10) | ||||
| 	item.Value("progress").IsEqual(0) | ||||
| @@ -95,7 +96,7 @@ func TestCreateUserAllowance(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"name":   TestHistoryName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 	} | ||||
| @@ -120,7 +121,7 @@ func TestCreateUserAllowance(t *testing.T) { | ||||
|  | ||||
| 	allowance := allowances.Value(1).Object() | ||||
| 	allowance.Value("id").IsEqual(allowanceId) | ||||
| 	allowance.Value("name").IsEqual(TestAllowanceName) | ||||
| 	allowance.Value("name").IsEqual(TestHistoryName) | ||||
| 	allowance.Value("target").IsEqual(5000) | ||||
| 	allowance.Value("weight").IsEqual(10) | ||||
| 	allowance.Value("progress").IsEqual(0) | ||||
| @@ -130,7 +131,7 @@ func TestCreateUserAllowanceNoUser(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"name":   TestHistoryName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 	} | ||||
| @@ -171,7 +172,7 @@ func TestCreateUserAllowanceBadId(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"name":   TestHistoryName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 	} | ||||
| @@ -187,7 +188,7 @@ func TestDeleteUserAllowance(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance to delete | ||||
| 	createRequest := map[string]interface{}{ | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"name":   TestHistoryName, | ||||
| 		"target": 1000, | ||||
| 		"weight": 5, | ||||
| 	} | ||||
| @@ -284,6 +285,54 @@ func TestCreateTask(t *testing.T) { | ||||
| 	responseWithUser.Value("id").Number().IsEqual(2) | ||||
| } | ||||
|  | ||||
| func TestCreateScheduleTask(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	// Create a new task without assigned user | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":     "Test Task", | ||||
| 		"reward":   100, | ||||
| 		"schedule": "0 */5 * * * *", | ||||
| 	} | ||||
|  | ||||
| 	response := e.POST("/tasks"). | ||||
| 		WithJSON(requestBody). | ||||
| 		Expect(). | ||||
| 		Status(201). // Expect Created status | ||||
| 		JSON().Object() | ||||
|  | ||||
| 	requestBody["schedule"] = "every 5 seconds" | ||||
| 	e.POST("/tasks").WithJSON(requestBody).Expect().Status(400) | ||||
|  | ||||
| 	// Verify the response has an ID | ||||
| 	response.ContainsKey("id") | ||||
| 	response.Value("id").Number().IsEqual(1) | ||||
|  | ||||
| 	e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) | ||||
|  | ||||
| 	// Get task | ||||
| 	result := e.GET("/task/1").Expect().Status(200).JSON().Object() | ||||
| 	result.Value("id").IsEqual(1) | ||||
| 	result.Value("name").IsEqual("Test Task") | ||||
| 	result.Value("schedule").IsEqual("0 */5 * * * *") | ||||
| 	result.Value("reward").IsEqual(100) | ||||
| 	result.Value("assigned").IsNull() | ||||
|  | ||||
| 	// Complete the task | ||||
| 	e.POST("/task/1/complete").Expect().Status(200) | ||||
|  | ||||
| 	// Set expires date to 1 second in the past | ||||
| 	db.db.Query("update tasks set next_run = ? where id = 1").Bind(time.Now().Add(10 * -time.Minute).Unix()).MustExec() | ||||
|  | ||||
| 	// Verify a new task is created | ||||
| 	newTask := e.GET("/task/2").Expect().Status(200).JSON().Object() | ||||
| 	newTask.Value("id").IsEqual(2) | ||||
| 	newTask.Value("name").IsEqual("Test Task") | ||||
| 	newTask.Value("schedule").IsEqual("0 */5 * * * *") | ||||
| 	newTask.Value("reward").IsEqual(100) | ||||
| 	newTask.Value("assigned").IsNull() | ||||
| } | ||||
|  | ||||
| func TestDeleteTask(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| @@ -434,37 +483,50 @@ func TestPutTaskInvalidTaskId(t *testing.T) { | ||||
| 	e.PUT("/task/999").WithJSON(requestBody).Expect().Status(404) | ||||
| } | ||||
|  | ||||
| func TestPostAllowance(t *testing.T) { | ||||
| func TestPostHistory(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100, Description: "Add a 100"}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20, Description: "Lolol"}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10, Description: "Subtracting"}).Expect().Status(200) | ||||
|  | ||||
| 	response := e.GET("/user/1").Expect().Status(200).JSON().Object() | ||||
| 	response.Value("allowance").Number().IsEqual(100 + 20 - 10) | ||||
| } | ||||
|  | ||||
| func TestPostAllowanceInvalidUserId(t *testing.T) { | ||||
| func TestPostHistoryInvalidUserId(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	e.POST("/user/999/history").WithJSON(PostHistory{Allowance: 100}).Expect(). | ||||
| 	e.POST("/user/999/history").WithJSON(PostHistory{Allowance: 100, Description: "Good"}).Expect(). | ||||
| 		Status(404) | ||||
| } | ||||
|  | ||||
| func TestPostHistoryInvalidDescription(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect(). | ||||
| 		Status(400) | ||||
| } | ||||
|  | ||||
| func TestGetHistory(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 100, Description: "Add 100"}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: 20, Description: "Add 20"}).Expect().Status(200) | ||||
| 	e.POST("/user/1/history").WithJSON(PostHistory{Allowance: -10, Description: "Subtract 10"}).Expect().Status(200) | ||||
|  | ||||
| 	response := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||
| 	response.Length().IsEqual(3) | ||||
| 	response.Value(0).Object().Length().IsEqual(3) | ||||
| 	response.Value(0).Object().Value("allowance").Number().IsEqual(100) | ||||
| 	response.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	response.Value(0).Object().Value("description").String().IsEqual("Add 100") | ||||
|  | ||||
| 	response.Value(1).Object().Value("allowance").Number().IsEqual(20) | ||||
| 	response.Value(1).Object().Value("description").String().IsEqual("Add 20") | ||||
|  | ||||
| 	response.Value(2).Object().Value("allowance").Number().IsEqual(-10) | ||||
| 	response.Value(2).Object().Value("description").String().IsEqual("Subtract 10") | ||||
| } | ||||
|  | ||||
| func TestGetUserAllowanceById(t *testing.T) { | ||||
| @@ -472,9 +534,10 @@ func TestGetUserAllowanceById(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"name":   TestHistoryName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 		"colour": "#FF5733", | ||||
| 	} | ||||
| 	resp := e.POST("/user/1/allowance").WithJSON(requestBody).Expect().Status(201).JSON().Object() | ||||
| 	allowanceId := int(resp.Value("id").Number().Raw()) | ||||
| @@ -482,10 +545,21 @@ func TestGetUserAllowanceById(t *testing.T) { | ||||
| 	// Retrieve the created allowance by ID | ||||
| 	result := e.GET("/user/1/allowance/" + strconv.Itoa(allowanceId)).Expect().Status(200).JSON().Object() | ||||
| 	result.Value("id").IsEqual(allowanceId) | ||||
| 	result.Value("name").IsEqual(TestAllowanceName) | ||||
| 	result.Value("name").IsEqual(TestHistoryName) | ||||
| 	result.Value("target").IsEqual(5000) | ||||
| 	result.Value("weight").IsEqual(10) | ||||
| 	result.Value("progress").IsEqual(0) | ||||
| 	result.Value("colour").IsEqual("#FF5733") | ||||
|  | ||||
| 	resultArray := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	resultArray.Length().IsEqual(2) | ||||
| 	result = resultArray.Value(1).Object() | ||||
| 	result.Value("id").IsEqual(allowanceId) | ||||
| 	result.Value("name").IsEqual(TestHistoryName) | ||||
| 	result.Value("target").IsEqual(5000) | ||||
| 	result.Value("weight").IsEqual(10) | ||||
| 	result.Value("progress").IsEqual(0) | ||||
| 	result.Value("colour").IsEqual("#FF5733") | ||||
| } | ||||
|  | ||||
| func TestGetUserByAllowanceIdInvalidAllowance(t *testing.T) { | ||||
| @@ -513,7 +587,7 @@ func TestPutAllowanceById(t *testing.T) { | ||||
|  | ||||
| 	// Create a new allowance | ||||
| 	requestBody := map[string]interface{}{ | ||||
| 		"name":   TestAllowanceName, | ||||
| 		"name":   TestHistoryName, | ||||
| 		"target": 5000, | ||||
| 		"weight": 10, | ||||
| 		"colour": "#FF5733", | ||||
| @@ -593,6 +667,36 @@ func TestCompleteTask(t *testing.T) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestCompleteTaskWithNoWeights(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
| 	taskId := createTestTaskWithAmount(e, 101) | ||||
|  | ||||
| 	e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1) | ||||
|  | ||||
| 	// Ensure main allowance has no weight | ||||
| 	e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ | ||||
| 		Weight: 0, | ||||
| 	}).Expect().Status(200) | ||||
|  | ||||
| 	// Complete the task | ||||
| 	e.POST("/task/" + strconv.Itoa(taskId) + "/complete").Expect().Status(200) | ||||
|  | ||||
| 	// Verify the task is marked as completed | ||||
| 	e.GET("/task/" + strconv.Itoa(taskId)).Expect().Status(404) | ||||
|  | ||||
| 	// Verify the allowances are updated for user 1 | ||||
| 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Length().IsEqual(1) | ||||
| 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().InDelta(101.00, 0.01) | ||||
|  | ||||
| 	// And also for user 2 | ||||
| 	allowances = e.GET("/user/2/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Length().IsEqual(1) | ||||
| 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().InDelta(101.00, 0.01) | ||||
| } | ||||
|  | ||||
| func TestCompleteTaskAllowanceWeightsSumTo0(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
| 	taskId := createTestTaskWithAmount(e, 101) | ||||
| @@ -631,6 +735,11 @@ func TestCompleteAllowance(t *testing.T) { | ||||
| 	createTestTaskWithAmount(e, 100) | ||||
| 	createTestAllowance(e, "Test Allowance 1", 100, 50) | ||||
|  | ||||
| 	// Update base allowance | ||||
| 	e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{ | ||||
| 		Weight: 0, | ||||
| 	}).Expect().Status(200) | ||||
|  | ||||
| 	// Complete the task | ||||
| 	e.POST("/task/1/complete").Expect().Status(200) | ||||
|  | ||||
| @@ -643,10 +752,15 @@ func TestCompleteAllowance(t *testing.T) { | ||||
| 	// Verify history is updated | ||||
| 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||
| 	history.Length().IsEqual(2) | ||||
| 	history.Value(0).Object().Length().IsEqual(3) | ||||
| 	history.Value(0).Object().Value("allowance").Number().IsEqual(100) | ||||
| 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	history.Value(0).Object().Value("description").String().IsEqual("Task completed: Test Task") | ||||
|  | ||||
| 	history.Value(1).Object().Length().IsEqual(3) | ||||
| 	history.Value(1).Object().Value("allowance").Number().IsEqual(-100) | ||||
| 	history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	history.Value(1).Object().Value("description").String().IsEqual("Allowance completed: Test Allowance 1") | ||||
| } | ||||
|  | ||||
| func TestCompleteAllowanceInvalidUserId(t *testing.T) { | ||||
| @@ -693,6 +807,145 @@ func TestPutBulkAllowance(t *testing.T) { | ||||
| 	allowances.Value(2).Object().Value("weight").Number().IsEqual(10) | ||||
| } | ||||
|  | ||||
| func TestAddAllowanceSimple(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	createTestAllowance(e, "Test Allowance 1", 1000, 1) | ||||
|  | ||||
| 	request := map[string]interface{}{ | ||||
| 		"amount":      10, | ||||
| 		"description": "Added to allowance 1", | ||||
| 	} | ||||
| 	e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200) | ||||
|  | ||||
| 	// Verify the allowance is updated | ||||
| 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Value(1).Object().Value("id").Number().IsEqual(1) | ||||
| 	allowances.Value(1).Object().Value("progress").Number().InDelta(10.0, 0.01) | ||||
|  | ||||
| 	// Verify the history is updated | ||||
| 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||
| 	history.Length().IsEqual(1) | ||||
| 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||
| 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||
| } | ||||
|  | ||||
| func TestAddAllowanceWithSpillage(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	createTestAllowance(e, "Test Allowance 1", 5, 1) | ||||
| 	createTestAllowance(e, "Test Allowance 2", 5, 1) | ||||
| 	e.PUT("/user/1/allowance/0").WithJSON(UpdateAllowanceRequest{Weight: 1}).Expect().Status(200) | ||||
|  | ||||
| 	request := map[string]interface{}{ | ||||
| 		"amount":      10, | ||||
| 		"description": "Added to allowance 1", | ||||
| 	} | ||||
| 	e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200) | ||||
|  | ||||
| 	// Verify the allowance is updated | ||||
| 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Value(1).Object().Value("id").Number().IsEqual(1) | ||||
| 	allowances.Value(1).Object().Value("progress").Number().InDelta(5.0, 0.01) | ||||
|  | ||||
| 	allowances.Value(2).Object().Value("id").Number().IsEqual(2) | ||||
| 	allowances.Value(2).Object().Value("progress").Number().InDelta(2.5, 0.01) | ||||
|  | ||||
| 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().InDelta(2.5, 0.01) | ||||
|  | ||||
| 	// Verify the history is updated | ||||
| 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||
| 	history.Length().IsEqual(1) | ||||
| 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||
| 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||
| } | ||||
|  | ||||
| func TestAddAllowanceIdZero(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	createTestAllowance(e, "Test Allowance 1", 1000, 1) | ||||
|  | ||||
| 	request := map[string]interface{}{ | ||||
| 		"amount":      10, | ||||
| 		"description": "Added to allowance 1", | ||||
| 	} | ||||
| 	e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200) | ||||
|  | ||||
| 	// Verify the allowance is updated | ||||
| 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().InDelta(10.0, 0.01) | ||||
|  | ||||
| 	// Verify the history is updated | ||||
| 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||
| 	history.Length().IsEqual(1) | ||||
| 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||
| 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||
| } | ||||
|  | ||||
| func TestSubtractAllowanceSimple(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	createTestAllowance(e, "Test Allowance 1", 1000, 1) | ||||
|  | ||||
| 	request := map[string]interface{}{ | ||||
| 		"amount":      10, | ||||
| 		"description": "Added to allowance 1", | ||||
| 	} | ||||
| 	e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200) | ||||
| 	request["amount"] = -2.5 | ||||
| 	e.POST("/user/1/allowance/1/add").WithJSON(request).Expect().Status(200) | ||||
|  | ||||
| 	// Verify the allowance is updated | ||||
| 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Value(1).Object().Value("id").Number().IsEqual(1) | ||||
| 	allowances.Value(1).Object().Value("progress").Number().InDelta(7.5, 0.01) | ||||
|  | ||||
| 	// Verify the history is updated | ||||
| 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||
| 	history.Length().IsEqual(2) | ||||
| 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||
| 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||
|  | ||||
| 	history.Value(1).Object().Value("allowance").Number().InDelta(-2.5, 0.01) | ||||
| 	history.Value(1).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	history.Value(1).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||
| } | ||||
|  | ||||
| func TestSubtractllowanceIdZero(t *testing.T) { | ||||
| 	e := startServer(t) | ||||
|  | ||||
| 	createTestAllowance(e, "Test Allowance 1", 1000, 1) | ||||
|  | ||||
| 	request := map[string]interface{}{ | ||||
| 		"amount":      10, | ||||
| 		"description": "Added to allowance 1", | ||||
| 	} | ||||
| 	e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200) | ||||
| 	request["amount"] = -2.5 | ||||
| 	e.POST("/user/1/allowance/0/add").WithJSON(request).Expect().Status(200) | ||||
|  | ||||
| 	// Verify the allowance is updated | ||||
| 	allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array() | ||||
| 	allowances.Value(0).Object().Value("id").Number().IsEqual(0) | ||||
| 	allowances.Value(0).Object().Value("progress").Number().InDelta(7.5, 0.01) | ||||
|  | ||||
| 	// Verify the history is updated | ||||
| 	history := e.GET("/user/1/history").Expect().Status(200).JSON().Array() | ||||
| 	history.Length().IsEqual(2) | ||||
| 	history.Value(0).Object().Value("allowance").Number().InDelta(10.0, 0.01) | ||||
| 	history.Value(0).Object().Value("timestamp").String().AsDateTime().InRange(getDelta(time.Now(), 2.0)) | ||||
| 	history.Value(0).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||
|  | ||||
| 	history.Value(1).Object().Value("allowance").Number().InDelta(-2.5, 0.01) | ||||
| 	history.Value(1).Object().Value("description").String().IsEqual("Added to allowance 1") | ||||
| } | ||||
|  | ||||
| func getDelta(base time.Time, delta float64) (time.Time, time.Time) { | ||||
| 	start := base.Add(-time.Duration(delta) * time.Second) | ||||
| 	end := base.Add(time.Duration(delta) * time.Second) | ||||
|   | ||||
| @@ -28,3 +28,7 @@ func ConvertStringToColour(colourStr string) (int, error) { | ||||
| 	} | ||||
| 	return colour, nil | ||||
| } | ||||
|  | ||||
| func ConvertColourToString(colour int) string { | ||||
| 	return fmt.Sprintf("#%06X", colour) | ||||
| } | ||||
|   | ||||
							
								
								
									
										317
									
								
								backend/db.go
									
									
									
									
									
								
							
							
						
						| @@ -3,6 +3,7 @@ package main | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"github.com/adhocore/gronx" | ||||
| 	"log" | ||||
| 	"math" | ||||
| 	"time" | ||||
| @@ -84,13 +85,14 @@ func (db *Db) GetUserAllowances(userId int) ([]Allowance, error) { | ||||
| 	totalAllowance.Progress = float64(progress) / 100.0 | ||||
| 	allowances = append(allowances, totalAllowance) | ||||
|  | ||||
| 	for row := range db.db.Query("select id, name, target, balance, weight from allowances where user_id = ?"). | ||||
| 	for row := range db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ?"). | ||||
| 		Bind(userId).Range(&err) { | ||||
| 		allowance := Allowance{} | ||||
| 		var target, progress int | ||||
| 		err = row.Scan(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight) | ||||
| 		var target, progress, colour int | ||||
| 		err = row.Scan(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour) | ||||
| 		allowance.Target = float64(target) / 100.0 | ||||
| 		allowance.Progress = float64(progress) / 100.0 | ||||
| 		allowance.Colour = ConvertColourToString(colour) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @@ -113,13 +115,14 @@ func (db *Db) GetUserAllowanceById(userId int, allowanceId int) (*Allowance, err | ||||
| 			return nil, err | ||||
| 		} | ||||
| 	} else { | ||||
| 		var target, progress, colour int64 | ||||
| 		var target, progress int64 | ||||
| 		var colour int | ||||
| 		err := db.db.Query("select id, name, target, balance, weight, colour from allowances where user_id = ? and id = ?"). | ||||
| 			Bind(userId, allowanceId). | ||||
| 			ScanSingle(&allowance.ID, &allowance.Name, &target, &progress, &allowance.Weight, &colour) | ||||
| 		allowance.Target = float64(target) / 100.0 | ||||
| 		allowance.Progress = float64(progress) / 100.0 | ||||
| 		allowance.Colour = fmt.Sprintf("#%06X", colour) | ||||
| 		allowance.Colour = ConvertColourToString(colour) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @@ -205,8 +208,9 @@ func (db *Db) CompleteAllowance(userId int, allowanceId int) error { | ||||
|  | ||||
| 	// Get the cost of the allowance | ||||
| 	var cost int | ||||
| 	err = tx.Query("select balance from allowances where id = ? and user_id = ?"). | ||||
| 		Bind(allowanceId, userId).ScanSingle(&cost) | ||||
| 	var allowanceName string | ||||
| 	err = tx.Query("select balance, name from allowances where id = ? and user_id = ?"). | ||||
| 		Bind(allowanceId, userId).ScanSingle(&cost, &allowanceName) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| @@ -219,8 +223,8 @@ func (db *Db) CompleteAllowance(userId int, allowanceId int) error { | ||||
| 	} | ||||
|  | ||||
| 	// Add a history entry | ||||
| 	err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). | ||||
| 		Bind(userId, time.Now().Unix(), -cost). | ||||
| 	err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)"). | ||||
| 		Bind(userId, time.Now().Unix(), -cost, fmt.Sprintf("Allowance completed: %s", allowanceName)). | ||||
| 		Exec() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| @@ -310,10 +314,20 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	var nextRun *int64 | ||||
| 	if task.Schedule != nil { | ||||
| 		nextRunTime, err := gronx.NextTick(*task.Schedule, false) | ||||
| 		if err != nil { | ||||
| 			return 0, fmt.Errorf("failed to calculate next run: %w", err) | ||||
| 		} | ||||
| 		nextRunTimeAsInt := nextRunTime.Unix() | ||||
| 		nextRun = &nextRunTimeAsInt | ||||
| 	} | ||||
|  | ||||
| 	// Insert the new task | ||||
| 	reward := int(math.Round(task.Reward * 100.0)) | ||||
| 	err = tx.Query("insert into tasks (name, reward, assigned) values (?, ?, ?)"). | ||||
| 		Bind(task.Name, reward, task.Assigned). | ||||
| 	err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) values (?, ?, ?, ?, ?)"). | ||||
| 		Bind(task.Name, reward, task.Assigned, task.Schedule, nextRun). | ||||
| 		Exec() | ||||
|  | ||||
| 	if err != nil { | ||||
| @@ -337,13 +351,17 @@ func (db *Db) CreateTask(task *CreateTaskRequest) (int, error) { | ||||
| } | ||||
|  | ||||
| func (db *Db) GetTasks() ([]Task, error) { | ||||
| 	tasks := make([]Task, 0) | ||||
| 	var err error | ||||
| 	err := db.UpdateScheduledTasks() | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to update scheduled tasks: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	for row := range db.db.Query("select id, name, reward, assigned from tasks").Range(&err) { | ||||
| 	tasks := make([]Task, 0) | ||||
|  | ||||
| 	for row := range db.db.Query("select id, name, reward, assigned, schedule from tasks where completed is null").Range(&err) { | ||||
| 		task := Task{} | ||||
| 		var reward int64 | ||||
| 		err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned) | ||||
| 		err = row.Scan(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) | ||||
| 		task.Reward = float64(reward) / 100.0 | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| @@ -359,16 +377,78 @@ func (db *Db) GetTasks() ([]Task, error) { | ||||
| func (db *Db) GetTask(id int) (Task, error) { | ||||
| 	task := Task{} | ||||
|  | ||||
| 	var reward int64 | ||||
| 	err := db.db.Query("select id, name, reward, assigned from tasks where id = ?"). | ||||
| 		Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned) | ||||
| 	task.Reward = float64(reward) / 100.0 | ||||
| 	err := db.UpdateScheduledTasks() | ||||
| 	if err != nil { | ||||
| 		return Task{}, err | ||||
| 		return Task{}, fmt.Errorf("failed to update scheduled tasks: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var reward int64 | ||||
| 	err = db.db.Query("select id, name, reward, assigned, schedule from tasks where id = ? and completed is null"). | ||||
| 		Bind(id).ScanSingle(&task.ID, &task.Name, &reward, &task.Assigned, &task.Schedule) | ||||
| 	if err != nil { | ||||
| 		return task, err | ||||
| 	} | ||||
| 	task.Reward = float64(reward) / 100.0 | ||||
| 	return task, nil | ||||
| } | ||||
|  | ||||
| func (db *Db) UpdateScheduledTasks() error { | ||||
| 	type ScheduledTask struct { | ||||
| 		ID       int | ||||
| 		Schedule string | ||||
| 		Expires  int64 | ||||
| 	} | ||||
| 	tasks := make([]ScheduledTask, 0) | ||||
| 	var err error | ||||
|  | ||||
| 	for row := range db.db.Query("select id, schedule, next_run from tasks where schedule is not null").Range(&err) { | ||||
| 		task := ScheduledTask{} | ||||
| 		err := row.Scan(&task.ID, &task.Schedule, &task.Expires) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if time.Now().Unix() >= task.Expires { | ||||
| 			tasks = append(tasks, task) | ||||
| 		} | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to fetch scheduled tasks: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	tx, err := db.db.Begin() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	for _, task := range tasks { | ||||
| 		nextRun, err := gronx.NextTickAfter(task.Schedule, time.Now(), false) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to calculate next run for task %d: %w", task.ID, err) | ||||
| 		} | ||||
|  | ||||
| 		err = tx.Query("insert into tasks (name, reward, assigned, schedule, next_run) select name, reward, assigned, schedule, ? from tasks where id = ?"). | ||||
| 			Bind(nextRun.Unix(), task.ID). | ||||
| 			Exec() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		err = tx.Query("update tasks set schedule = null where id = ?").Bind(task.ID).Exec() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		tx.Query("select last_insert_rowid()").MustScanSingle(&task.ID) | ||||
| 		log.Printf("Task %d scheduled for %s", task.ID, nextRun) | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return tx.Commit() | ||||
| } | ||||
|  | ||||
| func (db *Db) DeleteTask(id int) error { | ||||
| 	tx, err := db.db.Begin() | ||||
| 	if err != nil { | ||||
| @@ -419,63 +499,28 @@ func (db *Db) CompleteTask(taskId int) error { | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	var reward int | ||||
| 	err = tx.Query("select reward from tasks where id = ?").Bind(taskId).ScanSingle(&reward) | ||||
| 	var rewardName string | ||||
| 	err = tx.Query("select reward, name from tasks where id = ?").Bind(taskId).ScanSingle(&reward, &rewardName) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	for userRow := range tx.Query("select id, weight from users").Range(&err) { | ||||
| 	for userRow := range tx.Query("select id from users").Range(&err) { | ||||
| 		var userId int | ||||
| 		var userWeight float64 | ||||
| 		err = userRow.Scan(&userId, &userWeight) | ||||
| 		err = userRow.Scan(&userId) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		// Add the history entry | ||||
| 		err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). | ||||
| 			Bind(userId, time.Now().Unix(), reward). | ||||
| 		err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)"). | ||||
| 			Bind(userId, time.Now().Unix(), reward, fmt.Sprintf("Task completed: %s", rewardName)). | ||||
| 			Exec() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		// Calculate the sums of all weights | ||||
| 		var sumOfWeights float64 | ||||
| 		err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights) | ||||
| 		sumOfWeights += userWeight | ||||
|  | ||||
| 		remainingReward := reward | ||||
|  | ||||
| 		if sumOfWeights > 0 { | ||||
| 			// Distribute the reward to the allowances | ||||
| 			for allowanceRow := range tx.Query("select id, weight, target, balance from allowances where user_id = ? and weight > 0 order by (target - balance) asc").Bind(userId).Range(&err) { | ||||
| 				var allowanceId, allowanceTarget, allowanceBalance int | ||||
| 				var allowanceWeight float64 | ||||
| 				err = allowanceRow.Scan(&allowanceId, &allowanceWeight, &allowanceTarget, &allowanceBalance) | ||||
| 				if err != nil { | ||||
| 					return err | ||||
| 				} | ||||
|  | ||||
| 				// Calculate the amount to add to the allowance | ||||
| 				amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) | ||||
| 				if allowanceBalance+amount > allowanceTarget { | ||||
| 					// If the amount reaches past the target, set it to the target | ||||
| 					amount = allowanceTarget - allowanceBalance | ||||
| 				} | ||||
| 				sumOfWeights -= allowanceWeight | ||||
| 				err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). | ||||
| 					Bind(amount, allowanceId, userId).Exec() | ||||
| 				if err != nil { | ||||
| 					return err | ||||
| 				} | ||||
| 				remainingReward -= amount | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Add the remaining reward to the user | ||||
| 		err = tx.Query("update users set balance = balance + ? where id = ?"). | ||||
| 			Bind(remainingReward, userId).Exec() | ||||
| 		err := db.addDistributedReward(tx, userId, reward) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| @@ -485,11 +530,60 @@ func (db *Db) CompleteTask(taskId int) error { | ||||
| 	} | ||||
|  | ||||
| 	// Remove the task | ||||
| 	err = tx.Query("delete from tasks where id = ?").Bind(taskId).Exec() | ||||
| 	err = tx.Query("update tasks set completed=? where id = ?").Bind(time.Now().Unix(), taskId).Exec() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return tx.Commit() | ||||
| } | ||||
|  | ||||
| func (db *Db) addDistributedReward(tx *mysqlite.Tx, userId int, reward int) error { | ||||
| 	var userWeight float64 | ||||
| 	err := tx.Query("select weight from users where id = ?").Bind(userId).ScanSingle(&userWeight) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Calculate the sums of all weights | ||||
| 	var sumOfWeights float64 | ||||
| 	err = tx.Query("select sum(weight) from allowances where user_id = ? and weight > 0").Bind(userId).ScanSingle(&sumOfWeights) | ||||
| 	sumOfWeights += userWeight | ||||
|  | ||||
| 	remainingReward := reward | ||||
|  | ||||
| 	if sumOfWeights > 0 { | ||||
| 		// Distribute the reward to the allowances | ||||
| 		for allowanceRow := range tx.Query("select id, weight, target, balance from allowances where user_id = ? and weight > 0 order by (target - balance) asc").Bind(userId).Range(&err) { | ||||
| 			var allowanceId, allowanceTarget, allowanceBalance int | ||||
| 			var allowanceWeight float64 | ||||
| 			err = allowanceRow.Scan(&allowanceId, &allowanceWeight, &allowanceTarget, &allowanceBalance) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
|  | ||||
| 			// Calculate the amount to add to the allowance | ||||
| 			amount := int((allowanceWeight / sumOfWeights) * float64(remainingReward)) | ||||
| 			if allowanceBalance+amount > allowanceTarget { | ||||
| 				// If the amount reaches past the target, set it to the target | ||||
| 				amount = allowanceTarget - allowanceBalance | ||||
| 			} | ||||
| 			sumOfWeights -= allowanceWeight | ||||
| 			err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). | ||||
| 				Bind(amount, allowanceId, userId).Exec() | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			remainingReward -= amount | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Add the remaining reward to the user | ||||
| 	err = tx.Query("update users set balance = balance + ? where id = ?"). | ||||
| 		Bind(remainingReward, userId).Exec() | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (db *Db) AddHistory(userId int, allowance *PostHistory) error { | ||||
| 	tx, err := db.db.Begin() | ||||
| 	if err != nil { | ||||
| @@ -498,8 +592,8 @@ func (db *Db) AddHistory(userId int, allowance *PostHistory) error { | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	amount := int(math.Round(allowance.Allowance * 100.0)) | ||||
| 	err = tx.Query("insert into history (user_id, timestamp, amount) values (?, ?, ?)"). | ||||
| 		Bind(userId, time.Now().Unix(), amount). | ||||
| 	err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)"). | ||||
| 		Bind(userId, time.Now().Unix(), amount, allowance.Description). | ||||
| 		Exec() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| @@ -511,11 +605,11 @@ func (db *Db) GetHistory(userId int) ([]History, error) { | ||||
| 	history := make([]History, 0) | ||||
| 	var err error | ||||
|  | ||||
| 	for row := range db.db.Query("select amount, `timestamp` from history where user_id = ? order by `timestamp` desc"). | ||||
| 	for row := range db.db.Query("select amount, `timestamp`, description from history where user_id = ? order by `timestamp` desc"). | ||||
| 		Bind(userId).Range(&err) { | ||||
| 		allowance := History{} | ||||
| 		var timestamp, amount int64 | ||||
| 		err = row.Scan(&amount, ×tamp) | ||||
| 		err = row.Scan(&amount, ×tamp, &allowance.Description) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| @@ -528,3 +622,92 @@ func (db *Db) GetHistory(userId int) ([]History, error) { | ||||
| 	} | ||||
| 	return history, nil | ||||
| } | ||||
|  | ||||
| func (db *Db) AddAllowanceAmount(userId int, allowanceId int, request AddAllowanceAmountRequest) error { | ||||
| 	tx, err := db.db.Begin() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer tx.MustRollback() | ||||
|  | ||||
| 	// Convert amount to integer (cents) | ||||
| 	remainingAmount := int(math.Round(request.Amount * 100)) | ||||
|  | ||||
| 	// Insert history entry | ||||
| 	err = tx.Query("insert into history (user_id, timestamp, amount, description) values (?, ?, ?, ?)"). | ||||
| 		Bind(userId, time.Now().Unix(), remainingAmount, request.Description). | ||||
| 		Exec() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if allowanceId == 0 { | ||||
| 		if remainingAmount < 0 { | ||||
| 			var userBalance int | ||||
| 			err = tx.Query("select balance from users where id = ?"). | ||||
| 				Bind(userId).ScanSingle(&userBalance) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			if remainingAmount > userBalance { | ||||
| 				return fmt.Errorf("cannot remove more than the current balance: %d", userBalance) | ||||
| 			} | ||||
| 		} | ||||
| 		err = tx.Query("update users set balance = balance + ? where id = ?"). | ||||
| 			Bind(remainingAmount, userId).Exec() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} else if remainingAmount < 0 { | ||||
| 		var progress int | ||||
| 		err = tx.Query("select balance from allowances where id = ? and user_id = ?"). | ||||
| 			Bind(allowanceId, userId).ScanSingle(&progress) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		if remainingAmount > progress { | ||||
| 			return fmt.Errorf("cannot remove more than the current allowance balance: %d", progress) | ||||
| 		} | ||||
|  | ||||
| 		err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). | ||||
| 			Bind(remainingAmount, allowanceId, userId).Exec() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} else { | ||||
| 		// Fetch the target and progress of the specified allowance | ||||
| 		var target, progress int | ||||
| 		err = tx.Query("select target, balance from allowances where id = ? and user_id = ?"). | ||||
| 			Bind(allowanceId, userId).ScanSingle(&target, &progress) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		// Calculate the amount to add to the current allowance | ||||
| 		toAdd := remainingAmount | ||||
| 		if progress+toAdd > target { | ||||
| 			toAdd = target - progress | ||||
| 		} | ||||
| 		remainingAmount -= toAdd | ||||
|  | ||||
| 		// Update the current allowance | ||||
| 		if toAdd > 0 { | ||||
| 			err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?"). | ||||
| 				Bind(toAdd, allowanceId, userId).Exec() | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// If there's remaining amount, distribute it to the user's allowances | ||||
| 		if remainingAmount > 0 { | ||||
| 			err = db.addDistributedReward(tx, userId, remainingAmount) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return tx.Commit() | ||||
| } | ||||
|   | ||||
| @@ -14,12 +14,14 @@ type UserWithAllowance struct { | ||||
| } | ||||
|  | ||||
| type History struct { | ||||
| 	Allowance float64   `json:"allowance"` | ||||
| 	Timestamp time.Time `json:"timestamp"` | ||||
| 	Allowance   float64   `json:"allowance"` | ||||
| 	Timestamp   time.Time `json:"timestamp"` | ||||
| 	Description string    `json:"description"` | ||||
| } | ||||
|  | ||||
| type PostHistory struct { | ||||
| 	Allowance float64 `json:"allowance"` | ||||
| 	Allowance   float64 `json:"allowance"` | ||||
| 	Description string  `json:"description"` | ||||
| } | ||||
|  | ||||
| // Task represents a task in the system. | ||||
| @@ -27,7 +29,8 @@ type Task struct { | ||||
| 	ID       int     `json:"id"` | ||||
| 	Name     string  `json:"name"` | ||||
| 	Reward   float64 `json:"reward"` | ||||
| 	Assigned *int    `json:"assigned"` // Pointer to allow null | ||||
| 	Assigned *int    `json:"assigned"` | ||||
| 	Schedule *string `json:"schedule"` | ||||
| } | ||||
|  | ||||
| type Allowance struct { | ||||
| @@ -66,8 +69,14 @@ type CreateTaskRequest struct { | ||||
| 	Name     string  `json:"name" binding:"required"` | ||||
| 	Reward   float64 `json:"reward"` | ||||
| 	Assigned *int    `json:"assigned"` | ||||
| 	Schedule *string `json:"schedule"` | ||||
| } | ||||
|  | ||||
| type CreateTaskResponse struct { | ||||
| 	ID int `json:"id"` | ||||
| } | ||||
|  | ||||
| type AddAllowanceAmountRequest struct { | ||||
| 	Amount      float64 `json:"amount"` | ||||
| 	Description string  `json:"description"` | ||||
| } | ||||
|   | ||||
| @@ -6,11 +6,12 @@ require ( | ||||
| 	gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 | ||||
| 	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/gin-gonic/gin v1.10.1 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect | ||||
| 	github.com/adhocore/gronx v1.19.6 // indirect | ||||
| 	github.com/ajg/form v1.5.1 // indirect | ||||
| 	github.com/andybalholm/brotli v1.1.1 // indirect | ||||
| 	github.com/bytedance/sonic v1.13.2 // indirect | ||||
| @@ -49,7 +50,7 @@ require ( | ||||
| 	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/ugorji/go/codec v1.2.14 // indirect | ||||
| 	github.com/valyala/bytebufferpool v1.0.0 // indirect | ||||
| 	github.com/valyala/fasthttp v1.62.0 // indirect | ||||
| 	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | ||||
| @@ -68,10 +69,10 @@ require ( | ||||
| 	gopkg.in/fsnotify.v1 v1.4.7 // indirect | ||||
| 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| 	modernc.org/libc v1.65.7 // indirect | ||||
| 	modernc.org/libc v1.65.8 // indirect | ||||
| 	modernc.org/mathutil v1.7.1 // indirect | ||||
| 	modernc.org/memory v1.11.0 // indirect | ||||
| 	modernc.org/sqlite v1.37.0 // indirect | ||||
| 	modernc.org/sqlite v1.37.1 // indirect | ||||
| 	moul.io/http2curl/v2 v2.3.0 // indirect | ||||
| 	zombiezen.com/go/sqlite v1.4.0 // indirect | ||||
| 	zombiezen.com/go/sqlite v1.4.2 // 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= | ||||
| @@ -34,6 +36,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w | ||||
| github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= | ||||
| github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= | ||||
| github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||||
| github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= | ||||
| github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||
| github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | ||||
| github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | ||||
| @@ -127,6 +131,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS | ||||
| github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||||
| github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | ||||
| github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||
| github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw= | ||||
| github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||
| github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | ||||
| github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | ||||
| github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= | ||||
| @@ -216,6 +222,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | ||||
| modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | ||||
| modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= | ||||
| modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= | ||||
| modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q= | ||||
| modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= | ||||
| modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | ||||
| modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | ||||
| modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | ||||
| @@ -226,6 +234,8 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | ||||
| modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | ||||
| modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= | ||||
| modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= | ||||
| modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= | ||||
| modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= | ||||
| modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | ||||
| modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | ||||
| modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | ||||
| @@ -235,3 +245,5 @@ moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHc | ||||
| nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | ||||
| zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= | ||||
| zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= | ||||
| zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= | ||||
| zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= | ||||
|   | ||||
| @@ -4,7 +4,9 @@ import ( | ||||
| 	"context" | ||||
| 	"embed" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"gitea.seeseepuff.be/seeseemelk/mysqlite" | ||||
| 	"github.com/adhocore/gronx" | ||||
| 	"log" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| @@ -43,6 +45,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 { | ||||
| @@ -368,6 +375,56 @@ func completeAllowance(c *gin.Context) { | ||||
| 	c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"}) | ||||
| } | ||||
|  | ||||
| func addToAllowance(c *gin.Context) { | ||||
| 	userIdStr := c.Param("userId") | ||||
| 	allowanceIdStr := c.Param("allowanceId") | ||||
|  | ||||
| 	userId, err := strconv.Atoi(userIdStr) | ||||
| 	if err != nil { | ||||
| 		log.Printf(ErrInvalidUserID+": %v", err) | ||||
| 		c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidUserID}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	allowanceId, err := strconv.Atoi(allowanceIdStr) | ||||
| 	if err != nil { | ||||
| 		log.Printf("Invalid allowance ID: %v", err) | ||||
| 		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid allowance ID"}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	exists, err := db.UserExists(userId) | ||||
| 	if err != nil { | ||||
| 		log.Printf(ErrCheckingUserExist, err) | ||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) | ||||
| 		return | ||||
| 	} | ||||
| 	if !exists { | ||||
| 		c.JSON(http.StatusNotFound, gin.H{"error": ErrUserNotFound}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	var allowanceRequest AddAllowanceAmountRequest | ||||
| 	if err := c.ShouldBindJSON(&allowanceRequest); err != nil { | ||||
| 		log.Printf("Error parsing request body: %v", err) | ||||
| 		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	err = db.AddAllowanceAmount(userId, allowanceId, allowanceRequest) | ||||
| 	if errors.Is(err, mysqlite.ErrNoRows) { | ||||
| 		c.JSON(http.StatusNotFound, gin.H{"error": "Allowance not found"}) | ||||
| 		return | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		log.Printf("Error completing allowance: %v", err) | ||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.IndentedJSON(http.StatusOK, gin.H{"message": "Allowance completed successfully"}) | ||||
| } | ||||
|  | ||||
| func createTask(c *gin.Context) { | ||||
| 	var taskRequest CreateTaskRequest | ||||
| 	if err := c.ShouldBindJSON(&taskRequest); err != nil { | ||||
| @@ -381,6 +438,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) | ||||
| @@ -458,6 +523,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 { | ||||
| @@ -539,6 +609,11 @@ func postHistory(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if historyRequest.Description == "" { | ||||
| 		c.JSON(http.StatusBadRequest, gin.H{"error": "Description cannot be empty"}) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	exists, err := db.UserExists(userId) | ||||
| 	if err != nil { | ||||
| 		log.Printf(ErrCheckingUserExist, err) | ||||
| @@ -606,6 +681,7 @@ func start(ctx context.Context, config *ServerConfig) { | ||||
| 	router.DELETE("/api/user/:userId/allowance/:allowanceId", deleteUserAllowance) | ||||
| 	router.PUT("/api/user/:userId/allowance/:allowanceId", putUserAllowance) | ||||
| 	router.POST("/api/user/:userId/allowance/:allowanceId/complete", completeAllowance) | ||||
| 	router.POST("/api/user/:userId/allowance/:allowanceId/add", addToAllowance) | ||||
| 	router.POST("/api/tasks", createTask) | ||||
| 	router.GET("/api/tasks", getTasks) | ||||
| 	router.GET("/api/task/:taskId", getTask) | ||||
| @@ -650,5 +726,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) | ||||
| } | ||||
|   | ||||
| @@ -2,7 +2,7 @@ create table users | ||||
| ( | ||||
|     id   integer primary key, | ||||
|     name text not null, | ||||
|     weight real not null default 0.0, | ||||
|     weight real not null default 10.0, | ||||
|     balance integer not null default 0 | ||||
| ) strict; | ||||
|  | ||||
|   | ||||
							
								
								
									
										1
									
								
								backend/migrations/3_change_weight_default.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| update users set weight = 10.0 where weight = 0.0; | ||||
							
								
								
									
										2
									
								
								backend/migrations/4_add_history_description.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | ||||
| alter table history | ||||
| add column description text; | ||||
							
								
								
									
										3
									
								
								backend/migrations/5_add_schedules.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| alter table tasks add column schedule text; | ||||
| alter table tasks add column completed date; | ||||
| alter table tasks add column next_run date; | ||||
| @@ -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) { | ||||
| @@ -59,16 +71,24 @@ func renderCreateTask(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	_, err = db.CreateTask(&CreateTaskRequest{ | ||||
| 	request := &CreateTaskRequest{ | ||||
| 		Name:   name, | ||||
| 		Reward: reward, | ||||
| 	}) | ||||
| 	} | ||||
|  | ||||
| 	schedule := c.PostForm("schedule") | ||||
| 	if schedule != "" { | ||||
| 		request.Schedule = &schedule | ||||
| 	} | ||||
| 	 | ||||
| 	_, err = db.CreateTask(request) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		renderError(c, http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPageStatus(c, "/", http.StatusFound) | ||||
| } | ||||
|  | ||||
| func renderCompleteTask(c *gin.Context) { | ||||
| @@ -85,7 +105,7 @@ func renderCompleteTask(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPageStatus(c, "/", http.StatusFound) | ||||
| } | ||||
|  | ||||
| func renderCreateAllowance(c *gin.Context) { | ||||
| @@ -122,7 +142,7 @@ func renderCreateAllowance(c *gin.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.Redirect(http.StatusFound, "/") | ||||
| 	redirectToPageStatus(c, "/", http.StatusFound) | ||||
| } | ||||
|  | ||||
| func renderCompleteAllowance(c *gin.Context) { | ||||
| @@ -144,11 +164,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 +193,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}} | ||||
| @@ -79,6 +81,7 @@ | ||||
| 					<th>Name</th> | ||||
| 					<th>Assigned</th> | ||||
| 					<th>Reward</th> | ||||
| 					<th>Schedule</th> | ||||
| 					<th>Actions</th> | ||||
| 				</tr> | ||||
| 				</thead> | ||||
| @@ -94,6 +97,7 @@ | ||||
| 							{{end}} | ||||
| 						</td> | ||||
| 						<td>{{.Reward}}</td> | ||||
| 						<td>{{.Schedule}}</td> | ||||
| 						<td> | ||||
| 							<a href="/completeTask?task={{.ID}}">Mark as completed</a> | ||||
| 						</td> | ||||
| @@ -103,7 +107,8 @@ | ||||
| 							<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><label><input type="text" name="schedule" placeholder="Schedule"></label></td> | ||||
| 							<td><input type="submit" value="Create"></td> | ||||
| 						</tr> | ||||
| 				</tbody> | ||||
| 			</table> | ||||
|   | ||||
| @@ -422,7 +422,10 @@ components: | ||||
|           description: The task name | ||||
|         reward: | ||||
|           type: integer | ||||
|           description: The task reward, in cents | ||||
|           description: The task reward | ||||
|         schedule: | ||||
|           type: string | ||||
|           description: The schedule of the task, in cron format | ||||
|         assigned: | ||||
|           type: integer | ||||
|           description: The user ID of the user assigned to the task | ||||
|   | ||||
							
								
								
									
										101
									
								
								frontend/allowance-planner-v2/android/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,101 @@ | ||||
| # Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore | ||||
|  | ||||
| # Built application files | ||||
| *.apk | ||||
| *.aar | ||||
| *.ap_ | ||||
| *.aab | ||||
|  | ||||
| # Files for the ART/Dalvik VM | ||||
| *.dex | ||||
|  | ||||
| # Java class files | ||||
| *.class | ||||
|  | ||||
| # Generated files | ||||
| bin/ | ||||
| gen/ | ||||
| out/ | ||||
| #  Uncomment the following line in case you need and you don't have the release build type files in your app | ||||
| # release/ | ||||
|  | ||||
| # Gradle files | ||||
| .gradle/ | ||||
| build/ | ||||
|  | ||||
| # Local configuration file (sdk path, etc) | ||||
| local.properties | ||||
|  | ||||
| # Proguard folder generated by Eclipse | ||||
| proguard/ | ||||
|  | ||||
| # Log Files | ||||
| *.log | ||||
|  | ||||
| # Android Studio Navigation editor temp files | ||||
| .navigation/ | ||||
|  | ||||
| # Android Studio captures folder | ||||
| captures/ | ||||
|  | ||||
| # IntelliJ | ||||
| *.iml | ||||
| .idea/workspace.xml | ||||
| .idea/tasks.xml | ||||
| .idea/gradle.xml | ||||
| .idea/assetWizardSettings.xml | ||||
| .idea/dictionaries | ||||
| .idea/libraries | ||||
| # Android Studio 3 in .gitignore file. | ||||
| .idea/caches | ||||
| .idea/modules.xml | ||||
| # Comment next line if keeping position of elements in Navigation Editor is relevant for you | ||||
| .idea/navEditor.xml | ||||
|  | ||||
| # Keystore files | ||||
| # Uncomment the following lines if you do not want to check your keystore files in. | ||||
| #*.jks | ||||
| #*.keystore | ||||
|  | ||||
| # External native build folder generated in Android Studio 2.2 and later | ||||
| .externalNativeBuild | ||||
| .cxx/ | ||||
|  | ||||
| # Google Services (e.g. APIs or Firebase) | ||||
| # google-services.json | ||||
|  | ||||
| # Freeline | ||||
| freeline.py | ||||
| freeline/ | ||||
| freeline_project_description.json | ||||
|  | ||||
| # fastlane | ||||
| fastlane/report.xml | ||||
| fastlane/Preview.html | ||||
| fastlane/screenshots | ||||
| fastlane/test_output | ||||
| fastlane/readme.md | ||||
|  | ||||
| # Version control | ||||
| vcs.xml | ||||
|  | ||||
| # lint | ||||
| lint/intermediates/ | ||||
| lint/generated/ | ||||
| lint/outputs/ | ||||
| lint/tmp/ | ||||
| # lint/reports/ | ||||
|  | ||||
| # Android Profiling | ||||
| *.hprof | ||||
|  | ||||
| # Cordova plugins for Capacitor | ||||
| capacitor-cordova-android-plugins | ||||
|  | ||||
| # Copied web assets | ||||
| app/src/main/assets/public | ||||
|  | ||||
| # Generated Config files | ||||
| app/src/main/assets/capacitor.config.json | ||||
| app/src/main/assets/capacitor.plugins.json | ||||
| app/src/main/res/xml/config.xml | ||||
							
								
								
									
										2
									
								
								frontend/allowance-planner-v2/android/app/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | ||||
| /build/* | ||||
| !/build/.npmkeep | ||||
							
								
								
									
										54
									
								
								frontend/allowance-planner-v2/android/app/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| apply plugin: 'com.android.application' | ||||
|  | ||||
| android { | ||||
|     namespace "io.ionic.starter" | ||||
|     compileSdk rootProject.ext.compileSdkVersion | ||||
|     defaultConfig { | ||||
|         applicationId "io.ionic.starter" | ||||
|         minSdkVersion rootProject.ext.minSdkVersion | ||||
|         targetSdkVersion rootProject.ext.targetSdkVersion | ||||
|         versionCode 1 | ||||
|         versionName "1.0" | ||||
|         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | ||||
|         aaptOptions { | ||||
|              // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. | ||||
|              // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 | ||||
|             ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' | ||||
|         } | ||||
|     } | ||||
|     buildTypes { | ||||
|         release { | ||||
|             minifyEnabled false | ||||
|             proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| repositories { | ||||
|     flatDir{ | ||||
|         dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' | ||||
|     } | ||||
| } | ||||
|  | ||||
| dependencies { | ||||
|     implementation fileTree(include: ['*.jar'], dir: 'libs') | ||||
|     implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" | ||||
|     implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" | ||||
|     implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" | ||||
|     implementation project(':capacitor-android') | ||||
|     testImplementation "junit:junit:$junitVersion" | ||||
|     androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" | ||||
|     androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" | ||||
|     implementation project(':capacitor-cordova-android-plugins') | ||||
| } | ||||
|  | ||||
| apply from: 'capacitor.build.gradle' | ||||
|  | ||||
| try { | ||||
|     def servicesJSON = file('google-services.json') | ||||
|     if (servicesJSON.text) { | ||||
|         apply plugin: 'com.google.gms.google-services' | ||||
|     } | ||||
| } catch(Exception e) { | ||||
|     logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN | ||||
|  | ||||
| android { | ||||
|   compileOptions { | ||||
|       sourceCompatibility JavaVersion.VERSION_21 | ||||
|       targetCompatibility JavaVersion.VERSION_21 | ||||
|   } | ||||
| } | ||||
|  | ||||
| apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" | ||||
| dependencies { | ||||
|     implementation project(':capacitor-app') | ||||
|     implementation project(':capacitor-haptics') | ||||
|     implementation project(':capacitor-keyboard') | ||||
|     implementation project(':capacitor-status-bar') | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| if (hasProperty('postBuildExtras')) { | ||||
|   postBuildExtras() | ||||
| } | ||||
							
								
								
									
										21
									
								
								frontend/allowance-planner-v2/android/app/proguard-rules.pro
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | ||||
| # Add project specific ProGuard rules here. | ||||
| # You can control the set of applied configuration files using the | ||||
| # proguardFiles setting in build.gradle. | ||||
| # | ||||
| # For more details, see | ||||
| #   http://developer.android.com/guide/developing/tools/proguard.html | ||||
|  | ||||
| # If your project uses WebView with JS, uncomment the following | ||||
| # and specify the fully qualified class name to the JavaScript interface | ||||
| # class: | ||||
| #-keepclassmembers class fqcn.of.javascript.interface.for.webview { | ||||
| #   public *; | ||||
| #} | ||||
|  | ||||
| # Uncomment this to preserve the line number information for | ||||
| # debugging stack traces. | ||||
| #-keepattributes SourceFile,LineNumberTable | ||||
|  | ||||
| # If you keep the line number information, uncomment this to | ||||
| # hide the original source file name. | ||||
| #-renamesourcefileattribute SourceFile | ||||
| @@ -0,0 +1,26 @@ | ||||
| package com.getcapacitor.myapp; | ||||
|  | ||||
| import static org.junit.Assert.*; | ||||
|  | ||||
| import android.content.Context; | ||||
| import androidx.test.ext.junit.runners.AndroidJUnit4; | ||||
| import androidx.test.platform.app.InstrumentationRegistry; | ||||
| import org.junit.Test; | ||||
| import org.junit.runner.RunWith; | ||||
|  | ||||
| /** | ||||
|  * Instrumented test, which will execute on an Android device. | ||||
|  * | ||||
|  * @see <a href="http://d.android.com/tools/testing">Testing documentation</a> | ||||
|  */ | ||||
| @RunWith(AndroidJUnit4.class) | ||||
| public class ExampleInstrumentedTest { | ||||
|  | ||||
|     @Test | ||||
|     public void useAppContext() throws Exception { | ||||
|         // Context of the app under test. | ||||
|         Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); | ||||
|  | ||||
|         assertEquals("com.getcapacitor.app", appContext.getPackageName()); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|  | ||||
|     <application | ||||
|         android:allowBackup="true" | ||||
|         android:icon="@mipmap/ic_launcher" | ||||
|         android:label="@string/app_name" | ||||
|         android:roundIcon="@mipmap/ic_launcher_round" | ||||
|         android:supportsRtl="true" | ||||
|         android:theme="@style/AppTheme"> | ||||
|  | ||||
|         <activity | ||||
|             android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation" | ||||
|             android:name=".MainActivity" | ||||
|             android:label="@string/title_activity_main" | ||||
|             android:theme="@style/AppTheme.NoActionBarLaunch" | ||||
|             android:launchMode="singleTask" | ||||
|             android:exported="true"> | ||||
|  | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MAIN" /> | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|         </activity> | ||||
|  | ||||
|         <provider | ||||
|             android:name="androidx.core.content.FileProvider" | ||||
|             android:authorities="${applicationId}.fileprovider" | ||||
|             android:exported="false" | ||||
|             android:grantUriPermissions="true"> | ||||
|             <meta-data | ||||
|                 android:name="android.support.FILE_PROVIDER_PATHS" | ||||
|                 android:resource="@xml/file_paths"></meta-data> | ||||
|         </provider> | ||||
|     </application> | ||||
|  | ||||
|     <!-- Permissions --> | ||||
|  | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
| </manifest> | ||||
| @@ -0,0 +1,5 @@ | ||||
| package io.ionic.starter; | ||||
|  | ||||
| import com.getcapacitor.BridgeActivity; | ||||
|  | ||||
| public class MainActivity extends BridgeActivity {} | ||||
| After Width: | Height: | Size: 33 KiB | 
| After Width: | Height: | Size: 9.1 KiB | 
| After Width: | Height: | Size: 16 KiB | 
| After Width: | Height: | Size: 64 KiB | 
| After Width: | Height: | Size: 91 KiB | 
| After Width: | Height: | Size: 122 KiB | 
| After Width: | Height: | Size: 32 KiB | 
| After Width: | Height: | Size: 9.1 KiB | 
| After Width: | Height: | Size: 16 KiB | 
| After Width: | Height: | Size: 65 KiB | 
| After Width: | Height: | Size: 90 KiB | 
| After Width: | Height: | Size: 119 KiB | 
| @@ -0,0 +1,34 @@ | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:aapt="http://schemas.android.com/aapt" | ||||
|     android:width="108dp" | ||||
|     android:height="108dp" | ||||
|     android:viewportHeight="108" | ||||
|     android:viewportWidth="108"> | ||||
|     <path | ||||
|         android:fillType="evenOdd" | ||||
|         android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z" | ||||
|         android:strokeColor="#00000000" | ||||
|         android:strokeWidth="1"> | ||||
|         <aapt:attr name="android:fillColor"> | ||||
|             <gradient | ||||
|                 android:endX="78.5885" | ||||
|                 android:endY="90.9159" | ||||
|                 android:startX="48.7653" | ||||
|                 android:startY="61.0927" | ||||
|                 android:type="linear"> | ||||
|                 <item | ||||
|                     android:color="#44000000" | ||||
|                     android:offset="0.0" /> | ||||
|                 <item | ||||
|                     android:color="#00000000" | ||||
|                     android:offset="1.0" /> | ||||
|             </gradient> | ||||
|         </aapt:attr> | ||||
|     </path> | ||||
|     <path | ||||
|         android:fillColor="#FFFFFF" | ||||
|         android:fillType="nonZero" | ||||
|         android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z" | ||||
|         android:strokeColor="#00000000" | ||||
|         android:strokeWidth="1" /> | ||||
| </vector> | ||||
| @@ -0,0 +1,170 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:width="108dp" | ||||
|     android:height="108dp" | ||||
|     android:viewportHeight="108" | ||||
|     android:viewportWidth="108"> | ||||
|     <path | ||||
|         android:fillColor="#26A69A" | ||||
|         android:pathData="M0,0h108v108h-108z" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M9,0L9,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,0L19,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M29,0L29,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M39,0L39,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M49,0L49,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M59,0L59,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M69,0L69,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M79,0L79,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M89,0L89,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M99,0L99,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,9L108,9" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,19L108,19" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,29L108,29" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,39L108,39" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,49L108,49" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,59L108,59" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,69L108,69" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,79L108,79" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,89L108,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,99L108,99" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,29L89,29" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,39L89,39" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,49L89,49" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,59L89,59" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,69L89,69" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,79L89,79" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M29,19L29,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M39,19L39,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M49,19L49,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M59,19L59,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M69,19L69,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M79,19L79,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
| </vector> | ||||
| After Width: | Height: | Size: 16 KiB | 
| @@ -0,0 +1,12 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:app="http://schemas.android.com/apk/res-auto" | ||||
|     xmlns:tools="http://schemas.android.com/tools" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="match_parent" | ||||
|     tools:context=".MainActivity"> | ||||
|  | ||||
|     <WebView | ||||
|         android:layout_width="match_parent" | ||||
|         android:layout_height="match_parent" /> | ||||
| </androidx.coordinatorlayout.widget.CoordinatorLayout> | ||||
| @@ -0,0 +1,9 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <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> | ||||
| @@ -0,0 +1,9 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <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: 2.7 KiB | 
| After Width: | Height: | Size: 660 B | 
| After Width: | Height: | Size: 5.1 KiB | 
| After Width: | Height: | Size: 4.2 KiB | 
| After Width: | Height: | Size: 296 B | 
| After Width: | Height: | Size: 2.1 KiB | 
| After Width: | Height: | Size: 1.8 KiB | 
| After Width: | Height: | Size: 408 B | 
| After Width: | Height: | Size: 3.0 KiB | 
| After Width: | Height: | Size: 2.7 KiB | 
| After Width: | Height: | Size: 3.9 KiB | 
| After Width: | Height: | Size: 1006 B | 
| After Width: | Height: | Size: 7.5 KiB | 
| After Width: | Height: | Size: 6.4 KiB | 
| After Width: | Height: | Size: 6.5 KiB | 
| After Width: | Height: | Size: 1.8 KiB | 
| After Width: | Height: | Size: 13 KiB | 
| After Width: | Height: | Size: 10 KiB | 
| After Width: | Height: | Size: 9.2 KiB | 
| After Width: | Height: | Size: 2.5 KiB | 
| After Width: | Height: | Size: 18 KiB | 
| After Width: | Height: | Size: 16 KiB | 
| @@ -0,0 +1,4 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|     <color name="ic_launcher_background">#FFFFFF</color> | ||||
| </resources> | ||||
| @@ -0,0 +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="package_name">io.ionic.starter</string> | ||||
|     <string name="custom_url_scheme">io.ionic.starter</string> | ||||
| </resources> | ||||
| @@ -0,0 +1,22 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources> | ||||
|  | ||||
|     <!-- Base application theme. --> | ||||
|     <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> | ||||
|         <!-- Customize your theme here. --> | ||||
|         <item name="colorPrimary">@color/colorPrimary</item> | ||||
|         <item name="colorPrimaryDark">@color/colorPrimaryDark</item> | ||||
|         <item name="colorAccent">@color/colorAccent</item> | ||||
|     </style> | ||||
|  | ||||
|     <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar"> | ||||
|         <item name="windowActionBar">false</item> | ||||
|         <item name="windowNoTitle">true</item> | ||||
|         <item name="android:background">@null</item> | ||||
|     </style> | ||||
|  | ||||
|  | ||||
|     <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen"> | ||||
|         <item name="android:background">@drawable/splash</item> | ||||
|     </style> | ||||
| </resources> | ||||
| @@ -0,0 +1,5 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <paths xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <external-path name="my_images" path="." /> | ||||
|     <cache-path name="my_cache_images" path="." /> | ||||
| </paths> | ||||
| @@ -0,0 +1,18 @@ | ||||
| package com.getcapacitor.myapp; | ||||
|  | ||||
| import static org.junit.Assert.*; | ||||
|  | ||||
| import org.junit.Test; | ||||
|  | ||||
| /** | ||||
|  * Example local unit test, which will execute on the development machine (host). | ||||
|  * | ||||
|  * @see <a href="http://d.android.com/tools/testing">Testing documentation</a> | ||||
|  */ | ||||
| public class ExampleUnitTest { | ||||
|  | ||||
|     @Test | ||||
|     public void addition_isCorrect() throws Exception { | ||||
|         assertEquals(4, 2 + 2); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								frontend/allowance-planner-v2/android/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,29 @@ | ||||
| // Top-level build file where you can add configuration options common to all sub-projects/modules. | ||||
|  | ||||
| buildscript { | ||||
|      | ||||
|     repositories { | ||||
|         google() | ||||
|         mavenCentral() | ||||
|     } | ||||
|     dependencies { | ||||
|         classpath 'com.android.tools.build:gradle:8.7.2' | ||||
|         classpath 'com.google.gms:google-services:4.4.2' | ||||
|  | ||||
|         // NOTE: Do not place your application dependencies here; they belong | ||||
|         // in the individual module build.gradle files | ||||
|     } | ||||
| } | ||||
|  | ||||
| apply from: "variables.gradle" | ||||
|  | ||||
| allprojects { | ||||
|     repositories { | ||||
|         google() | ||||
|         mavenCentral() | ||||
|     } | ||||
| } | ||||
|  | ||||
| task clean(type: Delete) { | ||||
|     delete rootProject.buildDir | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN | ||||
| include ':capacitor-android' | ||||
| project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') | ||||
|  | ||||
| include ':capacitor-app' | ||||
| project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') | ||||
|  | ||||
| include ':capacitor-haptics' | ||||
| project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') | ||||
|  | ||||
| include ':capacitor-keyboard' | ||||
| project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') | ||||
|  | ||||
| include ':capacitor-status-bar' | ||||
| project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') | ||||
							
								
								
									
										22
									
								
								frontend/allowance-planner-v2/android/gradle.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | ||||
| # Project-wide Gradle settings. | ||||
|  | ||||
| # IDE (e.g. Android Studio) users: | ||||
| # Gradle settings configured through the IDE *will override* | ||||
| # any settings specified in this file. | ||||
|  | ||||
| # For more details on how to configure your build environment visit | ||||
| # http://www.gradle.org/docs/current/userguide/build_environment.html | ||||
|  | ||||
| # Specifies the JVM arguments used for the daemon process. | ||||
| # The setting is particularly useful for tweaking memory settings. | ||||
| org.gradle.jvmargs=-Xmx1536m | ||||
|  | ||||
| # When configured, Gradle will run in incubating parallel mode. | ||||
| # This option should only be used with decoupled projects. More details, visit | ||||
| # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects | ||||
| # org.gradle.parallel=true | ||||
|  | ||||
| # AndroidX package structure to make it clearer which packages are bundled with the | ||||
| # Android operating system, and which are packaged with your app's APK | ||||
| # https://developer.android.com/topic/libraries/support-library/androidx-rn | ||||
| android.useAndroidX=true | ||||
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/android/gradle/wrapper/gradle-wrapper.jar
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										7
									
								
								frontend/allowance-planner-v2/android/gradle/wrapper/gradle-wrapper.properties
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| distributionBase=GRADLE_USER_HOME | ||||
| distributionPath=wrapper/dists | ||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip | ||||
| networkTimeout=10000 | ||||
| validateDistributionUrl=true | ||||
| zipStoreBase=GRADLE_USER_HOME | ||||
| zipStorePath=wrapper/dists | ||||
							
								
								
									
										252
									
								
								frontend/allowance-planner-v2/android/gradlew
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,252 @@ | ||||
| #!/bin/sh | ||||
|  | ||||
| # | ||||
| # Copyright © 2015-2021 the original authors. | ||||
| # | ||||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| # you may not use this file except in compliance with the License. | ||||
| # You may obtain a copy of the License at | ||||
| # | ||||
| #      https://www.apache.org/licenses/LICENSE-2.0 | ||||
| # | ||||
| # Unless required by applicable law or agreed to in writing, software | ||||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| # See the License for the specific language governing permissions and | ||||
| # limitations under the License. | ||||
| # | ||||
| # SPDX-License-Identifier: Apache-2.0 | ||||
| # | ||||
|  | ||||
| ############################################################################## | ||||
| # | ||||
| #   Gradle start up script for POSIX generated by Gradle. | ||||
| # | ||||
| #   Important for running: | ||||
| # | ||||
| #   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is | ||||
| #       noncompliant, but you have some other compliant shell such as ksh or | ||||
| #       bash, then to run this script, type that shell name before the whole | ||||
| #       command line, like: | ||||
| # | ||||
| #           ksh Gradle | ||||
| # | ||||
| #       Busybox and similar reduced shells will NOT work, because this script | ||||
| #       requires all of these POSIX shell features: | ||||
| #         * functions; | ||||
| #         * expansions «$var», «${var}», «${var:-default}», «${var+SET}», | ||||
| #           «${var#prefix}», «${var%suffix}», and «$( cmd )»; | ||||
| #         * compound commands having a testable exit status, especially «case»; | ||||
| #         * various built-in commands including «command», «set», and «ulimit». | ||||
| # | ||||
| #   Important for patching: | ||||
| # | ||||
| #   (2) This script targets any POSIX shell, so it avoids extensions provided | ||||
| #       by Bash, Ksh, etc; in particular arrays are avoided. | ||||
| # | ||||
| #       The "traditional" practice of packing multiple parameters into a | ||||
| #       space-separated string is a well documented source of bugs and security | ||||
| #       problems, so this is (mostly) avoided, by progressively accumulating | ||||
| #       options in "$@", and eventually passing that to Java. | ||||
| # | ||||
| #       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, | ||||
| #       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; | ||||
| #       see the in-line comments for details. | ||||
| # | ||||
| #       There are tweaks for specific operating systems such as AIX, CygWin, | ||||
| #       Darwin, MinGW, and NonStop. | ||||
| # | ||||
| #   (3) This script is generated from the Groovy template | ||||
| #       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt | ||||
| #       within the Gradle project. | ||||
| # | ||||
| #       You can find Gradle at https://github.com/gradle/gradle/. | ||||
| # | ||||
| ############################################################################## | ||||
|  | ||||
| # Attempt to set APP_HOME | ||||
|  | ||||
| # Resolve links: $0 may be a link | ||||
| app_path=$0 | ||||
|  | ||||
| # Need this for daisy-chained symlinks. | ||||
| while | ||||
|     APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path | ||||
|     [ -h "$app_path" ] | ||||
| do | ||||
|     ls=$( ls -ld "$app_path" ) | ||||
|     link=${ls#*' -> '} | ||||
|     case $link in             #( | ||||
|       /*)   app_path=$link ;; #( | ||||
|       *)    app_path=$APP_HOME$link ;; | ||||
|     esac | ||||
| done | ||||
|  | ||||
| # This is normally unused | ||||
| # shellcheck disable=SC2034 | ||||
| APP_BASE_NAME=${0##*/} | ||||
| # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) | ||||
| APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s | ||||
| ' "$PWD" ) || exit | ||||
|  | ||||
| # Use the maximum available, or set MAX_FD != -1 to use that value. | ||||
| MAX_FD=maximum | ||||
|  | ||||
| warn () { | ||||
|     echo "$*" | ||||
| } >&2 | ||||
|  | ||||
| die () { | ||||
|     echo | ||||
|     echo "$*" | ||||
|     echo | ||||
|     exit 1 | ||||
| } >&2 | ||||
|  | ||||
| # OS specific support (must be 'true' or 'false'). | ||||
| cygwin=false | ||||
| msys=false | ||||
| darwin=false | ||||
| nonstop=false | ||||
| case "$( uname )" in                #( | ||||
|   CYGWIN* )         cygwin=true  ;; #( | ||||
|   Darwin* )         darwin=true  ;; #( | ||||
|   MSYS* | MINGW* )  msys=true    ;; #( | ||||
|   NONSTOP* )        nonstop=true ;; | ||||
| esac | ||||
|  | ||||
| CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar | ||||
|  | ||||
|  | ||||
| # Determine the Java command to use to start the JVM. | ||||
| if [ -n "$JAVA_HOME" ] ; then | ||||
|     if [ -x "$JAVA_HOME/jre/sh/java" ] ; then | ||||
|         # IBM's JDK on AIX uses strange locations for the executables | ||||
|         JAVACMD=$JAVA_HOME/jre/sh/java | ||||
|     else | ||||
|         JAVACMD=$JAVA_HOME/bin/java | ||||
|     fi | ||||
|     if [ ! -x "$JAVACMD" ] ; then | ||||
|         die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME | ||||
|  | ||||
| Please set the JAVA_HOME variable in your environment to match the | ||||
| location of your Java installation." | ||||
|     fi | ||||
| else | ||||
|     JAVACMD=java | ||||
|     if ! command -v java >/dev/null 2>&1 | ||||
|     then | ||||
|         die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. | ||||
|  | ||||
| Please set the JAVA_HOME variable in your environment to match the | ||||
| location of your Java installation." | ||||
|     fi | ||||
| fi | ||||
|  | ||||
| # Increase the maximum file descriptors if we can. | ||||
| if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then | ||||
|     case $MAX_FD in #( | ||||
|       max*) | ||||
|         # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. | ||||
|         # shellcheck disable=SC2039,SC3045 | ||||
|         MAX_FD=$( ulimit -H -n ) || | ||||
|             warn "Could not query maximum file descriptor limit" | ||||
|     esac | ||||
|     case $MAX_FD in  #( | ||||
|       '' | soft) :;; #( | ||||
|       *) | ||||
|         # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. | ||||
|         # shellcheck disable=SC2039,SC3045 | ||||
|         ulimit -n "$MAX_FD" || | ||||
|             warn "Could not set maximum file descriptor limit to $MAX_FD" | ||||
|     esac | ||||
| fi | ||||
|  | ||||
| # Collect all arguments for the java command, stacking in reverse order: | ||||
| #   * args from the command line | ||||
| #   * the main class name | ||||
| #   * -classpath | ||||
| #   * -D...appname settings | ||||
| #   * --module-path (only if needed) | ||||
| #   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. | ||||
|  | ||||
| # For Cygwin or MSYS, switch paths to Windows format before running java | ||||
| if "$cygwin" || "$msys" ; then | ||||
|     APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) | ||||
|     CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) | ||||
|  | ||||
|     JAVACMD=$( cygpath --unix "$JAVACMD" ) | ||||
|  | ||||
|     # Now convert the arguments - kludge to limit ourselves to /bin/sh | ||||
|     for arg do | ||||
|         if | ||||
|             case $arg in                                #( | ||||
|               -*)   false ;;                            # don't mess with options #( | ||||
|               /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath | ||||
|                     [ -e "$t" ] ;;                      #( | ||||
|               *)    false ;; | ||||
|             esac | ||||
|         then | ||||
|             arg=$( cygpath --path --ignore --mixed "$arg" ) | ||||
|         fi | ||||
|         # Roll the args list around exactly as many times as the number of | ||||
|         # args, so each arg winds up back in the position where it started, but | ||||
|         # possibly modified. | ||||
|         # | ||||
|         # NB: a `for` loop captures its iteration list before it begins, so | ||||
|         # changing the positional parameters here affects neither the number of | ||||
|         # iterations, nor the values presented in `arg`. | ||||
|         shift                   # remove old arg | ||||
|         set -- "$@" "$arg"      # push replacement arg | ||||
|     done | ||||
| fi | ||||
|  | ||||
|  | ||||
| # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. | ||||
| DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' | ||||
|  | ||||
| # Collect all arguments for the java command: | ||||
| #   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, | ||||
| #     and any embedded shellness will be escaped. | ||||
| #   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be | ||||
| #     treated as '${Hostname}' itself on the command line. | ||||
|  | ||||
| set -- \ | ||||
|         "-Dorg.gradle.appname=$APP_BASE_NAME" \ | ||||
|         -classpath "$CLASSPATH" \ | ||||
|         org.gradle.wrapper.GradleWrapperMain \ | ||||
|         "$@" | ||||
|  | ||||
| # Stop when "xargs" is not available. | ||||
| if ! command -v xargs >/dev/null 2>&1 | ||||
| then | ||||
|     die "xargs is not available" | ||||
| fi | ||||
|  | ||||
| # Use "xargs" to parse quoted args. | ||||
| # | ||||
| # With -n1 it outputs one arg per line, with the quotes and backslashes removed. | ||||
| # | ||||
| # In Bash we could simply go: | ||||
| # | ||||
| #   readarray ARGS < <( xargs -n1 <<<"$var" ) && | ||||
| #   set -- "${ARGS[@]}" "$@" | ||||
| # | ||||
| # but POSIX shell has neither arrays nor command substitution, so instead we | ||||
| # post-process each arg (as a line of input to sed) to backslash-escape any | ||||
| # character that might be a shell metacharacter, then use eval to reverse | ||||
| # that process (while maintaining the separation between arguments), and wrap | ||||
| # the whole thing up as a single "set" statement. | ||||
| # | ||||
| # This will of course break if any of these variables contains a newline or | ||||
| # an unmatched quote. | ||||
| # | ||||
|  | ||||
| eval "set -- $( | ||||
|         printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | | ||||
|         xargs -n1 | | ||||
|         sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | | ||||
|         tr '\n' ' ' | ||||
|     )" '"$@"' | ||||
|  | ||||
| exec "$JAVACMD" "$@" | ||||
							
								
								
									
										94
									
								
								frontend/allowance-planner-v2/android/gradlew.bat
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,94 @@ | ||||
| @rem | ||||
| @rem Copyright 2015 the original author or authors. | ||||
| @rem | ||||
| @rem Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| @rem you may not use this file except in compliance with the License. | ||||
| @rem You may obtain a copy of the License at | ||||
| @rem | ||||
| @rem      https://www.apache.org/licenses/LICENSE-2.0 | ||||
| @rem | ||||
| @rem Unless required by applicable law or agreed to in writing, software | ||||
| @rem distributed under the License is distributed on an "AS IS" BASIS, | ||||
| @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| @rem See the License for the specific language governing permissions and | ||||
| @rem limitations under the License. | ||||
| @rem | ||||
| @rem SPDX-License-Identifier: Apache-2.0 | ||||
| @rem | ||||
|  | ||||
| @if "%DEBUG%"=="" @echo off | ||||
| @rem ########################################################################## | ||||
| @rem | ||||
| @rem  Gradle startup script for Windows | ||||
| @rem | ||||
| @rem ########################################################################## | ||||
|  | ||||
| @rem Set local scope for the variables with windows NT shell | ||||
| if "%OS%"=="Windows_NT" setlocal | ||||
|  | ||||
| set DIRNAME=%~dp0 | ||||
| if "%DIRNAME%"=="" set DIRNAME=. | ||||
| @rem This is normally unused | ||||
| set APP_BASE_NAME=%~n0 | ||||
| set APP_HOME=%DIRNAME% | ||||
|  | ||||
| @rem Resolve any "." and ".." in APP_HOME to make it shorter. | ||||
| for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi | ||||
|  | ||||
| @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. | ||||
| set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" | ||||
|  | ||||
| @rem Find java.exe | ||||
| if defined JAVA_HOME goto findJavaFromJavaHome | ||||
|  | ||||
| set JAVA_EXE=java.exe | ||||
| %JAVA_EXE% -version >NUL 2>&1 | ||||
| if %ERRORLEVEL% equ 0 goto execute | ||||
|  | ||||
| echo. 1>&2 | ||||
| echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 | ||||
| echo. 1>&2 | ||||
| echo Please set the JAVA_HOME variable in your environment to match the 1>&2 | ||||
| echo location of your Java installation. 1>&2 | ||||
|  | ||||
| goto fail | ||||
|  | ||||
| :findJavaFromJavaHome | ||||
| set JAVA_HOME=%JAVA_HOME:"=% | ||||
| set JAVA_EXE=%JAVA_HOME%/bin/java.exe | ||||
|  | ||||
| if exist "%JAVA_EXE%" goto execute | ||||
|  | ||||
| echo. 1>&2 | ||||
| echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 | ||||
| echo. 1>&2 | ||||
| echo Please set the JAVA_HOME variable in your environment to match the 1>&2 | ||||
| echo location of your Java installation. 1>&2 | ||||
|  | ||||
| goto fail | ||||
|  | ||||
| :execute | ||||
| @rem Setup the command line | ||||
|  | ||||
| set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar | ||||
|  | ||||
|  | ||||
| @rem Execute Gradle | ||||
| "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* | ||||
|  | ||||
| :end | ||||
| @rem End local scope for the variables with windows NT shell | ||||
| if %ERRORLEVEL% equ 0 goto mainEnd | ||||
|  | ||||
| :fail | ||||
| rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of | ||||
| rem the _cmd.exe /c_ return code! | ||||
| set EXIT_CODE=%ERRORLEVEL% | ||||
| if %EXIT_CODE% equ 0 set EXIT_CODE=1 | ||||
| if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% | ||||
| exit /b %EXIT_CODE% | ||||
|  | ||||
| :mainEnd | ||||
| if "%OS%"=="Windows_NT" endlocal | ||||
|  | ||||
| :omega | ||||
							
								
								
									
										5
									
								
								frontend/allowance-planner-v2/android/settings.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| include ':app' | ||||
| include ':capacitor-cordova-android-plugins' | ||||
| project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') | ||||
|  | ||||
| apply from: 'capacitor.settings.gradle' | ||||
							
								
								
									
										16
									
								
								frontend/allowance-planner-v2/android/variables.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,16 @@ | ||||
| ext { | ||||
|     minSdkVersion = 23 | ||||
|     compileSdkVersion = 35 | ||||
|     targetSdkVersion = 35 | ||||
|     androidxActivityVersion = '1.9.2' | ||||
|     androidxAppCompatVersion = '1.7.0' | ||||
|     androidxCoordinatorLayoutVersion = '1.2.0' | ||||
|     androidxCoreVersion = '1.15.0' | ||||
|     androidxFragmentVersion = '1.8.4' | ||||
|     coreSplashScreenVersion = '1.0.1' | ||||
|     androidxWebkitVersion = '1.12.1' | ||||
|     junitVersion = '4.13.2' | ||||
|     androidxJunitVersion = '1.2.1' | ||||
|     androidxEspressoCoreVersion = '3.6.1' | ||||
|     cordovaAndroidVersion = '10.1.1' | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/icon-background.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 38 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/allowance-planner-v2/assets/icon-foreground.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 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' | ||||
| }; | ||||
|  | ||||
|   | ||||
							
								
								
									
										3480
									
								
								frontend/allowance-planner-v2/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -23,6 +23,7 @@ | ||||
|     "@angular/platform-browser": "^19.0.0", | ||||
|     "@angular/platform-browser-dynamic": "^19.0.0", | ||||
|     "@angular/router": "^19.0.0", | ||||
|     "@capacitor/android": "7.2.0", | ||||
|     "@capacitor/app": "7.0.1", | ||||
|     "@capacitor/core": "7.2.0", | ||||
|     "@capacitor/haptics": "7.0.1", | ||||
| @@ -46,6 +47,7 @@ | ||||
|     "@angular/cli": "^19.0.0", | ||||
|     "@angular/compiler-cli": "^19.0.0", | ||||
|     "@angular/language-service": "^19.0.0", | ||||
|     "@capacitor/assets": "^3.0.5", | ||||
|     "@capacitor/cli": "7.2.0", | ||||
|     "@ionic/angular-toolkit": "^12.0.0", | ||||
|     "@types/jasmine": "~5.1.0", | ||||
|   | ||||
| @@ -11,7 +11,6 @@ const routes: Routes = [ | ||||
|     path: '', | ||||
|     loadChildren: () => import('./pages/tabs/tabs.module').then(m => m.TabsPageModule) | ||||
|   }, | ||||
|  | ||||
| ]; | ||||
| @NgModule({ | ||||
|   imports: [ | ||||
|   | ||||
							
								
								
									
										10
									
								
								frontend/allowance-planner-v2/src/app/models/allowance.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | ||||
| export interface Allowance { | ||||
|     id: number; | ||||
|     name: string; | ||||
|     target: number; | ||||
|     // Current allowance value | ||||
|     progress: number; | ||||
|     // Can be any positive number (backend checks for number relative to each other) | ||||
|     weight: number; | ||||
|     colour: string; | ||||
| } | ||||
							
								
								
									
										5
									
								
								frontend/allowance-planner-v2/src/app/models/history.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| export interface History { | ||||
|     timestamp: string; | ||||
|     allowance: number; | ||||
|     description: string; | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { Routes, RouterModule } from '@angular/router'; | ||||
|  | ||||
| import { AddAllowancePage } from './add-allowance.page'; | ||||
|  | ||||
| const routes: Routes = [ | ||||
|   { | ||||
|     path: '', | ||||
|     component: AddAllowancePage | ||||
|   } | ||||
| ]; | ||||
|  | ||||
| @NgModule({ | ||||
|   imports: [RouterModule.forChild(routes)], | ||||
|   exports: [RouterModule], | ||||
| }) | ||||
| export class AddAllowancePageRoutingModule {} | ||||
| @@ -0,0 +1,23 @@ | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { CommonModule } from '@angular/common'; | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||||
|  | ||||
| import { IonicModule } from '@ionic/angular'; | ||||
|  | ||||
| import { AddAllowancePageRoutingModule } from './add-allowance-routing.module'; | ||||
|  | ||||
| import { AddAllowancePage } from './add-allowance.page'; | ||||
| import { MatIconModule } from '@angular/material/icon'; | ||||
|  | ||||
| @NgModule({ | ||||
|   imports: [ | ||||
|     CommonModule, | ||||
|     FormsModule, | ||||
|     IonicModule, | ||||
|     AddAllowancePageRoutingModule, | ||||
|     ReactiveFormsModule, | ||||
|     MatIconModule | ||||
|   ], | ||||
|   declarations: [AddAllowancePage] | ||||
| }) | ||||
| export class AddAllowancePageModule {} | ||||
| @@ -0,0 +1,27 @@ | ||||
| <ion-header [translucent]="true"> | ||||
|   <ion-toolbar> | ||||
|     <div class="toolbar"> | ||||
|       <div class="icon" (click)="navigateBack()"> | ||||
|         <mat-icon>arrow_back</mat-icon> | ||||
|       </div> | ||||
|       <ion-title *ngIf="isAddMode && goalId == 0">Add to Allowance</ion-title> | ||||
|       <ion-title *ngIf="isAddMode && goalId != 0">Add to Goal</ion-title> | ||||
|       <ion-title *ngIf="!isAddMode">Spend Allowance</ion-title> | ||||
|     </div> | ||||
|   </ion-toolbar> | ||||
| </ion-header> | ||||
|  | ||||
| <ion-content [fullscreen]="true"> | ||||
|   <form [formGroup]="form"> | ||||
|     <label>Amount</label> | ||||
|     <input id="amount" type="number" placeholder="0.00" name="price" min="0" value="0" step="0.01" formControlName="amount"/> | ||||
|  | ||||
|     <label>Description</label> | ||||
|     <input id="description" type="text" formControlName="description"/> | ||||
|  | ||||
|     <button type="button" [disabled]="!form.valid" (click)="changeAllowance()"> | ||||
|       <span *ngIf="isAddMode">Add</span> | ||||
|       <span *ngIf="!isAddMode">Spend</span> | ||||
|     </button> | ||||
|   </form> | ||||
| </ion-content> | ||||
| @@ -0,0 +1,49 @@ | ||||
| .toolbar { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| .icon { | ||||
|     margin-left: 5px; | ||||
| } | ||||
|  | ||||
| form { | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| form, | ||||
| .item { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     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; | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| button { | ||||
|     background-color: var(--ion-color-primary); | ||||
|     border-radius: 5px; | ||||
|     color: white; | ||||
|     padding: 10px; | ||||
|     width: 250px; | ||||
|     margin-top: 100px; | ||||
| } | ||||
|  | ||||
| button:disabled, | ||||
| button[disabled]{ | ||||
|     opacity: 0.5; | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||||
| import { AddAllowancePage } from './add-allowance.page'; | ||||
|  | ||||
| describe('AddAllowancePage', () => { | ||||
|   let component: AddAllowancePage; | ||||
|   let fixture: ComponentFixture<AddAllowancePage>; | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     fixture = TestBed.createComponent(AddAllowancePage); | ||||
|     component = fixture.componentInstance; | ||||
|     fixture.detectChanges(); | ||||
|   }); | ||||
|  | ||||
|   it('should create', () => { | ||||
|     expect(component).toBeTruthy(); | ||||
|   }); | ||||
| }); | ||||
| @@ -0,0 +1,51 @@ | ||||
| import { Location } from '@angular/common'; | ||||
| import { Component } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { AllowanceService } from 'src/app/services/allowance.service'; | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-add-allowance', | ||||
|   templateUrl: './add-allowance.page.html', | ||||
|   styleUrls: ['./add-allowance.page.scss'], | ||||
|   standalone: false, | ||||
| }) | ||||
| export class AddAllowancePage { | ||||
|   public form: FormGroup; | ||||
|   public goalId: number; | ||||
|   public userId: number; | ||||
|   public isAddMode = true; | ||||
| // Marcus' first comment | ||||
| //            b    ........a`.OK  ø¶Ópppppppp--P09OP | ||||
|  | ||||
|  | ||||
|   constructor( | ||||
|     private allowanceService: AllowanceService, | ||||
|     private route: ActivatedRoute, | ||||
|     private formBuilder: FormBuilder, | ||||
|     private router: Router, | ||||
|     private location: Location | ||||
|   ) { | ||||
|     this.userId = this.route.snapshot.params['id']; | ||||
|     this.goalId = this.route.snapshot.params['goalId']; | ||||
|  | ||||
|     this.form = this.formBuilder.group({ | ||||
|       amount: ['', Validators.required], | ||||
|       description: ['', Validators.required] | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   changeAllowance() { | ||||
|     this.allowanceService.addOrSpendAllowance( | ||||
|       this.goalId, | ||||
|       this.userId, | ||||
|       this.form.value.amount, | ||||
|       this.form.value.description | ||||
|     ); | ||||
|     this.router.navigate(['/tabs/allowance', this.userId]); | ||||
|   } | ||||
|  | ||||
|   navigateBack() { | ||||
|     this.location.back(); | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { Routes, RouterModule } from '@angular/router'; | ||||
|  | ||||
| import { SpendllowancePage } from './spend-allowance.page'; | ||||
|  | ||||
| const routes: Routes = [ | ||||
|     { | ||||
|         path: '', | ||||
|         component: SpendllowancePage | ||||
|     } | ||||
| ]; | ||||
|  | ||||
| @NgModule({ | ||||
|     imports: [RouterModule.forChild(routes)], | ||||
|     exports: [RouterModule], | ||||
| }) | ||||
| export class SpendAllowancePageRoutingModule {} | ||||
| @@ -0,0 +1,22 @@ | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { CommonModule } from '@angular/common'; | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||||
|  | ||||
| import { IonicModule } from '@ionic/angular'; | ||||
|  | ||||
| import { SpendAllowancePageRoutingModule } from './spend-allowance-routing.module'; | ||||
| import { SpendllowancePage } from './spend-allowance.page'; | ||||
| import { MatIconModule } from '@angular/material/icon'; | ||||
|  | ||||
| @NgModule({ | ||||
|     imports: [ | ||||
|         CommonModule, | ||||
|         FormsModule, | ||||
|         IonicModule, | ||||
|         SpendAllowancePageRoutingModule, | ||||
|         ReactiveFormsModule, | ||||
|         MatIconModule | ||||
|     ], | ||||
|     declarations: [SpendllowancePage] | ||||
| }) | ||||
| export class SpendAllowancePageModule {} | ||||
| @@ -0,0 +1,52 @@ | ||||
| import { Location } from '@angular/common'; | ||||
| import { Component } from '@angular/core'; | ||||
| import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||||
| import { ActivatedRoute, Router } from '@angular/router'; | ||||
| import { AllowanceService } from 'src/app/services/allowance.service'; | ||||
|  | ||||
| @Component({ | ||||
|     selector: 'app-spend-allowance', | ||||
|     templateUrl: './add-allowance.page.html', | ||||
|     styleUrls: ['./add-allowance.page.scss'], | ||||
|     standalone: false, | ||||
| }) | ||||
| export class SpendllowancePage { | ||||
|     public form: FormGroup; | ||||
|     public goalId: number; | ||||
|     public userId: number; | ||||
|     public isAddMode = false; | ||||
|  | ||||
|     constructor( | ||||
|         private allowanceService: AllowanceService, | ||||
|         private route: ActivatedRoute, | ||||
|         private formBuilder: FormBuilder, | ||||
|         private router: Router, | ||||
|         private location: Location | ||||
|     ) { | ||||
|         this.userId = this.route.snapshot.params['id']; | ||||
|         this.goalId = this.route.snapshot.params['goalId']; | ||||
|  | ||||
|         this.form = this.formBuilder.group({ | ||||
|         amount: ['', Validators.required], | ||||
|         description: ['', Validators.required] | ||||
|         }); | ||||
|  | ||||
|         this.allowanceService.getAllowanceById(this.goalId, this.userId).subscribe(allowance => { | ||||
|             this.form.controls['amount'].addValidators([Validators.max(allowance.progress)]); | ||||
|         });  | ||||
|     } | ||||
|  | ||||
|     changeAllowance() { | ||||
|         this.allowanceService.addOrSpendAllowance( | ||||
|         this.goalId, | ||||
|         this.userId, | ||||
|         -this.form.value.amount, | ||||
|         this.form.value.description | ||||
|         ); | ||||
|         this.router.navigate(['/tabs/allowance', this.userId]); | ||||
|     } | ||||
|  | ||||
|     navigateBack() { | ||||
|         this.location.back(); | ||||
|     } | ||||
| } | ||||
| @@ -6,6 +6,22 @@ const routes: Routes = [ | ||||
|   { | ||||
|     path: ':id', | ||||
|     component: AllowancePage, | ||||
|   }, | ||||
|   { | ||||
|     path: ':id/add', | ||||
|     loadChildren: () => import('../edit-allowance/edit-allowance.module').then(m => m.EditAllowancePageModule) | ||||
|   }, | ||||
|   { | ||||
|     path: ':id/edit/:goalId', | ||||
|     loadChildren: () => import('../edit-allowance/edit-allowance.module').then(m => m.EditAllowancePageModule) | ||||
|   }, | ||||
|   { | ||||
|     path: ':id/increase/:goalId', | ||||
|     loadChildren: () => import('../add-allowance/add-allowance.module').then(m => m.AddAllowancePageModule) | ||||
|   }, | ||||
|   { | ||||
|     path: ':id/spend/:goalId', | ||||
|     loadChildren: () => import('../add-allowance/spend-allowance.module').then(m => m.SpendAllowancePageModule) | ||||
|   } | ||||
| ]; | ||||
|  | ||||
|   | ||||
| @@ -5,14 +5,22 @@ import { FormsModule } from '@angular/forms'; | ||||
| import { AllowancePage } from './allowance.page'; | ||||
|  | ||||
| import { AllowancePageRoutingModule } from './allowance-routing.module'; | ||||
| import { AllowanceService } from 'src/app/services/allowance.service'; | ||||
| import { provideHttpClient } from '@angular/common/http'; | ||||
| import { MatIconModule } from '@angular/material/icon'; | ||||
|  | ||||
| @NgModule({ | ||||
|   imports: [ | ||||
|     IonicModule, | ||||
|     CommonModule, | ||||
|     FormsModule, | ||||
|     AllowancePageRoutingModule | ||||
|     AllowancePageRoutingModule, | ||||
|     MatIconModule | ||||
|   ], | ||||
|   declarations: [AllowancePage] | ||||
|   declarations: [AllowancePage], | ||||
|   providers: [ | ||||
|     provideHttpClient(), | ||||
|     AllowanceService | ||||
|   ] | ||||
| }) | ||||
| export class AllowancePageModule {} | ||||
|   | ||||
| @@ -1,10 +1,72 @@ | ||||
| <ion-header [translucent]="true" class="ion-no-border"> | ||||
|   <ion-toolbar> | ||||
|     <ion-title> | ||||
|       Allowance | ||||
|     </ion-title> | ||||
|     <div class="toolbar"> | ||||
|       <ion-title> | ||||
|         Allowance | ||||
|       </ion-title> | ||||
|       <button class="top-add-button" (click)="createAllowance()">Add Goal</button> | ||||
|     </div> | ||||
|   </ion-toolbar> | ||||
| </ion-header> | ||||
|  | ||||
| <ion-content> | ||||
|   <div class="content" *ngIf="allowance$ | async as allowance"> | ||||
|     <div class="bar"> | ||||
|       <div class="distribution">Allowance distribution</div> | ||||
|       <div class="allowance-bar"> | ||||
|         <span | ||||
|           *ngFor="let goal of allowance" | ||||
|           class="partition" | ||||
|           [style.--partition-color]="goal.colour" | ||||
|           [style.width.%]="getPartitionSize(goal, allowance)" | ||||
|         ></span> | ||||
|       </div> | ||||
|       <div class="legend"> | ||||
|         <div class="legend-item" [style.--legend-color]="goal.colour" *ngFor="let goal of allowance"> | ||||
|           <div class="circle"></div> | ||||
|           <div class="title">{{ goal.name }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div | ||||
|       class="goal" | ||||
|       [style.--used-color]="goal.colour" | ||||
|       [ngClass]="{'other-goals': goal.id !== 0}" | ||||
|       *ngFor="let goal of allowance" | ||||
|     > | ||||
|       <div class="main" *ngIf="goal.id === 0; else other_goal"> | ||||
|         <div class="title"> | ||||
|           <div class="name">Main Allowance</div> | ||||
|           <div class="icon" (click)="updateAllowance(goal.id)"> | ||||
|             <mat-icon>settings</mat-icon> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="progress">{{ goal.progress }} SP</div> | ||||
|         <div class="buttons"> | ||||
|           <button class="add-button" (click)="addAllowance(goal.id)">Add</button> | ||||
|           <!-- <button class="move-button">Move</button> --> | ||||
|           <button class="spend-button" (click)="spendAllowance(goal.id)">Spend</button> | ||||
|         </div> | ||||
|       </div> | ||||
|       <ng-template #other_goal> | ||||
|         <div class="color-wrapper"> | ||||
|           <div> | ||||
|             <div class="title"> | ||||
|               <div class="name">{{ goal.name }}</div> | ||||
|               <div class="icon" (click)="updateAllowance(goal.id)"> | ||||
|                 <mat-icon>settings</mat-icon> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="progress">{{ goal.progress }} / {{ goal.target }} SP</div> | ||||
|             <div class="buttons"> | ||||
|               <button class="add-button" (click)="addAllowance(goal.id)">Add</button> | ||||
|               <!-- <button class="move-button">Move</button> --> | ||||
|               <button class="spend-button" [disabled]="!canFinishGoal(goal)" (click)="completeGoal(goal.id)">Finish goal</button> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="color" [style.--background]="hexToRgb(goal.colour)" [style.width.%]="getPercentage(goal)"></div> | ||||
|         </div> | ||||
|       </ng-template> | ||||
|     </div> | ||||
|   </div> | ||||
| </ion-content> | ||||
|   | ||||
| @@ -0,0 +1,139 @@ | ||||
| .goal { | ||||
|     border: 1px solid var(--used-color); | ||||
|     border-radius: 10px; | ||||
|     padding: 10px; | ||||
|     margin-bottom: 20px; | ||||
|     margin-left: 10px; | ||||
|     margin-right: 10px; | ||||
|     color: var(--used-color); | ||||
| } | ||||
|  | ||||
| .name { | ||||
|     font-size: 20px; | ||||
| } | ||||
|  | ||||
| .progress { | ||||
|     color: var(--font-color); | ||||
|     margin-left: 15px; | ||||
|     margin-top: 8px; | ||||
|     margin-bottom: 15px; | ||||
|     font-size: 16px; | ||||
| } | ||||
|  | ||||
| .bar { | ||||
|     margin-top: 20px; | ||||
|     margin-bottom: 20px; | ||||
|     margin-left: 20px; | ||||
| } | ||||
|  | ||||
| .distribution { | ||||
|     color: var(--ion-color-primary); | ||||
| } | ||||
|  | ||||
| .allowance-bar { | ||||
|     display: flex; | ||||
|     width: 95%; | ||||
|     height: 15px !important; | ||||
|     border-radius: 15px; | ||||
|     background-color: var(--font-color); | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .partition { | ||||
|     --partition-color: white; | ||||
|     background-color: var(--partition-color); | ||||
|     width: 25%; | ||||
|     height: 100%; | ||||
|     //border-radius: 15px; | ||||
| } | ||||
|  | ||||
| .buttons, | ||||
| .title { | ||||
|     display: flex; | ||||
|     gap: 10px; | ||||
| } | ||||
|  | ||||
| button { | ||||
|     height: 30px; | ||||
|     padding-inline: 30px; | ||||
|     border-radius: 10px; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| button:disabled, | ||||
| button[disabled] { | ||||
|     opacity: 0.5; | ||||
| } | ||||
|  | ||||
| .add-button { | ||||
|     background-color: var(--confirm-button-color); | ||||
| } | ||||
|  | ||||
| .move-button { | ||||
|     background-color: var(--ion-color-primary); | ||||
| } | ||||
|  | ||||
| .spend-button { | ||||
|     background-color: var(--negative-amount-color); | ||||
| } | ||||
|  | ||||
| .icon { | ||||
|     margin-left: auto; | ||||
|     color: var(--font-color); | ||||
| } | ||||
|  | ||||
| .color-wrapper { | ||||
|     padding: 10px; | ||||
|     border-radius: 9px; | ||||
|     position: relative; | ||||
|     z-index: 1; | ||||
| } | ||||
|  | ||||
| .color { | ||||
|     --background: rgba(0, 0, 0, 0.3); | ||||
|     background-color: var(--background); | ||||
|     border-radius: 9px; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     z-index: -1; | ||||
| } | ||||
|  | ||||
| .other-goals { | ||||
|     padding: unset; | ||||
| } | ||||
|  | ||||
| .legend { | ||||
|     width: 95%; | ||||
|     display: flex; | ||||
|     font-size: 13px; | ||||
|     gap: 8px; | ||||
|     margin-top: 5px; | ||||
|     flex-wrap: wrap; | ||||
| } | ||||
|  | ||||
| .legend-item { | ||||
|     display: flex; | ||||
|     --legend-color: white; | ||||
|     color: var(--legend-color); | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| .circle { | ||||
|     width: 12px; | ||||
|     height: 12px; | ||||
|     background-color: var(--legend-color); | ||||
|     border-radius: 20px; | ||||
|     margin-right: 2px; | ||||
| } | ||||
|  | ||||
| .toolbar { | ||||
|     display: flex; | ||||
| } | ||||
|  | ||||
| .top-add-button { | ||||
|     background-color: var(--ion-color-primary); | ||||
|     margin-right: 15px; | ||||
|     padding-inline: 15px; | ||||
| } | ||||