Init project
This commit is contained in:
0
soul_diary/__init__.py
Normal file
0
soul_diary/__init__.py
Normal file
7
soul_diary/__main__.py
Normal file
7
soul_diary/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .cli import get_cli
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli = get_cli()
|
||||
|
||||
cli()
|
||||
0
soul_diary/api/__init__.py
Normal file
0
soul_diary/api/__init__.py
Normal file
20
soul_diary/cli.py
Normal file
20
soul_diary/cli.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import asyncio
|
||||
|
||||
import typer
|
||||
|
||||
from . import ui
|
||||
|
||||
|
||||
def run():
|
||||
ui_service = ui.get_service()
|
||||
|
||||
asyncio.run(ui_service.run())
|
||||
|
||||
|
||||
def get_cli() -> typer.Typer:
|
||||
cli = typer.Typer()
|
||||
|
||||
cli.add_typer(ui.get_cli(), name="ui")
|
||||
cli.command(name="run")(run)
|
||||
|
||||
return cli
|
||||
2
soul_diary/ui/__init__.py
Normal file
2
soul_diary/ui/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .cli import get_cli
|
||||
from .service import UIService, get_service
|
||||
7
soul_diary/ui/__main__.py
Normal file
7
soul_diary/ui/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .cli import get_cli
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli = get_cli()
|
||||
|
||||
cli()
|
||||
1
soul_diary/ui/app/__init__.py
Normal file
1
soul_diary/ui/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .app import SoulDiaryApp
|
||||
56
soul_diary/ui/app/app.py
Normal file
56
soul_diary/ui/app/app.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from typing import Any
|
||||
|
||||
import flet
|
||||
from flet_route import Routing, path
|
||||
|
||||
from .local_storage import LocalStorage
|
||||
from .middleware import middleware
|
||||
from .models import BackendType
|
||||
from .routes import AUTH, INDEX, SENSE_ADD, SENSE_LIST
|
||||
from .views.auth import AuthView
|
||||
from .views.base import BaseView
|
||||
from .views.sense_add import SenseAddView
|
||||
from .views.sense_list import SenseListView
|
||||
|
||||
|
||||
class SoulDiaryApp:
|
||||
def __init__(
|
||||
self,
|
||||
backend: BackendType | None = None,
|
||||
backend_data: dict[str, Any] | None = None,
|
||||
):
|
||||
self._backend = backend
|
||||
self._backend_data = backend_data
|
||||
self._backend_client = None
|
||||
|
||||
def get_routes(self, page: flet.Page) -> dict[str, BaseView]:
|
||||
local_storage = LocalStorage(client_storage=page.client_storage)
|
||||
sense_list_view = SenseListView(local_storage=local_storage)
|
||||
|
||||
return {
|
||||
INDEX: sense_list_view,
|
||||
AUTH: AuthView(
|
||||
local_storage=local_storage,
|
||||
backend=self._backend,
|
||||
backend_data=self._backend_data,
|
||||
),
|
||||
SENSE_LIST: sense_list_view,
|
||||
SENSE_ADD: SenseAddView(),
|
||||
}
|
||||
|
||||
async def run(self, page: flet.Page):
|
||||
page.title = "Soul Diary"
|
||||
page.app = self
|
||||
|
||||
routes = self.get_routes(page)
|
||||
Routing(
|
||||
page=page,
|
||||
async_is=True,
|
||||
app_routes=[
|
||||
path(url=url, clear=False, view=view.entrypoint)
|
||||
for url, view in routes.items()
|
||||
],
|
||||
middleware=middleware,
|
||||
)
|
||||
|
||||
return await page.go_async(page.route)
|
||||
0
soul_diary/ui/app/backend/__init__.py
Normal file
0
soul_diary/ui/app/backend/__init__.py
Normal file
192
soul_diary/ui/app/backend/base.py
Normal file
192
soul_diary/ui/app/backend/base.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
|
||||
from soul_diary.ui.app.backend.models import SenseBackendData
|
||||
from soul_diary.ui.app.local_storage import LocalStorage
|
||||
from soul_diary.ui.app.models import BackendType, Emotion, Options, Sense
|
||||
|
||||
|
||||
class BaseBackend:
|
||||
BACKEND: BackendType
|
||||
NONCE = b"\x00" * 16
|
||||
MODE = AES.MODE_EAX
|
||||
ENCODING = "utf-8"
|
||||
ENCRYPTION_KEY_TEMPLATE = "backend:encryption_key:{username}:{password}"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
local_storage: LocalStorage,
|
||||
username: str | None = None,
|
||||
encryption_key: str | None = None,
|
||||
token: str | None = None,
|
||||
):
|
||||
self._local_storage = local_storage
|
||||
self._username = username
|
||||
self._encryption_key = (
|
||||
None
|
||||
if encryption_key is None else
|
||||
encryption_key.encode(self.ENCODING)
|
||||
)
|
||||
self._token = token
|
||||
|
||||
def generate_encryption_key(self, username: str, password: str) -> bytes:
|
||||
data = (
|
||||
self.ENCRYPTION_KEY_TEMPLATE
|
||||
.format(username=username, password=password)
|
||||
.encode(self.ENCODING)
|
||||
)
|
||||
return hashlib.sha256(data).hexdigest().encode(self.ENCODING)[:16]
|
||||
|
||||
def encode(self, data: dict[str, Any]) -> str:
|
||||
if self._encryption_key is None:
|
||||
raise ValueError("Need crypto key. For generating key you should authenticate.")
|
||||
|
||||
cipher = AES.new(self._encryption_key, self.MODE, nonce=self.NONCE)
|
||||
|
||||
data_string = json.dumps(data)
|
||||
data_bytes = data_string.encode(self.ENCODING)
|
||||
data_bytes_encoded, _ = cipher.encrypt_and_digest(data_bytes)
|
||||
data_bytes_encoded_base64 = base64.b64encode(data_bytes_encoded).decode(self.ENCODING)
|
||||
|
||||
return data_bytes_encoded_base64
|
||||
|
||||
def decode(self, data: str) -> dict[str, Any]:
|
||||
if self._encryption_key is None:
|
||||
raise ValueError("Need crypto key. For generating key you should authenticate.")
|
||||
|
||||
cipher = AES.new(self._encryption_key, self.MODE, nonce=self.NONCE)
|
||||
|
||||
data_bytes_encoded = base64.b64decode(data)
|
||||
data_bytes = cipher.decrypt(data_bytes_encoded)
|
||||
data_string = data_bytes.decode(self.ENCODING)
|
||||
data_decoded = json.loads(data_string)
|
||||
|
||||
return data_decoded
|
||||
|
||||
def convert_sense_data_to_sense(self, sense_data: SenseBackendData) -> Sense:
|
||||
return Sense(
|
||||
id=sense_data.id,
|
||||
created_at=sense_data.created_at,
|
||||
**self.decode(sense_data.data),
|
||||
)
|
||||
|
||||
async def registration(self, username: str, password: str):
|
||||
self._token = await self.create_user(username=username, password=password)
|
||||
self._encryption_key = self.generate_encryption_key(username=username, password=password)
|
||||
self._username = username
|
||||
await self._local_storage.store_auth_data(
|
||||
backend=self.BACKEND,
|
||||
backend_data=self.get_backend_data(),
|
||||
username=self._username,
|
||||
encryption_key=self._encryption_key.decode(self.ENCODING),
|
||||
token=self._token,
|
||||
)
|
||||
|
||||
async def login(self, username: str, password: str):
|
||||
self._token = await self.auth(username=username, password=password)
|
||||
self._encryption_key = self.generate_encryption_key(username=username, password=password)
|
||||
self._username = username
|
||||
await self._local_storage.store_auth_data(
|
||||
backend=self.BACKEND,
|
||||
backend_data=self.get_backend_data(),
|
||||
username=self._username,
|
||||
encryption_key=self._encryption_key.decode(self.ENCODING),
|
||||
token=self._token,
|
||||
)
|
||||
|
||||
async def logout(self):
|
||||
self._token = None
|
||||
self._encryption_key = None
|
||||
self._username = None
|
||||
|
||||
@property
|
||||
def is_auth(self) -> bool:
|
||||
return all((self._token, self._encryption_key))
|
||||
|
||||
async def get_sense_list(self, page: int = 1, limit: int = 10) -> list[Sense]:
|
||||
sense_data_list = await self.fetch_sense_list(page=page, limit=limit)
|
||||
return [
|
||||
self.convert_sense_data_to_sense(sense_data)
|
||||
for sense_data in sense_data_list
|
||||
]
|
||||
|
||||
async def create_sense(
|
||||
self,
|
||||
emotions: list[Emotion],
|
||||
feelings: str,
|
||||
body: str,
|
||||
desires: str,
|
||||
) -> Sense:
|
||||
breakpoint()
|
||||
data = {
|
||||
"emotions": emotions,
|
||||
"feelings": feelings,
|
||||
"body": body,
|
||||
"desires": desires
|
||||
}
|
||||
encoded_data = self.encode(data)
|
||||
|
||||
sense_data = await self.pull_sense_data(data=encoded_data)
|
||||
|
||||
return self.convert_sense_data_to_sense(sense_data)
|
||||
|
||||
async def get_sense(self, sense_id: uuid.UUID) -> Sense:
|
||||
sense_data = await self.fetch_sense(sense_id=sense_id)
|
||||
return self.convert_sense_data_to_sense(sense_data)
|
||||
|
||||
async def edit_sense(
|
||||
self,
|
||||
sense_id: uuid.UUID,
|
||||
emotions: list[Emotion] | None = None,
|
||||
feelings: str | None = None,
|
||||
body: str | None = None,
|
||||
desires: str | None = None,
|
||||
):
|
||||
data = {
|
||||
"emotions": emotions,
|
||||
"feelings": feelings,
|
||||
"body": body,
|
||||
"desires": desires,
|
||||
}
|
||||
encoded_data = self.encode(data)
|
||||
|
||||
sense_data = await self.pull_sense_data(data=encoded_data, sense_id=sense_id)
|
||||
|
||||
return self.convert_sense_data_to_sense(sense_data)
|
||||
|
||||
def get_backend_data(self) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def create_user(self, username: str, password: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def auth(self, username: str, password: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_options(self) -> Options:
|
||||
raise NotImplementedError
|
||||
|
||||
async def fetch_sense_list(
|
||||
self,
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
) -> list[SenseBackendData]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def fetch_sense(self, sense_id: uuid.UUID) -> SenseBackendData:
|
||||
raise NotImplementedError
|
||||
|
||||
async def pull_sense_data(
|
||||
self,
|
||||
data: str,
|
||||
sense_id: uuid.UUID | None = None,
|
||||
) -> SenseBackendData:
|
||||
raise NotImplementedError
|
||||
|
||||
async def delete_sense(self, sense_id: uuid.UUID):
|
||||
raise NotImplementedError
|
||||
18
soul_diary/ui/app/backend/exceptions.py
Normal file
18
soul_diary/ui/app/backend/exceptions.py
Normal file
@@ -0,0 +1,18 @@
|
||||
class BackendException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserAlreadyExistsException(BackendException):
|
||||
pass
|
||||
|
||||
|
||||
class IncorrectCredentialsException(BackendException):
|
||||
pass
|
||||
|
||||
|
||||
class NonAuthenticatedException(BackendException):
|
||||
pass
|
||||
|
||||
|
||||
class SenseNotFoundException(BackendException):
|
||||
pass
|
||||
135
soul_diary/ui/app/backend/local.py
Normal file
135
soul_diary/ui/app/backend/local.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from soul_diary.ui.app.models import BackendType, Options
|
||||
from .base import BaseBackend
|
||||
from .exceptions import (
|
||||
IncorrectCredentialsException,
|
||||
NonAuthenticatedException,
|
||||
SenseNotFoundException,
|
||||
UserAlreadyExistsException,
|
||||
)
|
||||
from .models import SenseBackendData
|
||||
|
||||
|
||||
class LocalBackend(BaseBackend):
|
||||
BACKEND = BackendType.LOCAL
|
||||
AUTH_BLOCK_TEMPLATE = "auth_block:{username}:{password}"
|
||||
AUTH_BLOCK_KEY_TEMPLATE = "soul_diary.backend.users.{username}.auth_block"
|
||||
SENSE_LIST_KEY_TEMPLATE = "soul_diary.backend.users.{username}.senses"
|
||||
|
||||
def generate_auth_block(self, username: str, password: str) -> str:
|
||||
auth_block_data = (
|
||||
self.AUTH_BLOCK_TEMPLATE
|
||||
.format(username=username, password=password)
|
||||
.encode(self.ENCODING)
|
||||
)
|
||||
return hashlib.sha256(auth_block_data).hexdigest()
|
||||
|
||||
def get_backend_data(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
async def create_user(self, username: str, password: str) -> str | None:
|
||||
auth_block_key = self.AUTH_BLOCK_KEY_TEMPLATE.format(username=username)
|
||||
|
||||
if await self._local_storage.raw_contains(auth_block_key):
|
||||
raise UserAlreadyExistsException()
|
||||
|
||||
auth_block = self.generate_auth_block(username=username, password=password)
|
||||
await self._local_storage.raw_write(auth_block_key, auth_block)
|
||||
return auth_block
|
||||
|
||||
async def auth(self, username: str, password: str) -> str | None:
|
||||
auth_block_key = self.AUTH_BLOCK_KEY_TEMPLATE.format(username=username)
|
||||
|
||||
if not await self._local_storage.raw_contains(auth_block_key):
|
||||
raise IncorrectCredentialsException()
|
||||
|
||||
auth_block = self.generate_auth_block(username=username, password=password)
|
||||
actual_auth_block = await self._local_storage.raw_read(auth_block_key)
|
||||
if auth_block != actual_auth_block:
|
||||
raise IncorrectCredentialsException()
|
||||
|
||||
return auth_block
|
||||
|
||||
async def get_options(self) -> Options:
|
||||
return Options(registration_enabled=True)
|
||||
|
||||
async def _fetch_sense_list(self) -> list[SenseBackendData]:
|
||||
if not self.is_auth:
|
||||
raise NonAuthenticatedException()
|
||||
|
||||
sense_list_key = self.SENSE_LIST_KEY_TEMPLATE.format(username=self._username)
|
||||
sense_list = await self._local_storage.raw_read(sense_list_key) or []
|
||||
return [SenseBackendData.model_validate(sense) for sense in sense_list]
|
||||
|
||||
async def fetch_sense_list(
|
||||
self,
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
) -> list[SenseBackendData]:
|
||||
sense_list = await self._fetch_sense_list()
|
||||
sense_list_filtered = sense_list[(page - 1) * limit:page * limit]
|
||||
|
||||
return sense_list_filtered
|
||||
|
||||
async def fetch_sense(self, sense_id: uuid.UUID) -> SenseBackendData:
|
||||
sense_list = await self._fetch_sense_list()
|
||||
|
||||
for sense in sense_list:
|
||||
if sense.id == sense_id:
|
||||
return sense
|
||||
|
||||
raise SenseNotFoundException()
|
||||
|
||||
async def pull_sense_data(self, data: str, sense_id: uuid.UUID | None = None) -> SenseBackendData:
|
||||
sense_list_key = self.SENSE_LIST_KEY_TEMPLATE.format(username=self._username)
|
||||
sense_list = await self._fetch_sense_list()
|
||||
|
||||
if sense_id is None:
|
||||
sense_ids = {sense.id for sense in sense_list}
|
||||
sense_id = uuid.uuid4()
|
||||
while sense_id in sense_ids:
|
||||
sense_id = uuid.uuid4()
|
||||
sense = SenseBackendData(
|
||||
id=sense_id,
|
||||
data=data,
|
||||
created_at=datetime.now().astimezone(),
|
||||
)
|
||||
sense_list.insert(0, sense)
|
||||
else:
|
||||
for index, sense in enumerate(sense_list):
|
||||
if sense.id == sense_id:
|
||||
break
|
||||
else:
|
||||
raise SenseNotFoundException()
|
||||
|
||||
sense = sense_list[index]
|
||||
sense.data = data
|
||||
sense_list[index] = sense
|
||||
|
||||
await self._local_storage.raw_write(
|
||||
sense_list_key,
|
||||
[sense.model_dump(mode="json") for sense in sense_list],
|
||||
)
|
||||
|
||||
return sense
|
||||
|
||||
async def delete_sense(self, sense_id: uuid.UUID):
|
||||
sense_list_key = self.SENSE_LIST_KEY_TEMPLATE.format(username=self._username)
|
||||
sense_list = await self._fetch_sense_list()
|
||||
|
||||
for index, sense in enumerate(sense_list):
|
||||
if sense.id == sense_id:
|
||||
break
|
||||
else:
|
||||
raise SenseNotFoundException()
|
||||
|
||||
sense_list = sense_list[:index] + sense_list[index + 1:]
|
||||
|
||||
await self._local_storage.raw_write(
|
||||
sense_list_key,
|
||||
[sense.model_dump(mode="json") for sense in sense_list],
|
||||
)
|
||||
9
soul_diary/ui/app/backend/models.py
Normal file
9
soul_diary/ui/app/backend/models.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel
|
||||
|
||||
|
||||
class SenseBackendData(BaseModel):
|
||||
id: uuid.UUID
|
||||
data: str
|
||||
created_at: AwareDatetime
|
||||
59
soul_diary/ui/app/local_storage.py
Normal file
59
soul_diary/ui/app/local_storage.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from soul_diary.ui.app.models import BackendType
|
||||
|
||||
|
||||
class AuthData(BaseModel):
|
||||
backend: BackendType
|
||||
backend_data: dict[str, Any]
|
||||
username: str
|
||||
encryption_key: str
|
||||
token: str
|
||||
|
||||
|
||||
class LocalStorage:
|
||||
def __init__(self, client_storage):
|
||||
self._client_storage = client_storage
|
||||
|
||||
async def store_auth_data(
|
||||
self,
|
||||
backend: BackendType,
|
||||
backend_data: dict[str, Any],
|
||||
username: str,
|
||||
encryption_key: str,
|
||||
token: str,
|
||||
):
|
||||
auth_data = AuthData(
|
||||
backend=backend,
|
||||
backend_data=backend_data,
|
||||
username=username,
|
||||
encryption_key=encryption_key,
|
||||
token=token,
|
||||
)
|
||||
await self.raw_write("soul_diary.client", auth_data.model_dump(mode="json"))
|
||||
|
||||
async def get_auth_data(self) -> AuthData | None:
|
||||
if await self.raw_contains("soul_diary.client"):
|
||||
data = await self.raw_read("soul_diary.client")
|
||||
return AuthData.model_validate(data)
|
||||
|
||||
async def remove_auth_data(self):
|
||||
if await self.raw_contains("soul_diary.client"):
|
||||
await self.raw_remove("soul_diary.client")
|
||||
|
||||
async def clear(self):
|
||||
await self._client_storage.clear_async()
|
||||
|
||||
async def raw_contains(self, key: str) -> bool:
|
||||
return await self._client_storage.contains_key_async(key)
|
||||
|
||||
async def raw_read(self, key: str):
|
||||
return await self._client_storage.get_async(key)
|
||||
|
||||
async def raw_write(self, key: str, value):
|
||||
await self._client_storage.set_async(key, value)
|
||||
|
||||
async def raw_remove(self, key: str):
|
||||
await self._client_storage.remove_async(key)
|
||||
36
soul_diary/ui/app/middleware.py
Normal file
36
soul_diary/ui/app/middleware.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import flet
|
||||
from flet_route import Basket, Params
|
||||
|
||||
from soul_diary.ui.app.backend.local import LocalBackend
|
||||
from soul_diary.ui.app.local_storage import LocalStorage
|
||||
from soul_diary.ui.app.models import BackendType
|
||||
from soul_diary.ui.app.routes import AUTH, SENSE_LIST
|
||||
|
||||
|
||||
async def middleware(page: flet.Page, params: Params, basket: Basket):
|
||||
if getattr(page.app, "backend_client", None) is not None:
|
||||
if page.route == AUTH:
|
||||
await page.go_async(SENSE_LIST)
|
||||
return
|
||||
|
||||
local_storage = LocalStorage(client_storage=page.client_storage)
|
||||
auth_data = await local_storage.get_auth_data()
|
||||
if auth_data is None:
|
||||
await page.go_async(AUTH)
|
||||
return
|
||||
|
||||
if auth_data.backend == BackendType.LOCAL:
|
||||
backend_client_class = LocalBackend
|
||||
else:
|
||||
await page.go_async(AUTH)
|
||||
return
|
||||
|
||||
page.app.backend_client = backend_client_class(
|
||||
local_storage=local_storage,
|
||||
username=auth_data.username,
|
||||
encryption_key=auth_data.encryption_key,
|
||||
token=auth_data.token,
|
||||
**auth_data.backend_data,
|
||||
)
|
||||
if page.route == AUTH:
|
||||
await page.go_async(SENSE_LIST)
|
||||
0
soul_diary/ui/app/middlewares/__init__.py
Normal file
0
soul_diary/ui/app/middlewares/__init__.py
Normal file
15
soul_diary/ui/app/middlewares/base.py
Normal file
15
soul_diary/ui/app/middlewares/base.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import Callable
|
||||
|
||||
import flet
|
||||
from flet_route import Basket, Params
|
||||
|
||||
|
||||
class BaseMiddleware:
|
||||
async def __call__(
|
||||
self,
|
||||
page: flet.Page,
|
||||
params: Params,
|
||||
basket: Basket,
|
||||
next_handler: Callable,
|
||||
):
|
||||
raise NotImplementedError
|
||||
31
soul_diary/ui/app/models.py
Normal file
31
soul_diary/ui/app/models.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, constr
|
||||
|
||||
|
||||
class Emotion(str, enum.Enum):
|
||||
JOY = "радость"
|
||||
FORCE = "сила"
|
||||
CALMNESS = "спокойствие"
|
||||
SADNESS = "грусть"
|
||||
MADNESS = "бешенство"
|
||||
FEAR = "страх"
|
||||
|
||||
|
||||
class BackendType(str, enum.Enum):
|
||||
LOCAL = "local"
|
||||
SOUL = "soul"
|
||||
|
||||
|
||||
class Sense(BaseModel):
|
||||
id: uuid.UUID
|
||||
emotions: list[Emotion] = []
|
||||
feelings: constr(min_length=1, strip_whitespace=True)
|
||||
body: constr(min_length=1, strip_whitespace=True)
|
||||
desires: constr(min_length=1, strip_whitespace=True)
|
||||
created_at: AwareDatetime
|
||||
|
||||
|
||||
class Options(BaseModel):
|
||||
registration_enabled: bool
|
||||
4
soul_diary/ui/app/routes.py
Normal file
4
soul_diary/ui/app/routes.py
Normal file
@@ -0,0 +1,4 @@
|
||||
INDEX = "/"
|
||||
AUTH = "/auth"
|
||||
SENSE_LIST = "/senses"
|
||||
SENSE_ADD = "/senses/add"
|
||||
0
soul_diary/ui/app/views/__init__.py
Normal file
0
soul_diary/ui/app/views/__init__.py
Normal file
325
soul_diary/ui/app/views/auth.py
Normal file
325
soul_diary/ui/app/views/auth.py
Normal file
@@ -0,0 +1,325 @@
|
||||
import asyncio
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
import flet
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from soul_diary.ui.app.backend.exceptions import IncorrectCredentialsException, UserAlreadyExistsException
|
||||
from soul_diary.ui.app.backend.local import LocalBackend
|
||||
from soul_diary.ui.app.local_storage import LocalStorage
|
||||
from soul_diary.ui.app.middlewares.base import BaseMiddleware
|
||||
from soul_diary.ui.app.models import BackendType, Options
|
||||
from soul_diary.ui.app.routes import AUTH, SENSE_LIST
|
||||
from soul_diary.ui.app.views.exceptions import SoulServerIncorrectURL
|
||||
from .base import BaseView, view
|
||||
|
||||
|
||||
class AuthView(BaseView):
|
||||
def __init__(
|
||||
self,
|
||||
local_storage: LocalStorage,
|
||||
backend: BackendType | None = None,
|
||||
backend_data: dict[str, Any] | None = None,
|
||||
middlewares: Sequence[BaseMiddleware | Callable] = (),
|
||||
):
|
||||
self.top_container: flet.Container
|
||||
self.center_container: flet.Container
|
||||
self.bottom_container: flet.Container
|
||||
|
||||
self.local_storage = local_storage
|
||||
self.initial_backend = self.backend = backend
|
||||
self.initial_backend_data = self.backend_data = backend_data or {}
|
||||
self.backend_registration_enabled: bool = True
|
||||
self.username: str | None = None
|
||||
self.password: str | None = None
|
||||
|
||||
super().__init__(middlewares=middlewares)
|
||||
|
||||
async def clear(self):
|
||||
self.top_container.content = None
|
||||
self.center_container.content = None
|
||||
self.bottom_container.content = None
|
||||
|
||||
def clear_data(self):
|
||||
self.backend = self.initial_backend
|
||||
self.backend_data = self.initial_backend_data
|
||||
self.username = None
|
||||
self.password = None
|
||||
|
||||
async def setup(self):
|
||||
self.top_container = flet.Container(alignment=flet.alignment.center)
|
||||
self.center_container = flet.Container(alignment=flet.alignment.center)
|
||||
self.bottom_container = flet.Container(alignment=flet.alignment.center)
|
||||
|
||||
self.container.content = flet.Column(
|
||||
controls=[self.top_container, self.center_container, self.bottom_container],
|
||||
width=300,
|
||||
)
|
||||
self.container.alignment = flet.alignment.center
|
||||
|
||||
self.view.route = AUTH
|
||||
self.view.vertical_alignment = flet.MainAxisAlignment.CENTER
|
||||
|
||||
@view(initial=True)
|
||||
async def entrypoint_view(self, page: flet.Page):
|
||||
if self.initial_backend == BackendType.SOUL:
|
||||
async def connect():
|
||||
async with self.in_progress(page=page):
|
||||
options = await self.connect_to_soul_server()
|
||||
self.backend_registration_enabled = options.registration_enabled
|
||||
await self.credentials_view(page=page)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(connect())
|
||||
elif self.initial_backend == BackendType.LOCAL:
|
||||
await self.credentials_view(page=page)
|
||||
else:
|
||||
await self.backend_view(page=page)
|
||||
|
||||
@view()
|
||||
async def backend_view(self, page: flet.Page):
|
||||
label = flet.Text("Выберите сервер")
|
||||
self.top_container.content = label
|
||||
|
||||
backend_controls = flet.Column()
|
||||
backend_dropdown = flet.Dropdown(
|
||||
label="Бэкенд",
|
||||
options=[
|
||||
flet.dropdown.Option(text="Локально", key=BackendType.LOCAL.value),
|
||||
flet.dropdown.Option(text="Soul Diary сервер", key=BackendType.SOUL.value),
|
||||
],
|
||||
value=None if self.backend is None else self.backend.value,
|
||||
on_change=self.callback_change_backend,
|
||||
)
|
||||
|
||||
container = flet.Container(
|
||||
content=flet.Column(
|
||||
controls=[backend_dropdown, backend_controls],
|
||||
width=300,
|
||||
),
|
||||
)
|
||||
self.center_container.content = container
|
||||
|
||||
connect_button = flet.ElevatedButton(
|
||||
"Выбрать",
|
||||
width=300,
|
||||
height=50,
|
||||
on_click=partial(self.callback_choose_backend, dropdown=backend_dropdown),
|
||||
)
|
||||
self.bottom_container.content = connect_button
|
||||
|
||||
@view()
|
||||
async def soul_server_data_view(self, page: flet.Page):
|
||||
label = flet.Text("Soul Diary сервер")
|
||||
backend_button = flet.IconButton(
|
||||
icon=flet.icons.ARROW_BACK,
|
||||
on_click=self.callback_go_backend,
|
||||
)
|
||||
self.top_container.content = flet.Row(
|
||||
controls=[backend_button, label],
|
||||
width=300,
|
||||
alignment=flet.MainAxisAlignment.START,
|
||||
)
|
||||
|
||||
url_field = flet.TextField(
|
||||
width=300,
|
||||
label="URL",
|
||||
value=self.backend_data.get("url"),
|
||||
on_change=self.callback_change_soul_server_url,
|
||||
)
|
||||
self.center_container.content = url_field
|
||||
|
||||
connect_button = flet.ElevatedButton(
|
||||
"Подключиться",
|
||||
width=300,
|
||||
height=50,
|
||||
on_click=partial(self.callback_soul_server_connect, url_field=url_field),
|
||||
)
|
||||
self.bottom_container.content = connect_button
|
||||
|
||||
@view()
|
||||
async def credentials_view(self, page: flet.Page):
|
||||
controls = []
|
||||
if self.initial_backend is None:
|
||||
backend_data_button = flet.IconButton(
|
||||
icon=flet.icons.ARROW_BACK,
|
||||
on_click=self.callback_go_backend_data,
|
||||
)
|
||||
controls.append(backend_data_button)
|
||||
self.top_container.content = flet.Row(
|
||||
controls=controls,
|
||||
width=300,
|
||||
alignment=flet.MainAxisAlignment.START,
|
||||
)
|
||||
|
||||
username_field = flet.TextField(
|
||||
label="Логин",
|
||||
on_change=self.callback_change_username,
|
||||
)
|
||||
password_field = flet.TextField(
|
||||
label="Пароль",
|
||||
password=True,
|
||||
can_reveal_password=True,
|
||||
on_change=self.callback_change_password,
|
||||
)
|
||||
self.center_container.content = flet.Column(
|
||||
controls=[username_field, password_field],
|
||||
width=300,
|
||||
)
|
||||
|
||||
signin_button = flet.Container(
|
||||
content=flet.ElevatedButton(
|
||||
text="Войти",
|
||||
width=300,
|
||||
height=50,
|
||||
on_click=partial(
|
||||
self.callback_signin,
|
||||
username_field=username_field,
|
||||
password_field=password_field,
|
||||
),
|
||||
),
|
||||
alignment=flet.alignment.center,
|
||||
)
|
||||
signup_button = flet.Container(
|
||||
content=flet.ElevatedButton(
|
||||
text="Зарегистрироваться",
|
||||
width=300,
|
||||
height=50,
|
||||
disabled=not self.backend_registration_enabled,
|
||||
on_click=partial(
|
||||
self.callback_signup,
|
||||
username_field=username_field,
|
||||
password_field=password_field,
|
||||
),
|
||||
),
|
||||
alignment=flet.alignment.center,
|
||||
)
|
||||
|
||||
self.bottom_container.content = flet.Column(controls=[signin_button, signup_button])
|
||||
|
||||
async def callback_change_backend(self, event: flet.ControlEvent):
|
||||
self.backend = BackendType(event.control.value)
|
||||
event.control.error_text = None
|
||||
await event.page.update_async()
|
||||
|
||||
async def callback_choose_backend(self, event: flet.ControlEvent, dropdown: flet.Dropdown):
|
||||
if self.backend == BackendType.LOCAL:
|
||||
await self.credentials_view(page=event.page)
|
||||
elif self.backend == BackendType.SOUL:
|
||||
await self.soul_server_data_view(page=event.page)
|
||||
else:
|
||||
dropdown.error_text = "Выберите тип бекенда"
|
||||
await event.page.update_async()
|
||||
|
||||
async def callback_change_soul_server_url(self, event: flet.ControlEvent):
|
||||
try:
|
||||
AnyHttpUrl(event.control.value or "")
|
||||
except:
|
||||
event.control.error_text = "Некорректный URL"
|
||||
self.backend_data["url"] = None
|
||||
else:
|
||||
event.control.error_text = None
|
||||
self.backend_data["url"] = event.control.value
|
||||
await event.page.update_async()
|
||||
|
||||
async def callback_soul_server_connect(
|
||||
self,
|
||||
event: flet.ControlEvent,
|
||||
url_field: flet.TextField,
|
||||
):
|
||||
if self.backend == BackendType.SOUL:
|
||||
async with self.in_progress(page=event.page):
|
||||
try:
|
||||
options = await self.connect_to_soul_server()
|
||||
except SoulServerIncorrectURL:
|
||||
url_field.error_text = "Некорректный URL"
|
||||
except:
|
||||
url_field.error_text = "Невозможно подключиться к серверу"
|
||||
await event.page.update_async()
|
||||
else:
|
||||
self.backend_registration_enabled = options.registration_enabled
|
||||
else:
|
||||
await self.credentials_view(page=event.page)
|
||||
|
||||
async def connect_to_soul_server(self) -> Options:
|
||||
try:
|
||||
AnyHttpUrl(self.backend_data.get("url"))
|
||||
except ValueError:
|
||||
raise SoulServerIncorrectURL()
|
||||
raise
|
||||
|
||||
async def callback_change_username(self, event: flet.ControlEvent):
|
||||
self.username = event.control.value
|
||||
|
||||
async def callback_change_password(self, event: flet.ControlEvent):
|
||||
self.password = event.control.value
|
||||
|
||||
async def callback_signin(
|
||||
self,
|
||||
event: flet.ControlEvent,
|
||||
username_field: flet.TextField,
|
||||
password_field: flet.TextField,
|
||||
):
|
||||
if not self.username:
|
||||
username_field.error_text = "Заполните имя пользователя"
|
||||
if not self.password:
|
||||
password_field.error_text = "Заполните пароль"
|
||||
if not self.username or not self.password:
|
||||
await event.page.update_async()
|
||||
return
|
||||
|
||||
if self.backend == BackendType.LOCAL:
|
||||
backend_client = LocalBackend(local_storage=self.local_storage)
|
||||
else:
|
||||
raise
|
||||
|
||||
async with self.in_progress(page=event.page):
|
||||
try:
|
||||
await backend_client.login(username=self.username, password=self.password)
|
||||
except IncorrectCredentialsException:
|
||||
password_field.error_text = "Неверные имя пользователя и пароль"
|
||||
await event.page.update_async()
|
||||
return
|
||||
|
||||
event.page.app.backend_client = backend_client
|
||||
await event.page.go_async(SENSE_LIST)
|
||||
|
||||
async def callback_signup(
|
||||
self,
|
||||
event: flet.ControlEvent,
|
||||
username_field: flet.TextField,
|
||||
password_field: flet.TextField,
|
||||
):
|
||||
if not self.username:
|
||||
username_field.error_text = "Заполните имя пользователя"
|
||||
if not self.password:
|
||||
password_field.error_text = "Заполните пароль"
|
||||
if not self.username or not self.password:
|
||||
await event.page.update_async()
|
||||
return
|
||||
|
||||
if self.backend == BackendType.LOCAL:
|
||||
backend_client = LocalBackend(local_storage=self.local_storage)
|
||||
else:
|
||||
raise
|
||||
|
||||
async with self.in_progress(page=event.page):
|
||||
try:
|
||||
await backend_client.registration(username=self.username, password=self.password)
|
||||
except UserAlreadyExistsException:
|
||||
username_field.error_text = "Пользователь с таким именем уже существует"
|
||||
await event.page.update_async()
|
||||
return
|
||||
|
||||
event.page.app.backend_client = backend_client
|
||||
await event.page.go_async(SENSE_LIST)
|
||||
|
||||
async def callback_go_backend(self, event: flet.ControlEvent):
|
||||
await self.backend_view(page=event.page)
|
||||
|
||||
async def callback_go_backend_data(self, event: flet.ControlEvent):
|
||||
if self.backend == BackendType.SOUL:
|
||||
await self.soul_server_data_view(page=event.page)
|
||||
elif self.backend == BackendType.LOCAL:
|
||||
await self.backend_view(page=event.page)
|
||||
117
soul_diary/ui/app/views/base.py
Normal file
117
soul_diary/ui/app/views/base.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import partial, reduce
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
import flet
|
||||
from flet_route import Basket, Params
|
||||
|
||||
from soul_diary.ui.app.middlewares.base import BaseMiddleware
|
||||
|
||||
|
||||
def view(initial: bool = False, disabled: bool = False):
|
||||
def decorator(function: Callable):
|
||||
async def wrapper(self, page: flet.Page):
|
||||
await self.clear()
|
||||
|
||||
await function(self, page=page)
|
||||
await page.update_async()
|
||||
|
||||
wrapper._view_enabled = not disabled
|
||||
wrapper._view_initial = initial
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def setup_middlewares(function: Callable):
|
||||
async def wrapper(self, page: flet.Page, params: Params, basket: Basket) -> flet.View:
|
||||
entrypoint = partial(function, self)
|
||||
if self.middlewares:
|
||||
entrypoint = partial(self.middlewares[-1], next_handler=entrypoint)
|
||||
entrypoint = reduce(
|
||||
lambda entrypoint, function: partial(entrypoint, next_handler=function),
|
||||
self.middlewares[-2::-1],
|
||||
entrypoint,
|
||||
)
|
||||
return await entrypoint(page=page, params=params, basket=basket)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class MetaView(type):
|
||||
def __init__(cls, name: str, bases: tuple[type], attrs: dict[str, Any]):
|
||||
super().__init__(name, bases, attrs)
|
||||
|
||||
is_abstract = attrs.pop("is_abstract", False)
|
||||
if not is_abstract:
|
||||
cls.setup_class(attrs=attrs)
|
||||
|
||||
def setup_class(cls, attrs: dict[str, Any]):
|
||||
initial_view = None
|
||||
for attr in attrs.values():
|
||||
view_enabled = getattr(attr, "_view_enabled", False)
|
||||
view_initial = getattr(attr, "_view_initial", False)
|
||||
if view_enabled and view_initial:
|
||||
if initial_view is not None:
|
||||
raise ValueError(f"Initial view already defined: {initial_view.__name__}")
|
||||
initial_view = attr
|
||||
if initial_view is None:
|
||||
raise ValueError("Initial view must be defined")
|
||||
|
||||
cls._initial_view = initial_view
|
||||
|
||||
|
||||
class BaseView(metaclass=MetaView):
|
||||
is_abstract = True
|
||||
_initial_view: Callable | None
|
||||
|
||||
def __init__(self, middlewares: Sequence[BaseMiddleware | Callable] = ()):
|
||||
self.middlewares = middlewares
|
||||
|
||||
self.container: flet.Container
|
||||
self.stack: flet.Stack
|
||||
self.view: flet.View
|
||||
|
||||
@setup_middlewares
|
||||
async def entrypoint(self, page: flet.Page, params: Params, basket: Basket) -> flet.View:
|
||||
self.container = flet.Container()
|
||||
self.stack = flet.Stack(controls=[self.container])
|
||||
self.view = flet.View(controls=[self.stack], route="/test")
|
||||
|
||||
await self.setup()
|
||||
await self.clear()
|
||||
self.clear_data()
|
||||
await self.run_initial_view(page=page)
|
||||
return self.view
|
||||
|
||||
async def setup(self):
|
||||
pass
|
||||
|
||||
async def clear(self):
|
||||
pass
|
||||
|
||||
def clear_data(self):
|
||||
pass
|
||||
|
||||
@asynccontextmanager
|
||||
async def in_progress(self, page: flet.Page, tooltip: str | None = None):
|
||||
for control in self.stack.controls:
|
||||
control.disabled = True
|
||||
self.stack.controls.append(
|
||||
flet.Container(
|
||||
content=flet.ProgressRing(tooltip=tooltip),
|
||||
alignment=flet.alignment.center,
|
||||
),
|
||||
)
|
||||
await page.update_async()
|
||||
|
||||
yield
|
||||
|
||||
self.stack.controls.pop()
|
||||
for control in self.stack.controls:
|
||||
control.disabled = False
|
||||
await page.update_async()
|
||||
|
||||
async def run_initial_view(self, page: flet.Page):
|
||||
if self._initial_view is not None:
|
||||
await self._initial_view(page=page)
|
||||
2
soul_diary/ui/app/views/exceptions.py
Normal file
2
soul_diary/ui/app/views/exceptions.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class SoulServerIncorrectURL(Exception):
|
||||
pass
|
||||
0
soul_diary/ui/app/views/onboarding.py
Normal file
0
soul_diary/ui/app/views/onboarding.py
Normal file
235
soul_diary/ui/app/views/sense_add.py
Normal file
235
soul_diary/ui/app/views/sense_add.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from functools import partial
|
||||
from typing import Awaitable, Callable, Sequence
|
||||
|
||||
import flet
|
||||
|
||||
from soul_diary.ui.app.middlewares.base import BaseMiddleware
|
||||
from soul_diary.ui.app.models import Emotion
|
||||
from soul_diary.ui.app.routes import SENSE_ADD, SENSE_LIST
|
||||
from .base import BaseView, view
|
||||
|
||||
|
||||
class SenseAddView(BaseView):
|
||||
def __init__(
|
||||
self,
|
||||
middlewares: Sequence[BaseMiddleware | Callable[[flet.Page], Awaitable]] = (),
|
||||
):
|
||||
self.title: flet.Text
|
||||
self.content_container: flet.Container
|
||||
self.buttons_row: flet.Row
|
||||
|
||||
self.emotions: list[Emotion] = []
|
||||
self.feelings: str | None = None
|
||||
self.body: str | None = None
|
||||
self.desires: str | None = None
|
||||
|
||||
super().__init__(middlewares=middlewares)
|
||||
|
||||
async def clear(self):
|
||||
self.title.value = ""
|
||||
self.content_container.content = None
|
||||
self.buttons_row.controls = []
|
||||
|
||||
def clear_data(self):
|
||||
self.emotions = []
|
||||
self.feelings = None
|
||||
self.body = None
|
||||
self.desires = None
|
||||
|
||||
async def setup(self):
|
||||
# Top
|
||||
self.title = flet.Text()
|
||||
close_button = flet.IconButton(icon=flet.icons.CLOSE, on_click=self.callback_close)
|
||||
top_container = flet.Container(
|
||||
content=flet.Row(
|
||||
[self.title, close_button],
|
||||
alignment=flet.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
margin=10,
|
||||
)
|
||||
|
||||
# Center
|
||||
self.content_container = flet.Container()
|
||||
center_container = flet.Container(content=self.content_container, margin=10)
|
||||
|
||||
# Bottom
|
||||
self.buttons_row = flet.Row()
|
||||
bottom_container = flet.Container(content=self.buttons_row, margin=10)
|
||||
|
||||
# Build
|
||||
self.container.content = flet.Column(
|
||||
controls=[top_container, center_container, bottom_container],
|
||||
width=600,
|
||||
)
|
||||
self.container.alignment = flet.alignment.center
|
||||
|
||||
self.view.route = SENSE_ADD
|
||||
self.view.vertical_alignment = flet.MainAxisAlignment.CENTER
|
||||
|
||||
@view(initial=True)
|
||||
async def emotions_view(self, page: flet.Page):
|
||||
self.title.value = "Что ты чувствуешь?"
|
||||
|
||||
chips = flet.Row(
|
||||
controls=[
|
||||
flet.Chip(
|
||||
label=flet.Text(emotion.value),
|
||||
show_checkmark=False,
|
||||
selected=emotion.value in self.emotions,
|
||||
on_select=partial(self.callback_choose_emotion, emotion=emotion),
|
||||
)
|
||||
for emotion in Emotion
|
||||
],
|
||||
wrap=True,
|
||||
)
|
||||
self.content_container.content = flet.Column(
|
||||
controls=[chips],
|
||||
)
|
||||
|
||||
next_button = flet.IconButton(
|
||||
flet.icons.ARROW_FORWARD,
|
||||
on_click=self.callback_go_feelings_from_emotions,
|
||||
)
|
||||
self.buttons_row.controls = [next_button]
|
||||
self.buttons_row.alignment = flet.MainAxisAlignment.END
|
||||
|
||||
@view()
|
||||
async def feelings_view(self, page: flet.Page):
|
||||
self.title.value = "Опиши свои чувства"
|
||||
|
||||
self.content_container.content = flet.TextField(
|
||||
value=self.feelings,
|
||||
multiline=True,
|
||||
min_lines=10,
|
||||
max_lines=10,
|
||||
on_change=self.callback_change_feelings,
|
||||
)
|
||||
|
||||
previous_button = flet.IconButton(
|
||||
flet.icons.ARROW_BACK,
|
||||
on_click=self.callback_go_emotions_from_feelings,
|
||||
)
|
||||
next_button = flet.IconButton(
|
||||
flet.icons.ARROW_FORWARD,
|
||||
on_click=self.callback_go_body_from_feelings,
|
||||
)
|
||||
self.buttons_row.controls = [previous_button, next_button]
|
||||
self.buttons_row.alignment = flet.MainAxisAlignment.SPACE_BETWEEN
|
||||
|
||||
@view()
|
||||
async def body_view(self, page: flet.Page):
|
||||
self.title.value = "Опиши свои телесные ощущения"
|
||||
|
||||
self.content_container.content = flet.TextField(
|
||||
value=self.body,
|
||||
multiline=True,
|
||||
min_lines=10,
|
||||
max_lines=10,
|
||||
on_change=self.callback_change_body,
|
||||
)
|
||||
|
||||
previous_button = flet.IconButton(
|
||||
flet.icons.ARROW_BACK,
|
||||
on_click=self.callback_go_feelings_from_body,
|
||||
)
|
||||
next_button = flet.IconButton(
|
||||
flet.icons.ARROW_FORWARD,
|
||||
on_click=self.callback_go_desires_from_body,
|
||||
)
|
||||
self.buttons_row.controls = [previous_button, next_button]
|
||||
self.buttons_row.alignment = flet.MainAxisAlignment.SPACE_BETWEEN
|
||||
|
||||
@view()
|
||||
async def desires_view(self, page: flet.Page):
|
||||
self.title.value = "Опиши свои желания на данный момент"
|
||||
|
||||
self.content_container.content = flet.TextField(
|
||||
value=self.desires,
|
||||
multiline=True,
|
||||
min_lines=10,
|
||||
max_lines=10,
|
||||
on_change=self.callback_change_desires,
|
||||
)
|
||||
|
||||
previous_button = flet.IconButton(
|
||||
flet.icons.ARROW_BACK,
|
||||
on_click=self.callback_go_body_from_desires,
|
||||
)
|
||||
apply_button = flet.IconButton(flet.icons.CREATE, on_click=self.callback_add_sense)
|
||||
self.buttons_row.controls = [previous_button, apply_button]
|
||||
self.buttons_row.alignment = flet.MainAxisAlignment.SPACE_BETWEEN
|
||||
|
||||
async def callback_close(self, event: flet.ControlEvent):
|
||||
self.clear_data()
|
||||
await event.page.go_async(SENSE_LIST)
|
||||
|
||||
async def callback_choose_emotion(self, event: flet.ControlEvent, emotion: Emotion):
|
||||
if event.control.selected:
|
||||
self.emotions.append(emotion)
|
||||
emotions_column = self.content_container.content
|
||||
if len(emotions_column.controls) > 1:
|
||||
emotions_column.controls = emotions_column.controls[:1]
|
||||
await event.page.update_async()
|
||||
else:
|
||||
self.emotions.remove(emotion)
|
||||
|
||||
async def callback_change_feelings(self, event: flet.ControlEvent):
|
||||
self.feelings = event.control.value
|
||||
|
||||
async def callback_change_body(self, event: flet.ControlEvent):
|
||||
self.body = event.control.value
|
||||
|
||||
async def callback_change_desires(self, event: flet.ControlEvent):
|
||||
self.desires = event.control.value
|
||||
|
||||
async def callback_go_emotions_from_feelings(self, event: flet.ControlEvent):
|
||||
await self.emotions_view(page=event.page)
|
||||
|
||||
async def callback_go_feelings_from_emotions(self, event: flet.ControlEvent):
|
||||
if not self.emotions:
|
||||
emotions_column = self.content_container.content
|
||||
error_text = flet.Text("Выберите как минимум одну эмоцию", color=flet.colors.RED)
|
||||
emotions_column.controls = [emotions_column.controls[0], error_text]
|
||||
await event.page.update_async()
|
||||
return
|
||||
|
||||
await self.feelings_view(page=event.page)
|
||||
|
||||
async def callback_go_feelings_from_body(self, event: flet.ControlEvent):
|
||||
await self.feelings_view(page=event.page)
|
||||
|
||||
async def callback_go_body_from_feelings(self, event: flet.ControlEvent):
|
||||
if self.feelings is None or not self.feelings.strip():
|
||||
self.content_container.content.error_text = "Коротко опиши свои чувства"
|
||||
await event.page.update_async()
|
||||
return
|
||||
|
||||
await self.body_view(page=event.page)
|
||||
|
||||
async def callback_go_body_from_desires(self, event: flet.ControlEvent):
|
||||
await self.body_view(page=event.page)
|
||||
|
||||
async def callback_go_desires_from_body(self, event: flet.ControlEvent):
|
||||
if self.body is None or not self.body.strip():
|
||||
self.content_container.content.error_text = "Коротко опиши свои телесные ощущения"
|
||||
await event.page.update_async()
|
||||
return
|
||||
|
||||
await self.desires_view(page=event.page)
|
||||
|
||||
async def callback_add_sense(self, event: flet.ControlEvent):
|
||||
if self.desires is None or not self.desires.strip():
|
||||
self.content_container.content.error_text = "Коротко опиши свои желания"
|
||||
await event.page.update_async()
|
||||
return
|
||||
|
||||
async with self.in_progress(page=event.page):
|
||||
await event.page.app.backend_client.create_sense(
|
||||
emotions=self.emotions,
|
||||
feelings=self.feelings,
|
||||
body=self.body,
|
||||
desires=self.desires,
|
||||
)
|
||||
|
||||
self.clear_data()
|
||||
await event.page.go_async(SENSE_LIST)
|
||||
95
soul_diary/ui/app/views/sense_list.py
Normal file
95
soul_diary/ui/app/views/sense_list.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
from typing import Awaitable, Callable, Sequence
|
||||
|
||||
import flet
|
||||
|
||||
from soul_diary.ui.app.local_storage import LocalStorage
|
||||
from soul_diary.ui.app.middlewares.base import BaseMiddleware
|
||||
from soul_diary.ui.app.models import Sense
|
||||
from soul_diary.ui.app.routes import AUTH, SENSE_ADD, SENSE_LIST
|
||||
from .base import BaseView, view
|
||||
|
||||
|
||||
class SenseListView(BaseView):
|
||||
def __init__(
|
||||
self,
|
||||
local_storage: LocalStorage,
|
||||
middlewares: Sequence[BaseMiddleware | Callable[[flet.Page], Awaitable]] = (),
|
||||
):
|
||||
self.cards: flet.Column
|
||||
|
||||
self.local_storage = local_storage
|
||||
|
||||
super().__init__(middlewares=middlewares)
|
||||
|
||||
async def setup(self):
|
||||
self.cards = flet.Column(alignment=flet.alignment.center, width=400)
|
||||
|
||||
add_button = flet.IconButton(
|
||||
icon=flet.icons.ADD_CIRCLE_OUTLINE,
|
||||
on_click=self.callback_add_sense,
|
||||
)
|
||||
settings_button = flet.IconButton(
|
||||
icon=flet.icons.SETTINGS,
|
||||
)
|
||||
logout_button = flet.IconButton(
|
||||
icon=flet.icons.LOGOUT,
|
||||
on_click=self.callback_logout,
|
||||
)
|
||||
top_container = flet.Container(
|
||||
content=flet.Row(
|
||||
controls=[add_button, settings_button, logout_button],
|
||||
alignment=flet.MainAxisAlignment.END,
|
||||
),
|
||||
)
|
||||
|
||||
self.container.content = flet.Column(
|
||||
controls=[top_container, self.cards],
|
||||
width=400,
|
||||
)
|
||||
self.container.alignment = flet.alignment.center
|
||||
|
||||
self.view.route = SENSE_LIST
|
||||
self.view.vertical_alignment = flet.MainAxisAlignment.CENTER
|
||||
self.view.scroll = flet.ScrollMode.ALWAYS
|
||||
|
||||
async def clear(self):
|
||||
self.cards.controls = []
|
||||
|
||||
@view(initial=True)
|
||||
async def sense_list_view(self, page: flet.Page):
|
||||
self.cards.controls = [
|
||||
flet.Container(
|
||||
content=flet.ProgressRing(),
|
||||
alignment=flet.alignment.center,
|
||||
),
|
||||
]
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(self.render_sense_list(page=page))
|
||||
|
||||
async def render_sense_list(self, page: flet.Page):
|
||||
senses = await page.app.backend_client.get_sense_list()
|
||||
self.cards.controls = [await self.render_card_from_sense(sense) for sense in senses]
|
||||
await page.update_async()
|
||||
|
||||
async def render_card_from_sense(self, sense: Sense) -> flet.Card:
|
||||
feelings = flet.Container(content=flet.Text(sense.feelings), expand=True)
|
||||
created_datetime = flet.Text(sense.created_at.strftime("%d %b %H:%M"))
|
||||
|
||||
return flet.Card(
|
||||
content=flet.Container(
|
||||
content=flet.Column(controls=[feelings, created_datetime]),
|
||||
padding=10,
|
||||
),
|
||||
width=400,
|
||||
height=100,
|
||||
)
|
||||
|
||||
async def callback_add_sense(self, event: flet.ControlEvent):
|
||||
await event.page.go_async(SENSE_ADD)
|
||||
|
||||
async def callback_logout(self, event: flet.ControlEvent):
|
||||
await self.local_storage.remove_auth_data()
|
||||
event.page.app.backend_client = None
|
||||
await event.page.go_async(AUTH)
|
||||
11
soul_diary/ui/cli.py
Normal file
11
soul_diary/ui/cli.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import typer
|
||||
|
||||
from . import web
|
||||
|
||||
|
||||
def get_cli() -> typer.Typer:
|
||||
cli = typer.Typer()
|
||||
|
||||
cli.add_typer(web.get_cli(), name="web")
|
||||
|
||||
return cli
|
||||
20
soul_diary/ui/service.py
Normal file
20
soul_diary/ui/service.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from facet import ServiceMixin
|
||||
|
||||
from .web import WebService, WebSettings, get_service as get_web_service
|
||||
|
||||
|
||||
class UIService(ServiceMixin):
|
||||
def __init__(self, web: WebService):
|
||||
self._web = web
|
||||
|
||||
@property
|
||||
def dependencies(self) -> list[ServiceMixin]:
|
||||
return [
|
||||
self._web,
|
||||
]
|
||||
|
||||
|
||||
def get_service() -> UIService:
|
||||
settings = WebSettings()
|
||||
web = get_web_service(settings=settings)
|
||||
return UIService(web=web)
|
||||
3
soul_diary/ui/web/__init__.py
Normal file
3
soul_diary/ui/web/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cli import get_cli
|
||||
from .service import WebService, get_service
|
||||
from .settings import WebSettings
|
||||
7
soul_diary/ui/web/__main__.py
Normal file
7
soul_diary/ui/web/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .cli import get_cli
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli = get_cli()
|
||||
|
||||
cli()
|
||||
28
soul_diary/ui/web/cli.py
Normal file
28
soul_diary/ui/web/cli.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import asyncio
|
||||
import typer
|
||||
|
||||
from .service import get_service
|
||||
from .settings import WebSettings, get_settings
|
||||
|
||||
|
||||
def run(ctx: typer.Context):
|
||||
settings: WebSettings = ctx.obj["settings"]
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
frontend_service = get_service(settings=settings)
|
||||
|
||||
loop.run_until_complete(frontend_service.run())
|
||||
|
||||
|
||||
def settings_callback(ctx: typer.Context):
|
||||
ctx.obj = ctx.obj or {}
|
||||
ctx.obj["settings"] = get_settings()
|
||||
|
||||
|
||||
def get_cli() -> typer.Typer:
|
||||
cli = typer.Typer()
|
||||
|
||||
cli.callback()(settings_callback)
|
||||
cli.command(name="run")(run)
|
||||
|
||||
return cli
|
||||
39
soul_diary/ui/web/service.py
Normal file
39
soul_diary/ui/web/service.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import Any
|
||||
|
||||
import flet_fastapi
|
||||
import uvicorn
|
||||
from facet import ServiceMixin
|
||||
|
||||
from soul_diary.ui.app.models import BackendType
|
||||
from soul_diary.ui.app import SoulDiaryApp
|
||||
from .settings import WebSettings
|
||||
|
||||
|
||||
class UvicornServer(uvicorn.Server):
|
||||
def install_signal_handlers(self):
|
||||
pass
|
||||
|
||||
|
||||
class WebService(ServiceMixin):
|
||||
def __init__(self, port: int = 8000, backend_data: dict[str, Any] | None = None):
|
||||
self._port = port
|
||||
self._backend_data = backend_data
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
async def start(self):
|
||||
app = flet_fastapi.app(SoulDiaryApp(
|
||||
# backend=BackendType.SOUL,
|
||||
# backend_data=self._backend_data,
|
||||
backend=BackendType.LOCAL,
|
||||
).run)
|
||||
config = uvicorn.Config(app=app, host="0.0.0.0", port=self._port)
|
||||
server = UvicornServer(config)
|
||||
|
||||
self.add_task(server.serve())
|
||||
|
||||
|
||||
def get_service(settings: WebSettings) -> WebService:
|
||||
return WebService(port=settings.port, backend_data=settings.backend_data)
|
||||
15
soul_diary/ui/web/settings.py
Normal file
15
soul_diary/ui/web/settings.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import conint
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class WebSettings(BaseSettings):
|
||||
port: conint(ge=1, le=65535) = 8000
|
||||
backend_data: dict[str, Any] = {
|
||||
"url": "http://localhost:8001",
|
||||
}
|
||||
|
||||
|
||||
def get_settings() -> WebSettings:
|
||||
return WebSettings()
|
||||
Reference in New Issue
Block a user