Household planning and management software meant to organise and streamline neurodivergent households.
0

Configure Feed

Select the types of activity you want to include in your feed.

move lockscreen, translations and msg to their own files

nnuuvv (Jan 17, 2026, 12:59 AM +0100) fdace36d 1a34cb39

+254 -231
+27
sweetnhouse_dashboard/src/msg.gleam
··· 1 + import formal/form.{type Form} 2 + import rsvp 3 + import translations.{type Translations} 4 + 5 + pub type Msg { 6 + ApiReturnedTranslations(Result(Translations, rsvp.Error)) 7 + LockScreenEnteredPin(Result(PinCodeFormData, Form(PinCodeFormData))) 8 + UserChangedLanguage(Language) 9 + LockNow 10 + } 11 + 12 + // ------------------------------------------------------------------------------ 13 + // keeping these here cause putting em in their own module seems silly atm ------ 14 + // ------------------------------------------------------------------------------ 15 + 16 + /// A pin code consists of 6 digits 17 + /// This can be a household pin or a admin pin. 18 + pub type PinCode = 19 + #(Int, Int, Int, Int, Int, Int) 20 + 21 + pub type PinCodeFormData = 22 + PinCode 23 + 24 + pub type Language { 25 + NlNl 26 + EnGb 27 + }
+27 -230
sweetnhouse_dashboard/src/sweetnhouse_dashboard.gleam
··· 1 1 // IMPORTS --------------------------------------------------------------------- 2 2 3 - import formal/form.{type Form} 4 3 import gleam/bool 5 - import gleam/dict.{type Dict} 6 - import gleam/dynamic/decode.{type Decoder} 7 - import gleam/int 4 + import gleam/dict 8 5 import gleam/list 9 6 import gleam/option.{type Option, None} 10 - import gleam/order 11 7 import gleam/result 12 8 import gleam/string 13 9 import gleam/uri ··· 18 14 import lustre/element/html 19 15 import lustre/element/svg 20 16 import lustre/event 17 + import msg.{type Language, type Msg, type PinCode} 21 18 import rsvp 19 + import translations.{type Translations} 20 + import views/lockscreen 22 21 23 22 // MAIN ------------------------------------------------------------------------ 24 23 ··· 29 28 Nil 30 29 } 31 30 32 - // MODEL ----------------------------------------------------------------------- 31 + // MODEL ----------------------------------------------------------------------- 33 32 34 33 type Model { 35 34 Model(global: GlobalModel, domain: DomainModel) ··· 52 51 /// E.g. models for specific features of the dashboard 53 52 type DomainModel { 54 53 /// Model for the lock screen 55 - LockScreenModel(LockScreenModel) 56 - } 57 - 58 - type LockScreenModel { 59 - LockscreenForm(Form(PinCodeFormData)) 60 - LockscreenProcessing 61 - } 62 - 63 - /// A pin code consists of 6 digits 64 - /// This can be a household pin or a admin pin. 65 - type PinCode = 66 - #(Int, Int, Int, Int, Int, Int) 67 - 68 - type PinCodeFormData = 69 - PinCode 70 - 71 - type Language { 72 - NlNl 73 - EnGb 74 - } 75 - 76 - /// Stores translations 77 - type Translations { 78 - Translations(nl_nl: Dict(String, String), en_gb: Dict(String, String)) 79 - } 80 - 81 - fn translations_decoder() -> Decoder(Translations) { 82 - use nl_nl <- decode.field("nl-nl", decode.dict(decode.string, decode.string)) 83 - use en_gb <- decode.field("en-gb", decode.dict(decode.string, decode.string)) 84 - decode.success(Translations(nl_nl:, en_gb:)) 54 + LockScreenModel(lockscreen.Model) 85 55 } 86 56 87 57 // /// More flexible way of storing translations, would allow any language to be listed. ··· 98 68 let model = 99 69 Model( 100 70 global: GlobalModel( 101 - translations: Translations( 71 + translations: translations.Translations( 102 72 nl_nl: dict.from_list([]), 103 73 en_gb: dict.from_list([]), 104 74 ), 105 75 lang: { 106 76 case js_get_language() |> string.lowercase() { 107 - "nl-nl" | "nl" -> NlNl 108 - _ -> EnGb 77 + "nl-nl" | "nl" -> msg.NlNl 78 + _ -> msg.EnGb 109 79 } 110 80 }, 111 81 admin_mode: False, 112 82 locked: None, 113 83 enabled_tabs: [], 114 84 ), 115 - domain: LockScreenModel(LockscreenForm(new_unlock_form())), 85 + domain: LockScreenModel(lockscreen.new()), 116 86 ) 117 - let effect = fetch_translations(on_response: ApiReturnedTranslations) 87 + // let effect = fetch_translations(on_response: ApiReturnedTranslations) 88 + let effect = translations.fetch(on_response: msg.ApiReturnedTranslations) 118 89 119 90 #(model, effect) 120 91 } 121 92 122 - fn fetch_translations( 123 - on_response handle_response: fn(Result(Translations, rsvp.Error)) -> msg, 124 - ) -> Effect(msg) { 125 - let assert Ok(uri_) = rsvp.parse_relative_uri("/static/translations.json") 126 - let url = 127 - uri_ 128 - |> uri.to_string() 129 - let handler = rsvp.expect_json(translations_decoder(), handle_response) 130 - rsvp.get(url, handler) 131 - } 132 - 133 93 // UPDATE ---------------------------------------------------------------------- 134 94 135 - type Msg { 136 - ApiReturnedTranslations(Result(Translations, rsvp.Error)) 137 - LockScreenEnteredPin(Result(PinCodeFormData, Form(PinCodeFormData))) 138 - SetLanguage(Language) 139 - LockNow 140 - } 141 - 142 95 fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 143 96 case msg { 144 - LockNow -> #( 97 + msg.LockNow -> #( 145 98 Model( 146 99 // ..model, 147 100 global: GlobalModel(..model.global, locked: None), 148 - domain: LockScreenModel(LockscreenForm(new_unlock_form())), 101 + domain: LockScreenModel(lockscreen.new()), 149 102 ), 150 103 effect.none(), 151 104 ) 152 - LockScreenEnteredPin(pin_result) -> { 105 + msg.LockScreenEnteredPin(pin_result) -> { 153 106 case pin_result { 154 107 Error(form_with_errors) -> { 155 108 let new_model = 156 109 Model( 157 110 ..model, 158 - domain: LockScreenModel(LockscreenForm(form_with_errors)), 111 + domain: LockScreenModel(lockscreen.Form(form_with_errors)), 159 112 ) 160 113 #(new_model, effect.none()) 161 114 } 162 115 // TODO: Handle successful unlock 163 116 Ok(form) -> { 164 117 let new_model = 165 - Model(..model, domain: LockScreenModel(LockscreenProcessing)) 118 + Model(..model, domain: LockScreenModel(lockscreen.Processing)) 166 119 #(new_model, effect.none()) 167 120 } 168 121 } 169 122 } 170 - SetLanguage(new_lang) -> { 123 + msg.UserChangedLanguage(new_lang) -> { 171 124 let new_model = 172 125 Model(..model, global: GlobalModel(..model.global, lang: new_lang)) 173 126 #(new_model, effect.none()) 174 127 } 175 - ApiReturnedTranslations(result) -> 128 + msg.ApiReturnedTranslations(result) -> 176 129 case result { 177 130 Ok(translations) -> { 178 131 let new_model = ··· 185 138 186 139 Error(_) -> { 187 140 // In case of an error, try fetching the translations again 188 - #(model, fetch_translations(on_response: ApiReturnedTranslations)) 141 + #(model, translations.fetch(on_response: msg.ApiReturnedTranslations)) 189 142 } 190 143 } 191 144 } 192 145 } 193 146 194 - fn new_unlock_form() -> Form(PinCode) { 195 - form.new({ 196 - let convert_pincode = fn(pincode) { 197 - let list_of_maybe = 198 - string.to_graphemes(pincode) 199 - |> list.map(fn(g: String) { int.parse(g) }) 200 - |> result.all 201 - // Now we have a Result(List(Int), String), we need to ensure that there are exactly 6 digits 202 - // and fit them into a PinCode tuple. 203 - case list_of_maybe { 204 - Error(Nil) -> #(-10, 0, 0, 0, 0, 0) 205 - Ok(nums) -> { 206 - case nums { 207 - [a, b, c, d, e, f] -> #(a, b, c, d, e, f) 208 - _ -> #(-25, 0, 0, 0, 0, 0) 209 - } 210 - } 211 - } 212 - } 213 - 214 - let checker = fn(pincode: PinCode) { 215 - // We use special sentinel values to indicate specific errors, positioned 216 - // in the first element of the tuple. 217 - let #(a, _, _, _, _, _) = pincode 218 - case a |> int.compare(0) { 219 - order.Lt if a == -10 -> Error("invalid_pin_message_digitsonly") 220 - order.Lt if a == -25 -> Error("invalid_pin_message_length") 221 - _ -> Ok(pincode) 222 - } 223 - } 224 - use pincode <- form.field( 225 - "pincode", 226 - form.parse_string 227 - |> form.map(convert_pincode) 228 - |> form.check(checker), 229 - ) 230 - 231 - form.success(pincode) 232 - }) 233 - } 234 - 235 147 // VIEW ------------------------------------------------------------------------ 236 148 237 149 fn view(model: Model) -> Element(Msg) { ··· 245 157 ), 246 158 ], 247 159 case model.domain { 248 - LockScreenModel(inner) -> [ 249 - view_lockscreen(inner, lang), 250 - ] 160 + LockScreenModel(inner) -> [lockscreen.view(inner, lang)] 251 161 }, 252 162 ), 253 163 ]) 254 164 } 255 165 256 - fn view_lockscreen( 257 - inner: LockScreenModel, 258 - lang: fn(String) -> Element(Msg), 259 - ) -> Element(Msg) { 260 - case inner { 261 - LockscreenForm(form) -> { 262 - let handle_submit = fn(values) { 263 - form |> form.add_values(values) |> form.run |> LockScreenEnteredPin 264 - } 265 - html.form( 266 - [ 267 - attribute.class( 268 - "p-8 w-[65VW] border rounded-2xl shadow-lg space-y-4 bg-primary border-base-300 text-primary-content", 269 - ), 270 - event.on_submit(handle_submit), 271 - ], 272 - [ 273 - html.h1( 274 - [ 275 - attribute.class( 276 - "text-lg md:text-2xl font-medium text-primary-content", 277 - ), 278 - ], 279 - [ 280 - lang("lockscreen_title"), 281 - ], 282 - ), 283 - view_input( 284 - form, 285 - is: "password", 286 - name: "pincode", 287 - label: "enter_pin_placeholder", 288 - lang: lang, 289 - ), 290 - html.div([attribute.class("flex justify-end")], [ 291 - html.button( 292 - [ 293 - attribute.class("text-white text-sm font-bold"), 294 - attribute.class( 295 - "px-4 py-2 bg-info text-info-content rounded-lg", 296 - ), 297 - attribute.class( 298 - "hover:bg-secondary hover:text-secondary-content", 299 - ), 300 - attribute.class( 301 - "focus:outline-2 focus:outline-offset-2 focus:bg-secondary focus:text-secondary-content", 302 - ), 303 - ], 304 - [lang("lockscreen_unlock_button")], 305 - ), 306 - ]), 307 - ], 308 - ) 309 - } 310 - LockscreenProcessing -> { 311 - html.div( 312 - [ 313 - attribute.class( 314 - "p-8 w-[65VW] border rounded-2xl shadow-lg space-y-4 bg-primary border-base-300 text-primary-content", 315 - ), 316 - ], 317 - [ 318 - html.h1([attribute.class("text-lg md:text-2xl font-medium")], [ 319 - lang("lockscreen_processing"), 320 - html.span( 321 - [ 322 - attribute.class( 323 - "loading loading-spinner loading-md float-right", 324 - ), 325 - ], 326 - [], 327 - ), 328 - ]), 329 - ], 330 - ) 331 - } 332 - } 333 - } 334 - 335 - fn view_input( 336 - form: Form(PinCode), 337 - is type_: String, 338 - name name: String, 339 - label label: String, 340 - lang lang: fn(String) -> Element(Msg), 341 - ) -> Element(Msg) { 342 - let errors = form.field_error_messages(form, name) 343 - 344 - html.div([], [ 345 - html.label( 346 - [attribute.for(name), attribute.class("text-xs font-bold text-slate-600")], 347 - [lang(label)], 348 - ), 349 - html.input([ 350 - attribute.type_(type_), 351 - attribute.class( 352 - "block mt-1 w-full px-3 py-1 border rounded-lg focus:shadow", 353 - ), 354 - case errors { 355 - [] -> attribute.class("focus:outline focus:outline-purple-600") 356 - _ -> attribute.class("outline outline-red-500") 357 - }, 358 - attribute.id(name), 359 - attribute.name(name), 360 - ]), 361 - ..list.map(errors, fn(error_message) { 362 - html.p([attribute.class("mt-0.5 text-xs text-red-500")], [ 363 - lang(error_message), 364 - ]) 365 - }) 366 - ]) 367 - } 368 - 369 166 fn view_navbar(model: Model, lang: fn(String) -> Element(Msg)) { 370 167 html.nav( 371 168 [attribute.class("navbar bg-secondary text-secondary-content shadow-sm")], ··· 422 219 html.a( 423 220 [ 424 221 attribute.class("btn hidden md:block h-full"), 425 - event.on_click(LockNow), 222 + event.on_click(msg.LockNow), 426 223 attribute.disabled(model.global.locked |> option.is_none), 427 224 ], 428 225 [lang("btn_lock")], ··· 430 227 html.a( 431 228 [ 432 229 attribute.class("btn block md:hidden btn h-full"), 433 - event.on_click(LockNow), 230 + event.on_click(msg.LockNow), 434 231 attribute.disabled(model.global.locked |> option.is_none), 435 232 ], 436 233 [ ··· 479 276 html.details([], [ 480 277 html.summary([], [lang("menu_language")]), 481 278 html.ul([attribute.class("p-2 bg-base-100 w-40 z-1")], [ 482 - html.li([event.on_click(SetLanguage(EnGb))], [ 279 + html.li([event.on_click(msg.UserChangedLanguage(msg.EnGb))], [ 483 280 html.a([], [lang("lang_en_gb")]), 484 281 ]), 485 - html.li([event.on_click(SetLanguage(NlNl))], [ 282 + html.li([event.on_click(msg.UserChangedLanguage(msg.NlNl))], [ 486 283 html.a([], [lang("lang_nl_nl")]), 487 284 ]), 488 285 ]), ··· 512 309 /// Helper function to get translations based on 513 310 fn translations(model: Model, takes: fn(fn(String) -> Element(Msg)) -> a) { 514 311 let lang_dict = case model.global.lang { 515 - EnGb -> model.global.translations.en_gb 516 - NlNl -> model.global.translations.nl_nl 312 + msg.EnGb -> model.global.translations.en_gb 313 + msg.NlNl -> model.global.translations.nl_nl 517 314 } 518 315 519 316 takes(fn(key: String) -> Element(Msg) {
+28
sweetnhouse_dashboard/src/translations.gleam
··· 1 + import gleam/dict.{type Dict} 2 + import gleam/dynamic/decode 3 + import gleam/result 4 + import gleam/uri 5 + import lustre/effect.{type Effect} 6 + import rsvp 7 + 8 + /// Stores translations 9 + pub type Translations { 10 + Translations(nl_nl: Dict(String, String), en_gb: Dict(String, String)) 11 + } 12 + 13 + pub fn decoder() -> decode.Decoder(Translations) { 14 + use nl_nl <- decode.field("nl-nl", decode.dict(decode.string, decode.string)) 15 + use en_gb <- decode.field("en-gb", decode.dict(decode.string, decode.string)) 16 + decode.success(Translations(nl_nl:, en_gb:)) 17 + } 18 + 19 + pub fn fetch( 20 + on_response handle_response: fn(Result(Translations, rsvp.Error)) -> msg, 21 + ) -> Effect(msg) { 22 + let assert Ok(url) = 23 + rsvp.parse_relative_uri("/static/translations.json") 24 + |> result.map(uri.to_string) 25 + 26 + let handler = rsvp.expect_json(decoder(), handle_response) 27 + rsvp.get(url, handler) 28 + }
+171
sweetnhouse_dashboard/src/views/lockscreen.gleam
··· 1 + import formal/form.{type Form} 2 + import gleam/int 3 + import gleam/list 4 + import gleam/order 5 + import gleam/result 6 + import gleam/string 7 + import lustre/attribute 8 + import lustre/element.{type Element} 9 + import lustre/element/html 10 + import lustre/event 11 + import msg.{type Msg, type PinCode, type PinCodeFormData, LockScreenEnteredPin} 12 + 13 + pub type Model { 14 + Form(Form(PinCodeFormData)) 15 + Processing 16 + } 17 + 18 + pub fn view(inner: Model, lang: fn(String) -> Element(Msg)) -> Element(Msg) { 19 + case inner { 20 + Form(form) -> { 21 + let handle_submit = fn(values) { 22 + form |> form.add_values(values) |> form.run |> LockScreenEnteredPin 23 + } 24 + html.form( 25 + [ 26 + attribute.class( 27 + "p-8 w-[65VW] border rounded-2xl shadow-lg space-y-4 bg-primary border-base-300 text-primary-content", 28 + ), 29 + event.on_submit(handle_submit), 30 + ], 31 + [ 32 + html.h1( 33 + [ 34 + attribute.class( 35 + "text-lg md:text-2xl font-medium text-primary-content", 36 + ), 37 + ], 38 + [ 39 + lang("lockscreen_title"), 40 + ], 41 + ), 42 + view_input( 43 + form, 44 + is: "password", 45 + name: "pincode", 46 + label: "enter_pin_placeholder", 47 + lang: lang, 48 + ), 49 + html.div([attribute.class("flex justify-end")], [ 50 + html.button( 51 + [ 52 + attribute.class("text-white text-sm font-bold"), 53 + attribute.class( 54 + "px-4 py-2 bg-info text-info-content rounded-lg", 55 + ), 56 + attribute.class( 57 + "hover:bg-secondary hover:text-secondary-content", 58 + ), 59 + attribute.class( 60 + "focus:outline-2 focus:outline-offset-2 focus:bg-secondary focus:text-secondary-content", 61 + ), 62 + ], 63 + [lang("lockscreen_unlock_button")], 64 + ), 65 + ]), 66 + ], 67 + ) 68 + } 69 + Processing -> { 70 + html.div( 71 + [ 72 + attribute.class( 73 + "p-8 w-[65VW] border rounded-2xl shadow-lg space-y-4 bg-primary border-base-300 text-primary-content", 74 + ), 75 + ], 76 + [ 77 + html.h1([attribute.class("text-lg md:text-2xl font-medium")], [ 78 + lang("lockscreen_processing"), 79 + html.span( 80 + [ 81 + attribute.class( 82 + "loading loading-spinner loading-md float-right", 83 + ), 84 + ], 85 + [], 86 + ), 87 + ]), 88 + ], 89 + ) 90 + } 91 + } 92 + } 93 + 94 + fn view_input( 95 + form: Form(PinCode), 96 + is type_: String, 97 + name name: String, 98 + label label: String, 99 + lang lang: fn(String) -> Element(Msg), 100 + ) -> Element(Msg) { 101 + let errors = form.field_error_messages(form, name) 102 + 103 + html.div([], [ 104 + html.label( 105 + [attribute.for(name), attribute.class("text-xs font-bold text-slate-600")], 106 + [lang(label)], 107 + ), 108 + html.input([ 109 + attribute.type_(type_), 110 + attribute.class( 111 + "block mt-1 w-full px-3 py-1 border rounded-lg focus:shadow", 112 + ), 113 + case errors { 114 + [] -> attribute.class("focus:outline focus:outline-purple-600") 115 + _ -> attribute.class("outline outline-red-500") 116 + }, 117 + attribute.id(name), 118 + attribute.name(name), 119 + ]), 120 + ..list.map(errors, fn(error_message) { 121 + html.p([attribute.class("mt-0.5 text-xs text-red-500")], [ 122 + lang(error_message), 123 + ]) 124 + }) 125 + ]) 126 + } 127 + 128 + pub fn new() { 129 + Form(new_unlock_form()) 130 + } 131 + 132 + fn new_unlock_form() -> Form(PinCode) { 133 + form.new({ 134 + let convert_pincode = fn(pincode) { 135 + let list_of_maybe = 136 + string.to_graphemes(pincode) 137 + |> list.map(fn(g: String) { int.parse(g) }) 138 + |> result.all 139 + // Now we have a Result(List(Int), String), we need to ensure that there are exactly 6 digits 140 + // and fit them into a PinCode tuple. 141 + case list_of_maybe { 142 + Error(Nil) -> #(-10, 0, 0, 0, 0, 0) 143 + Ok(nums) -> { 144 + case nums { 145 + [a, b, c, d, e, f] -> #(a, b, c, d, e, f) 146 + _ -> #(-25, 0, 0, 0, 0, 0) 147 + } 148 + } 149 + } 150 + } 151 + 152 + let checker = fn(pincode: PinCode) { 153 + // We use special sentinel values to indicate specific errors, positioned 154 + // in the first element of the tuple. 155 + let #(a, _, _, _, _, _) = pincode 156 + case a |> int.compare(0) { 157 + order.Lt if a == -10 -> Error("invalid_pin_message_digitsonly") 158 + order.Lt if a == -25 -> Error("invalid_pin_message_length") 159 + _ -> Ok(pincode) 160 + } 161 + } 162 + use pincode <- form.field( 163 + "pincode", 164 + form.parse_string 165 + |> form.map(convert_pincode) 166 + |> form.check(checker), 167 + ) 168 + 169 + form.success(pincode) 170 + }) 171 + }
+1 -1
sweetnhouse_server/priv/static/translations.json
··· 2 2 "en-gb": { 3 3 "welcome_message": "Welcome to SweetnHouse!", 4 4 "lockscreen_title": "Lockscreen", 5 - "lockscreen_unlock_button": "Ontgrendelen", 5 + "lockscreen_unlock_button": "Unlock", 6 6 "enter_pin_placeholder": "Enter your PIN:", 7 7 "invalid_pin_message_digitsonly": "PIN code must consist of digits only.", 8 8 "invalid_pin_message_length": "PIN code must be 6 digits long.",