feat: implement settings service with CRUD operations and integrate into app state

This commit is contained in:
2026-02-19 03:50:37 +00:00
parent 88e8640386
commit acc0668392
12 changed files with 553 additions and 7 deletions

View File

@@ -20,9 +20,10 @@ async fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error
// Establish database connection
let db = db::service::DbService::new(app_handle).await?;
let services = services::ServiceFactory::create_services(db.get_connection().clone());
// Create app state with all services
let app_state = AppState::new(db).await;
let app_state = AppState::new(db, services).await;
// Manage the state with Tauri
app.manage(app_state);

View File

@@ -1,10 +1,20 @@
use std::sync::Arc;
use sea_orm::DatabaseConnection;
pub mod settings;
/// Service factory for creating service instances
pub struct ServiceFactory;
pub struct ServiceFactoryResult {
pub settings_service: Arc<dyn settings::service::SettingsService>,
}
impl ServiceFactory {
pub fn create_services(db: DatabaseConnection) -> () {
()
pub fn create_services(db: DatabaseConnection) -> ServiceFactoryResult {
ServiceFactoryResult {
settings_service: Arc::new(settings::service::SettingsServiceImpl::new(db)),
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod types;

View File

@@ -0,0 +1,159 @@
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::Utc;
use log::warn;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait};
use struct_iterable::Iterable;
use crate::{
db::{connection::ConnectionSource, entities::settings},
errors::CommandResult,
services::settings::types::settings::{Settings, UpdateSettingsInput},
};
pub type SettingModel = settings::Model;
#[async_trait]
pub trait SettingsService: Send + Sync {
async fn get_settings(&self, tx: Option<&ConnectionSource<'_>>) -> CommandResult<Settings>;
async fn update_setting(
&self,
key: String,
value: String,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()>;
async fn update_settings(
&self,
input: UpdateSettingsInput,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()>;
async fn initialize_default_settings(
&self,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()>;
}
pub struct SettingsServiceImpl {
db: DatabaseConnection,
}
impl SettingsServiceImpl {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
async fn get_setting(&self, key: &str, default: &str) -> String {
crate::db::entities::settings::Entity::find_by_id(key.to_string())
.one(&self.db)
.await
.ok()
.flatten()
.and_then(|s| s.value)
.unwrap_or_else(|| default.to_string())
}
}
#[async_trait]
impl SettingsService for SettingsServiceImpl {
async fn get_settings(&self, tx: Option<&ConnectionSource<'_>>) -> CommandResult<Settings> {
let settings_list = crate::db::entities::settings::Entity::find()
.all(&tx.unwrap_or(&ConnectionSource::Connection(&self.db)))
.await?;
let mut map = HashMap::new();
for setting in settings_list {
map.insert(setting.key, setting.value.unwrap_or_default());
}
Ok(Settings::from(map))
}
async fn update_setting(
&self,
key: String,
value: String,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
let now = Utc::now().naive_utc();
let tx_ = match tx {
Some(tx) => tx,
_ => &ConnectionSource::Connection(&self.db),
};
let existing = crate::db::entities::settings::Entity::find_by_id(key.clone())
.one(&tx_)
.await?;
if let Some(setting) = existing {
let mut active_model: settings::ActiveModel = setting.into();
active_model.value = Set(Some(value));
active_model.updated_at = Set(now);
active_model.update(&tx_).await?;
} else {
let new_setting = settings::ActiveModel {
key: Set(key),
value: Set(Some(value)),
updated_at: Set(now),
};
new_setting.insert(&tx_).await?;
}
Ok(())
}
async fn update_settings(
&self,
input: UpdateSettingsInput,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
let tx_ = match tx {
Some(tx) => tx,
_ => &ConnectionSource::Connection(&self.db),
};
for (key, value) in input.settings {
self.update_setting(key, value, Some(tx_)).await?;
}
Ok(())
}
async fn initialize_default_settings(
&self,
tx: Option<&ConnectionSource<'_>>,
) -> CommandResult<()> {
let default_settings = Settings::default();
let settings_map: HashMap<String, String> = default_settings
.iter()
.filter_map(|(k, v)| -> Option<(String, String)> {
let value_string = if let Some(s) = v.downcast_ref::<String>() {
s.clone()
} else if let Some(i) = v.downcast_ref::<i32>() {
i.to_string()
} else {
warn!("Unsupported setting type for key '{k}'");
return None;
};
Some((k.to_string(), value_string))
})
.collect();
let tx_ = match tx {
Some(tx) => tx,
_ => &ConnectionSource::Connection(&self.db),
};
for (key, value) in settings_map {
let existing = crate::db::entities::settings::Entity::find_by_id(key.clone())
.one(&tx_)
.await?;
if existing.is_none() {
self.update_setting(key.clone(), value.clone(), Some(tx_))
.await?;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,83 @@
use serde::Serialize;
const SUNDAY: &str = "sunday";
const MONDAY: &str = "monday";
const TUESDAY: &str = "tuesday";
const WEDNESDAY: &str = "wednesday";
const THURSDAY: &str = "thursday";
const FRIDAY: &str = "friday";
const SATURDAY: &str = "saturday";
#[derive(Debug, Serialize, Default)]
pub enum DateOfWeek {
#[default]
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
impl std::fmt::Display for DateOfWeek {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DateOfWeek::Sunday => write!(f, "{}", SUNDAY),
DateOfWeek::Monday => write!(f, "{}", MONDAY),
DateOfWeek::Tuesday => write!(f, "{}", TUESDAY),
DateOfWeek::Wednesday => write!(f, "{}", WEDNESDAY),
DateOfWeek::Thursday => write!(f, "{}", THURSDAY),
DateOfWeek::Friday => write!(f, "{}", FRIDAY),
DateOfWeek::Saturday => write!(f, "{}", SATURDAY),
}
}
}
impl From<Option<String>> for DateOfWeek {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
SUNDAY => DateOfWeek::Sunday,
MONDAY => DateOfWeek::Monday,
TUESDAY => DateOfWeek::Tuesday,
WEDNESDAY => DateOfWeek::Wednesday,
THURSDAY => DateOfWeek::Thursday,
FRIDAY => DateOfWeek::Friday,
SATURDAY => DateOfWeek::Saturday,
_ => DateOfWeek::default(),
},
None => DateOfWeek::default(),
}
}
}
impl From<String> for DateOfWeek {
fn from(s: String) -> Self {
DateOfWeek::from(Some(s))
}
}
impl From<Option<i32>> for DateOfWeek {
fn from(i: Option<i32>) -> Self {
match i {
Some(i) => match i {
0 => DateOfWeek::Sunday,
1 => DateOfWeek::Monday,
2 => DateOfWeek::Tuesday,
3 => DateOfWeek::Wednesday,
4 => DateOfWeek::Thursday,
5 => DateOfWeek::Friday,
6 => DateOfWeek::Saturday,
_ => DateOfWeek::default(),
},
None => DateOfWeek::default(),
}
}
}
impl From<i32> for DateOfWeek {
fn from(i: i32) -> Self {
DateOfWeek::from(Some(i))
}
}

View File

@@ -0,0 +1,43 @@
use serde::Serialize;
const SYSTEM: &str = "system";
const LIGHT: &str = "light";
const DARK: &str = "dark";
#[derive(Debug, Serialize, Default)]
pub enum DisplayMode {
#[default]
System,
Light,
Dark,
}
impl std::fmt::Display for DisplayMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DisplayMode::System => write!(f, "{}", SYSTEM),
DisplayMode::Light => write!(f, "{}", LIGHT),
DisplayMode::Dark => write!(f, "{}", DARK),
}
}
}
impl From<Option<String>> for DisplayMode {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
SYSTEM => DisplayMode::System,
LIGHT => DisplayMode::Light,
DARK => DisplayMode::Dark,
_ => DisplayMode::default(),
},
None => DisplayMode::default(),
}
}
}
impl From<String> for DisplayMode {
fn from(s: String) -> Self {
DisplayMode::from(Some(s))
}
}

View File

@@ -0,0 +1,43 @@
use serde::Serialize;
const ENGLISH: &str = "en";
const TRADITIONAL_CHINESE: &str = "zh-TW";
const CANTONESE: &str = "zh-HK";
#[derive(Debug, Serialize, Default)]
pub enum Language {
#[default]
English,
TraditionalChinese,
Cantonese,
}
impl std::fmt::Display for Language {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Language::English => write!(f, "{}", ENGLISH),
Language::TraditionalChinese => write!(f, "{}", TRADITIONAL_CHINESE),
Language::Cantonese => write!(f, "{}", CANTONESE),
}
}
}
impl From<Option<String>> for Language {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
ENGLISH => Language::English,
TRADITIONAL_CHINESE => Language::TraditionalChinese,
CANTONESE => Language::Cantonese,
_ => Language::default(),
},
None => Language::default(),
}
}
}
impl From<String> for Language {
fn from(s: String) -> Self {
Language::from(Some(s))
}
}

