-
-
Notifications
You must be signed in to change notification settings - Fork 12
/
app.py
372 lines (330 loc) · 13.1 KB
/
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
from typing import Callable, Optional
from flet import (
AlertDialog,
FilePicker,
FilePickerUploadFile,
Page,
SnackBar,
TemplateRoute,
View,
app,
)
from loguru import logger
from pandas import DataFrame
from tuttle.app.auth.view import ProfileScreen, SplashScreen
from tuttle.app.contracts.view import ContractEditorScreen, ViewContractScreen
from tuttle.app.core.abstractions import TView, TViewParams
from tuttle.app.core.client_storage_impl import ClientStorageImpl
from tuttle.app.core.database_storage_impl import DatabaseStorageImpl
from tuttle.app.core.models import RouteView
from tuttle.app.core.utils import AlertDialogControls
from tuttle.app.core.views import THeading
from tuttle.app.error_views.page_not_found_screen import Error404Screen
from tuttle.app.home.view import HomeScreen
from tuttle.app.preferences.intent import PreferencesIntent
from tuttle.app.preferences.model import PreferencesStorageKeys
from tuttle.app.preferences.view import PreferencesScreen
from tuttle.app.projects.view import ProjectEditorScreen, ViewProjectScreen
from tuttle.app.res.colors import (
BLACK_COLOR_ALT,
ERROR_COLOR,
PRIMARY_COLOR,
WHITE_COLOR,
)
from tuttle.app.res.dimens import MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH
from tuttle.app.res.fonts import APP_FONTS, HEADLINE_4_SIZE, HEADLINE_FONT
from tuttle.app.res.res_utils import (
CONTRACT_DETAILS_SCREEN_ROUTE,
CONTRACT_EDITOR_SCREEN_ROUTE,
HOME_SCREEN_ROUTE,
PREFERENCES_SCREEN_ROUTE,
PROFILE_SCREEN_ROUTE,
PROJECT_DETAILS_SCREEN_ROUTE,
PROJECT_EDITOR_SCREEN_ROUTE,
SPLASH_SCREEN_ROUTE,
)
from tuttle.app.res.theme import APP_THEME, THEME_MODES, get_theme_mode_from_value
from tuttle.app.timetracking.intent import TimeTrackingIntent
class TuttleApp:
"""The main application class"""
def __init__(
self,
page: Page,
debug_mode: bool = False,
):
""" """
self.debug_mode = debug_mode
self.page = page
self.page.title = "Tuttle"
self.page.fonts = APP_FONTS
self.page.theme = APP_THEME
self.client_storage = ClientStorageImpl(page=self.page)
self.db = DatabaseStorageImpl(
store_demo_timetracking_dataframe=self.store_demo_timetracking_dataframe,
debug_mode=self.debug_mode,
)
preferences = PreferencesIntent(self.client_storage)
preferences_result = preferences.get_preference_by_key(
PreferencesStorageKeys.theme_mode_key
)
theme = (
preferences_result.data
if preferences_result.data
else THEME_MODES.dark.value
)
self.page.theme_mode = theme
self.page.window_min_width = MIN_WINDOW_WIDTH
self.page.window_min_height = MIN_WINDOW_HEIGHT
self.page.window_width = MIN_WINDOW_HEIGHT * 3
self.page.window_height = MIN_WINDOW_HEIGHT * 2
self.file_picker = FilePicker()
self.page.overlay.append(self.file_picker)
"""holds the RouteView object associated with a route
used in on route change"""
self.route_to_route_view_cache = {}
self.page.on_route_change = self.on_route_change
self.page.on_view_pop = self.on_view_pop
self.route_parser = TuttleRoutes(self)
self.current_route_view: Optional[RouteView] = None
self.page.on_resize = self.page_resize
def page_resize(self, e):
if self.current_route_view:
self.current_route_view.on_window_resized(
self.page.window_width, self.page.window_height
)
def pick_file_callback(
self,
on_file_picker_result,
allowed_extensions,
dialog_title,
file_type,
):
# used by views to request a file upload
self.file_picker.on_result = on_file_picker_result
self.file_picker.pick_files(
allow_multiple=False,
allowed_extensions=allowed_extensions,
dialog_title=dialog_title,
file_type=file_type,
)
def on_theme_mode_changed(self, selected_theme: str):
"""callback function used by views for changing app theme mode"""
mode = get_theme_mode_from_value(selected_theme)
self.page.theme_mode = mode.value
self.page.update()
def show_snack(
self,
message: str,
is_error: bool = False,
action_label: Optional[str] = None,
action_callback: Optional[Callable] = None,
):
"""callback function used by views to display a snack bar message"""
if self.page.snack_bar and self.page.snack_bar.open:
self.page.snack_bar.open = False
self.page.update()
self.page.snack_bar = SnackBar(
THeading(
title=message,
size=HEADLINE_4_SIZE,
color=ERROR_COLOR if is_error else WHITE_COLOR,
),
bgcolor=WHITE_COLOR if is_error else BLACK_COLOR_ALT,
action=action_label,
action_color=PRIMARY_COLOR,
on_action=action_callback,
)
self.page.snack_bar.open = True
self.page.update()
def control_alert_dialog(
self,
dialog: Optional[AlertDialog] = None,
control: AlertDialogControls = AlertDialogControls.CLOSE,
):
"""handles adding, opening and closing of page alert dialogs"""
if control.value == AlertDialogControls.ADD_AND_OPEN.value:
if self.page.dialog:
# make sure no two dialogs attempt to open at once
self.page.dialog.open = False
self.page.update()
if dialog:
self.page.dialog = dialog
dialog.open = True
self.page.update()
if control.value == AlertDialogControls.CLOSE.value:
if self.page.dialog:
dialog.open = False
self.page.update()
def change_route(self, to_route: str, data: Optional[any] = None):
"""navigates to a new route"""
newRoute = to_route if data is None else f"{to_route}/{data}"
self.page.go(newRoute)
def on_view_pop(self, view: Optional[View] = None):
"""invoked on back pressed"""
if len(self.page.views) == 1:
return
self.page.views.pop()
current_page_view: View = self.page.views[-1]
self.page.go(current_page_view.route)
if current_page_view.controls:
try:
# the controls should contain a TView as first control
tuttle_view: TView = current_page_view.controls[0]
# notify view that it has been resumed
tuttle_view.on_resume_after_back_pressed()
except Exception as e:
logger.error(
f"Exception raised @TuttleApp.on_view_pop {e.__class__.__name__}"
)
logger.exception(e)
def on_route_change(self, route):
"""auto invoked when the route changes
parses the new destination route
then appends the new page to page views
"""
# if route is already in stack, get it's view
# this happens when the user presses back
view_for_route = None
for view in self.page.views:
if view.route == route.route:
view_for_route = view
break
# get a new view if no view found in stack
if not view_for_route:
route_view_wrapper = self.route_parser.parse_route(pageRoute=route.route)
if not route_view_wrapper.keep_back_stack:
"""clear previous views"""
self.route_to_route_view_cache.clear()
self.page.views.clear()
view_for_route = route_view_wrapper.view
self.route_to_route_view_cache[route.route] = route_view_wrapper
self.page.views.append(view_for_route)
self.current_route_view: RouteView = self.route_to_route_view_cache[route.route]
self.page.update()
self.current_route_view.on_window_resized(
self.page.window_width, self.page.window_height
)
def store_demo_timetracking_dataframe(self, time_tracking_data: DataFrame):
"""Caches the time tracking dataframe created from a demo installation"""
self.timetracking_intent = TimeTrackingIntent(
client_storage=self.client_storage
)
self.timetracking_intent.set_timetracking_data(data=time_tracking_data)
def build(self):
self.page.go(self.page.route)
def close(self):
"""Closes the application."""
self.page.window_close()
def reset_and_quit(self):
"""Resets the application and quits."""
self.db.reset_database()
self.close()
class TuttleRoutes:
"""Utility class for parsing of routes to destination views"""
def __init__(self, app: TuttleApp):
# init callbacks for some views
self.on_theme_changed = app.on_theme_mode_changed
self.on_reset_and_quit = app.reset_and_quit
self.on_install_demo_data = app.db.install_demo_data
# init common params for views
self.tuttle_view_params = TViewParams(
navigate_to_route=app.change_route,
show_snack=app.show_snack,
dialog_controller=app.control_alert_dialog,
on_navigate_back=app.on_view_pop,
client_storage=app.client_storage,
pick_file_callback=app.pick_file_callback,
)
def get_page_route_view(
self,
routeName: str,
view: TView,
) -> RouteView:
"""Constructs the view with a given route"""
view_container = View(
padding=0,
spacing=0,
route=routeName,
scroll=view.page_scroll_type,
controls=[view],
vertical_alignment=view.vertical_alignment_in_parent,
horizontal_alignment=view.horizontal_alignment_in_parent,
)
return RouteView(
view=view_container,
on_window_resized=view.on_window_resized_listener,
keep_back_stack=view.keep_back_stack,
)
def parse_route(self, pageRoute: str):
"""parses a given route path and returns it's view"""
routePath = TemplateRoute(pageRoute)
screen = None
if routePath.match(SPLASH_SCREEN_ROUTE):
screen = SplashScreen(
params=self.tuttle_view_params,
on_install_demo_data=self.on_install_demo_data,
)
elif routePath.match(HOME_SCREEN_ROUTE):
screen = HomeScreen(
params=self.tuttle_view_params,
)
elif routePath.match(PROFILE_SCREEN_ROUTE):
screen = ProfileScreen(
params=self.tuttle_view_params,
)
elif routePath.match(CONTRACT_EDITOR_SCREEN_ROUTE):
screen = ContractEditorScreen(params=self.tuttle_view_params)
elif routePath.match(f"{CONTRACT_DETAILS_SCREEN_ROUTE}/:contractId"):
screen = ViewContractScreen(
params=self.tuttle_view_params, contract_id=routePath.contractId
)
elif routePath.match(f"{CONTRACT_EDITOR_SCREEN_ROUTE}/:contractId"):
contractId = None
if hasattr(routePath, "contractId"):
contractId = routePath.contractId
screen = ContractEditorScreen(
params=self.tuttle_view_params, contract_id_if_editing=contractId
)
elif routePath.match(PREFERENCES_SCREEN_ROUTE):
screen = PreferencesScreen(
params=self.tuttle_view_params,
on_theme_changed_callback=self.on_theme_changed,
on_reset_app_callback=self.on_reset_and_quit,
)
elif routePath.match(PROJECT_EDITOR_SCREEN_ROUTE):
screen = ProjectEditorScreen(params=self.tuttle_view_params)
elif routePath.match(f"{PROJECT_DETAILS_SCREEN_ROUTE}/:projectId"):
screen = ViewProjectScreen(
params=self.tuttle_view_params, project_id=routePath.projectId
)
elif routePath.match(PROJECT_EDITOR_SCREEN_ROUTE) or routePath.match(
f"{PROJECT_EDITOR_SCREEN_ROUTE}/:projectId"
):
projectId = None
if hasattr(routePath, "projectId"):
projectId = routePath.projectId
screen = ProjectEditorScreen(
params=self.tuttle_view_params, project_id_if_editing=projectId
)
else:
screen = Error404Screen(params=self.tuttle_view_params)
return self.get_page_route_view(routePath.route, view=screen)
def get_assets_uploads_url(with_parent_dir: bool = False):
uploads_parent_dir = "assets"
uploads_dir = "uploads"
if with_parent_dir:
return f"{uploads_parent_dir}/{uploads_dir}"
return uploads_dir
def main(page: Page):
"""Entry point of the app"""
app = TuttleApp(page)
# if database does not exist, create it
app.db.ensure_database()
app.build()
if __name__ == "__main__":
app(
name="Tuttle",
target=main,
assets_dir="assets",
upload_dir=get_assets_uploads_url(with_parent_dir=True),
)