diff --git a/be/app/api/auth/oauth.py b/be/app/api/auth/oauth.py
index 13223d6..6a1b624 100644
--- a/be/app/api/auth/oauth.py
+++ b/be/app/api/auth/oauth.py
@@ -1,11 +1,12 @@
-from fastapi import APIRouter, Depends, Request
-from fastapi.responses import RedirectResponse
+from fastapi import APIRouter, Depends, Request, HTTPException, status
+from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_transactional_session
from app.models import User
from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy
from app.config import settings
+from fastapi.security import OAuth2PasswordRequestForm
router = APIRouter()
@@ -92,4 +93,30 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
# Redirect to frontend with tokens
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
- return RedirectResponse(url=redirect_url)
\ No newline at end of file
+ return RedirectResponse(url=redirect_url)
+
+@router.post('/jwt/refresh')
+async def refresh_jwt_token(request: Request):
+ data = await request.json()
+ refresh_token = data.get('refresh_token')
+ if not refresh_token:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
+
+ refresh_strategy = get_refresh_jwt_strategy()
+ try:
+ user = await refresh_strategy.read_token(refresh_token, None)
+ except Exception:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
+
+ if not user:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
+
+ access_strategy = get_jwt_strategy()
+ access_token = await access_strategy.write_token(user)
+ # Optionally, issue a new refresh token (rotation)
+ new_refresh_token = await refresh_strategy.write_token(user)
+ return JSONResponse({
+ "access_token": access_token,
+ "refresh_token": new_refresh_token,
+ "token_type": "bearer"
+ })
\ No newline at end of file
diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py
index adc28cc..4d0599e 100644
--- a/be/app/api/v1/api.py
+++ b/be/app/api/v1/api.py
@@ -9,6 +9,7 @@ from app.api.v1.endpoints import ocr
from app.api.v1.endpoints import costs
from app.api.v1.endpoints import financials
from app.api.v1.endpoints import chores
+from app.api.auth import oauth
api_router_v1 = APIRouter()
@@ -21,5 +22,6 @@ api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
+api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
# Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
\ No newline at end of file
diff --git a/be/app/config.py b/be/app/config.py
index e709af5..fd48199 100644
--- a/be/app/config.py
+++ b/be/app/config.py
@@ -26,18 +26,168 @@ class Settings(BaseSettings):
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing
ALLOWED_IMAGE_TYPES: list[str] = ["image/jpeg", "image/png", "image/webp"] # Supported image formats
OCR_ITEM_EXTRACTION_PROMPT: str = """
-Extract the shopping list items from this image.
-List each distinct item on a new line.
-Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
-Focus only on the names of the products or items to be purchased.
-Add 2 underscores before and after the item name, if it is struck through.
-If the image does not appear to be a shopping list or receipt, state that clearly.
-Example output for a grocery list:
-Milk
-Eggs
-Bread
-__Apples__
-Organic Bananas
+**ROLE & GOAL**
+
+You are an expert AI assistant specializing in Optical Character Recognition (OCR) and structured data extraction. Your primary function is to act as a "Shopping List Digitizer."
+
+Your goal is to meticulously analyze the provided image of a shopping list, which is likely handwritten, and convert it into a structured, machine-readable JSON format. You must be accurate, infer context where necessary, and handle the inherent ambiguities of handwriting and informal list-making.
+
+**INPUT**
+
+You will receive a single image (`[Image]`). This image contains a shopping list. It may be:
+* Neatly written or very messy.
+* On lined paper, a whiteboard, a napkin, or a dedicated notepad.
+* Containing doodles, stains, or other visual noise.
+* Using various formats (bullet points, numbered lists, columns, simple line breaks).
+* could be in English or in German.
+
+**CORE TASK: STEP-BY-STEP ANALYSIS**
+
+Follow these steps precisely:
+
+1. **Initial Image Analysis & OCR:**
+ * Perform an advanced OCR scan on the entire image to transcribe all visible text.
+ * Pay close attention to the spatial layout. Identify headings, columns, and line items. Note which text elements appear to be grouped together.
+
+2. **Item Identification & Filtering:**
+ * Differentiate between actual list items and non-item elements.
+ * **INCLUDE:** Items intended for purchase.
+ * **EXCLUDE:** List titles (e.g., "GROCERIES," "Target List"), dates, doodles, unrelated notes, or stray marks. Capture the list title separately if one exists.
+
+3. **Detailed Extraction for Each Item:**
+ For every single item you identify, extract the following attributes. If an attribute is not present, use `null`.
+
+ * `item_name` (string): The primary name of the product.
+ * **Standardize:** Normalize the name. (e.g., "B. Powder" -> "Baking Powder", "A. Juice" -> "Apple Juice").
+ * **Contextual Guessing:** If a word is poorly written, use the context of a shopping list to make an educated guess. (e.g., "Ciffee" is almost certainly "Coffee").
+
+ * `quantity` (number or string): The amount needed.
+ * If a number is present (e.g., "**2** milks"), extract the number `2`.
+ * If it's a word (e.g., "**a dozen** eggs"), extract the string `"a dozen"`.
+ * If no quantity is specified (e.g., "Bread"), infer a default quantity of `1`.
+
+ * `unit` (string): The unit of measurement or packaging.
+ * Examples: "kg", "lbs", "liters", "gallons", "box", "can", "bag", "bunch".
+ * Infer where possible (e.g., for "2 Milks," the unit could be inferred as "cartons" or "gallons" depending on regional context, but it's safer to leave it `null` if not explicitly stated).
+
+ * `notes` (string): Any additional descriptive text.
+ * Examples: "low-sodium," "organic," "brand name (Tide)," "for the cake," "get the ripe ones."
+
+ * `category` (string): Infer a logical category for the item.
+ * Use common grocery store categories: `Produce`, `Dairy & Eggs`, `Meat & Seafood`, `Pantry`, `Frozen`, `Bakery`, `Beverages`, `Household`, `Personal Care`.
+ * If the list itself has category headings (e.g., a "DAIRY" section), use those first.
+
+ * `original_text` (string): Provide the exact, unaltered text that your OCR transcribed for this entire line item. This is crucial for verification.
+
+ * `is_crossed_out` (boolean): Set to `true` if the item is struck through, crossed out, or clearly marked as completed. Otherwise, set to `false`.
+
+**HANDLING AMBIGUITIES AND EDGE CASES**
+
+* **Illegible Text:** If a line or word is completely unreadable, set `item_name` to `"UNREADABLE"` and place the garbled OCR attempt in the `original_text` field.
+* **Abbreviations:** Expand common shopping list abbreviations (e.g., "OJ" -> "Orange Juice", "TP" -> "Toilet Paper", "AVOs" -> "Avocados", "G. Beef" -> "Ground Beef").
+* **Implicit Items:** If a line is vague like "Snacks for kids," list it as is. Do not invent specific items.
+* **Multi-item Lines:** If a line contains multiple items (e.g., "Onions, Garlic, Ginger"), split them into separate item objects.
+
+**OUTPUT FORMAT**
+
+Your final output MUST be a single JSON object with the following structure. Do not include any explanatory text before or after the JSON block.
+
+```json
+{
+ "list_title": "string or null",
+ "items": [
+ {
+ "item_name": "string",
+ "quantity": "number or string",
+ "unit": "string or null",
+ "category": "string",
+ "notes": "string or null",
+ "original_text": "string",
+ "is_crossed_out": "boolean"
+ }
+ ],
+ "summary": {
+ "total_items": "integer",
+ "unread_items": "integer",
+ "crossed_out_items": "integer"
+ }
+}
+```
+
+**EXAMPLE WALKTHROUGH**
+
+* **IF THE IMAGE SHOWS:** A crumpled sticky note with the title "Stuff for tonight" and the items:
+ * `2x Chicken Breasts`
+ * `~~Baguette~~` (this item is crossed out)
+ * `Salad mix (bag)`
+ * `Tomatos` (misspelled)
+ * `Choc Ice Cream`
+
+* **YOUR JSON OUTPUT SHOULD BE:**
+
+```json
+{
+ "list_title": "Stuff for tonight",
+ "items": [
+ {
+ "item_name": "Chicken Breasts",
+ "quantity": 2,
+ "unit": null,
+ "category": "Meat & Seafood",
+ "notes": null,
+ "original_text": "2x Chicken Breasts",
+ "is_crossed_out": false
+ },
+ {
+ "item_name": "Baguette",
+ "quantity": 1,
+ "unit": null,
+ "category": "Bakery",
+ "notes": null,
+ "original_text": "Baguette",
+ "is_crossed_out": true
+ },
+ {
+ "item_name": "Salad Mix",
+ "quantity": 1,
+ "unit": "bag",
+ "category": "Produce",
+ "notes": null,
+ "original_text": "Salad mix (bag)",
+ "is_crossed_out": false
+ },
+ {
+ "item_name": "Tomatoes",
+ "quantity": 1,
+ "unit": null,
+ "category": "Produce",
+ "notes": null,
+ "original_text": "Tomatos",
+ "is_crossed_out": false
+ },
+ {
+ "item_name": "Chocolate Ice Cream",
+ "quantity": 1,
+ "unit": null,
+ "category": "Frozen",
+ "notes": null,
+ "original_text": "Choc Ice Cream",
+ "is_crossed_out": false
+ }
+ ],
+ "summary": {
+ "total_items": 5,
+ "unread_items": 0,
+ "crossed_out_items": 1
+ }
+}
+```
+
+**FINAL INSTRUCTION**
+
+If the image provided is not a shopping list or is completely blank/unintelligible, respond with a JSON object where the `items` array is empty and add a note in the `list_title` field, such as "Image does not appear to be a shopping list."
+
+Now, analyze the provided image and generate the JSON output.
"""
# --- OCR Error Messages ---
OCR_SERVICE_UNAVAILABLE: str = "OCR service is currently unavailable. Please try again later."
@@ -49,7 +199,7 @@ Organic Bananas
OCR_PROCESSING_ERROR: str = "Error processing image: {detail}"
# --- Gemini AI Settings ---
- GEMINI_MODEL_NAME: str = "gemini-2.0-flash" # The model to use for OCR
+ GEMINI_MODEL_NAME: str = "gemini-2.5-flash-preview-05-20" # The model to use for OCR
GEMINI_SAFETY_SETTINGS: dict = {
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
"HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",
diff --git a/fe/package-lock.json b/fe/package-lock.json
index ff478d6..b296c5a 100644
--- a/fe/package-lock.json
+++ b/fe/package-lock.json
@@ -15,6 +15,7 @@
"@vueuse/core": "^13.1.0",
"axios": "^1.9.0",
"date-fns": "^4.1.0",
+ "framer-motion": "^12.16.0",
"motion": "^12.15.0",
"pinia": "^3.0.2",
"qs": "^6.14.0",
@@ -34,6 +35,7 @@
"@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.15.17",
+ "@types/qs": "^6.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/eslint-plugin": "^1.1.39",
"@vue/eslint-config-prettier": "^10.2.0",
@@ -4291,6 +4293,13 @@
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
@@ -7491,12 +7500,12 @@
}
},
"node_modules/framer-motion": {
- "version": "12.15.0",
- "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.15.0.tgz",
- "integrity": "sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==",
+ "version": "12.16.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.16.0.tgz",
+ "integrity": "sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==",
"license": "MIT",
"dependencies": {
- "motion-dom": "^12.15.0",
+ "motion-dom": "^12.16.0",
"motion-utils": "^12.12.1",
"tslib": "^2.4.0"
},
@@ -9295,9 +9304,9 @@
}
},
"node_modules/motion-dom": {
- "version": "12.15.0",
- "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.15.0.tgz",
- "integrity": "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==",
+ "version": "12.16.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.16.0.tgz",
+ "integrity": "sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.12.1"
@@ -11732,9 +11741,9 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
- "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
+ "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/fe/package.json b/fe/package.json
index b9253fb..fedc729 100644
--- a/fe/package.json
+++ b/fe/package.json
@@ -26,6 +26,7 @@
"@vueuse/core": "^13.1.0",
"axios": "^1.9.0",
"date-fns": "^4.1.0",
+ "framer-motion": "^12.16.0",
"motion": "^12.15.0",
"pinia": "^3.0.2",
"qs": "^6.14.0",
@@ -45,6 +46,7 @@
"@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.15.17",
+ "@types/qs": "^6.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/eslint-plugin": "^1.1.39",
"@vue/eslint-config-prettier": "^10.2.0",
diff --git a/fe/src/components/CreateGroupModal.vue b/fe/src/components/CreateGroupModal.vue
new file mode 100644
index 0000000..91ac849
--- /dev/null
+++ b/fe/src/components/CreateGroupModal.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/fe/src/layouts/MainLayout.vue b/fe/src/layouts/MainLayout.vue
index 7a47c33..a6ad428 100644
--- a/fe/src/layouts/MainLayout.vue
+++ b/fe/src/layouts/MainLayout.vue
@@ -1,45 +1,75 @@