View File

@@ -0,0 +1,6 @@
pub mod date_of_week;
pub mod display_mode;
pub mod language;
pub mod settings;
pub mod theme;
pub mod view;

View File

@@ -0,0 +1,107 @@
use serde::{Deserialize, Serialize};
use struct_iterable::Iterable;
use crate::services::settings::types::date_of_week::DateOfWeek;
use super::{display_mode::DisplayMode, language::Language, theme::Theme, view::View};
const LANGUAGE_KEY: &str = "language";
const DEFAULT_CURRENCY_KEY: &str = "default_currency";
const BASE_CURRENCY_KEY: &str = "base_currency";
const TIMEZONE_KEY: &str = "timezone";
const DEFAULT_VIEW_KEY: &str = "default_view";
const DISPLAY_MODE_KEY: &str = "display_mode";
const DECIMAL_PLACES_KEY: &str = "decimal_places";
const DATE_FORMAT_KEY: &str = "date_format";
const TIME_FORMAT_KEY: &str = "time_format";
const THEME_KEY: &str = "theme";
const WEEK_STARTS_ON_KEY: &str = "week_starts_on";
const SCHEDULED_CHECK_INTERVAL_KEY: &str = "scheduled_check_interval";
const DEFAULT_DECIMAL_PLACES: i32 = 2;
const DEFAULT_SCHEDULED_CHECK_INTERVAL: i32 = 1;
#[derive(Debug, Serialize, Iterable)]
pub struct Settings {
pub language: Language,
pub default_currency: String,
pub base_currency: String,
pub timezone: String,
pub default_view: View,
pub display_mode: DisplayMode,
pub decimal_places: i32,
pub date_format: String,
pub time_format: String,
pub theme: Theme,
pub week_starts_on: DateOfWeek,
pub scheduled_check_interval: i32,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsInput {
pub settings: std::collections::HashMap<String, String>,
}
impl Default for Settings {
fn default() -> Self {
Settings {
language: Language::default(),
default_currency: "HKD".to_string(),
base_currency: "HKD".to_string(),
timezone: "auto".to_string(),
default_view: View::default(),
display_mode: DisplayMode::default(),
decimal_places: DEFAULT_DECIMAL_PLACES,
date_format: "YYYY-MM-DD".to_string(),
time_format: "24h".to_string(),
theme: Theme::default(),
week_starts_on: DateOfWeek::default(),
scheduled_check_interval: DEFAULT_SCHEDULED_CHECK_INTERVAL,
}
}
}
impl From<std::collections::HashMap<String, String>> for Settings {
fn from(map: std::collections::HashMap<String, String>) -> Self {
let default_settings = Settings::default();
Settings {
language: map.get(LANGUAGE_KEY).cloned().into(),
default_currency: map
.get(DEFAULT_CURRENCY_KEY)
.cloned()
.unwrap_or(default_settings.default_currency),
base_currency: map
.get(BASE_CURRENCY_KEY)
.cloned()
.unwrap_or(default_settings.base_currency),
timezone: map
.get(TIMEZONE_KEY)
.cloned()
.unwrap_or(default_settings.timezone),
default_view: map.get(DEFAULT_VIEW_KEY).cloned().into(),
display_mode: map.get(DISPLAY_MODE_KEY).cloned().into(),
decimal_places: map
.get(DECIMAL_PLACES_KEY)
.cloned()
.unwrap_or(default_settings.decimal_places.to_string())
.parse()
.unwrap_or(DEFAULT_DECIMAL_PLACES),
date_format: map
.get(DATE_FORMAT_KEY)
.cloned()
.unwrap_or(default_settings.date_format),
time_format: map
.get(TIME_FORMAT_KEY)
.cloned()
.unwrap_or(default_settings.time_format),
theme: map.get(THEME_KEY).cloned().into(),
week_starts_on: map.get(WEEK_STARTS_ON_KEY).cloned().into(),
scheduled_check_interval: map
.get(SCHEDULED_CHECK_INTERVAL_KEY)
.cloned()
.unwrap_or(default_settings.scheduled_check_interval.to_string())
.parse()
.unwrap_or(DEFAULT_SCHEDULED_CHECK_INTERVAL),
}
}
}

View File

@@ -0,0 +1,43 @@
use serde::Serialize;
const SYSTEM: &str = "system";
const LIGHT: &str = "light";
const DARK: &str = "dark";
#[derive(Debug, Serialize, Default)]
pub enum Theme {
#[default]
System,
Light,
Dark,
}
impl std::fmt::Display for Theme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Theme::System => write!(f, "{}", SYSTEM),
Theme::Light => write!(f, "{}", LIGHT),
Theme::Dark => write!(f, "{}", DARK),
}
}
}
impl From<Option<String>> for Theme {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
SYSTEM => Theme::System,
LIGHT => Theme::Light,
DARK => Theme::Dark,
_ => Theme::default(),
},
None => Theme::default(),
}
}
}
impl From<String> for Theme {
fn from(s: String) -> Self {
Theme::from(Some(s))
}
}

View File

@@ -0,0 +1,39 @@
use serde::Serialize;
const COMBINED: &str = "combined";
const SPLIT: &str = "split";
#[derive(Debug, Default, Serialize)]
pub enum View {
#[default]
Combined,
Split,
}
impl std::fmt::Display for View {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
View::Combined => write!(f, "{}", COMBINED),
View::Split => write!(f, "{}", SPLIT),
}
}
}
impl From<Option<String>> for View {
fn from(s: Option<String>) -> Self {
match s {
Some(s) => match s.as_str() {
COMBINED => View::Combined,
SPLIT => View::Split,
_ => View::default(),
},
None => View::default(),
}
}
}
impl From<String> for View {
fn from(s: String) -> Self {
View::from(Some(s))
}
}

View File

@@ -1,18 +1,28 @@
use std::sync::Arc;
use crate::{db::service::DbService, services::ServiceFactory};
use crate::{
db::service::DbService,
services::{ServiceFactoryResult, settings::service::SettingsService},
};
pub struct AppState {
db: DbService,
settings_service: Arc<dyn SettingsService>,
}
impl AppState {
/// Create a new AppState with all services initialized
pub async fn new(db: DbService) -> Self {
let () = ServiceFactory::create_services(db.get_connection().clone());
Self { db }
pub async fn new(db: DbService, services: ServiceFactoryResult) -> Self {
Self {
db,
settings_service: services.settings_service,
}
}
/// Get the database service
pub fn db(&self) -> &DbService {
&self.db
}
/// Get the settings service
pub fn settings_service(&self) -> &Arc<dyn SettingsService> {
&self.settings_service
}
}