feat(settings): 添加通知偏好设置和后端 API 集成

This commit is contained in:
qzl
2026-04-07 18:43:42 +08:00
parent b18a205bf3
commit b22673ce49
9 changed files with 612 additions and 29 deletions
@@ -36,6 +36,38 @@ class ProfileApi {
return _toSettings(data); return _toSettings(data);
} }
Future<ProfileSettingsV1> updateSettings(ProfileSettingsV1 settings) async {
final payload = <String, dynamic>{
'settings': {
'version': settings.version,
'preferences': {
'interface_language': settings.preferences.interfaceLanguage,
'ai_language': settings.preferences.aiLanguage,
'timezone': settings.preferences.timezone,
'country': settings.preferences.country,
},
'privacy': settings.privacy,
'notification': {
'allow_notifications': settings.notification.allowNotifications,
'allow_vibration': settings.notification.allowVibration,
},
},
};
final json = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/users/me/settings',
data: payload,
);
final data = json.data;
if (data is! Map<String, dynamic>) {
throw ApiProblem(
status: 502,
title: 'Invalid settings payload',
detail: 'Expected settings response object',
);
}
return _toSettings(data);
}
Future<ProfileSettingsV1> uploadAvatar(String filePath) async { Future<ProfileSettingsV1> uploadAvatar(String filePath) async {
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath), 'file': await MultipartFile.fromFile(filePath),
@@ -71,6 +103,18 @@ class ProfileApi {
) )
: const PreferenceSettings(); : const PreferenceSettings();
final notificationRaw = settingsRaw is Map<String, dynamic>
? settingsRaw['notification']
: null;
final notification = notificationRaw is Map<String, dynamic>
? NotificationSettings(
allowNotifications:
(notificationRaw['allow_notifications'] as bool? ?? true),
allowVibration:
(notificationRaw['allow_vibration'] as bool? ?? true),
)
: const NotificationSettings();
return ProfileSettingsV1( return ProfileSettingsV1(
displayName: (json['display_name'] as String?) ?? '', displayName: (json['display_name'] as String?) ?? '',
bio: (json['bio'] as String?) ?? '', bio: (json['bio'] as String?) ?? '',
@@ -81,10 +125,7 @@ class ProfileApi {
? (settingsRaw['privacy'] as Map<String, dynamic>? ?? ? (settingsRaw['privacy'] as Map<String, dynamic>? ??
const <String, dynamic>{}) const <String, dynamic>{})
: const <String, dynamic>{}, : const <String, dynamic>{},
notification: settingsRaw is Map<String, dynamic> notification: notification,
? (settingsRaw['notification'] as Map<String, dynamic>? ??
const <String, dynamic>{})
: const <String, dynamic>{},
); );
} }
} }
@@ -37,6 +37,26 @@ class PreferenceSettings {
} }
} }
class NotificationSettings {
const NotificationSettings({
this.allowNotifications = true,
this.allowVibration = true,
});
final bool allowNotifications;
final bool allowVibration;
NotificationSettings copyWith({
bool? allowNotifications,
bool? allowVibration,
}) {
return NotificationSettings(
allowNotifications: allowNotifications ?? this.allowNotifications,
allowVibration: allowVibration ?? this.allowVibration,
);
}
}
class ProfileSettingsV1 { class ProfileSettingsV1 {
const ProfileSettingsV1({ const ProfileSettingsV1({
this.version = 1, this.version = 1,
@@ -46,7 +66,7 @@ class ProfileSettingsV1 {
this.avatarUrl, this.avatarUrl,
this.preferences = const PreferenceSettings(), this.preferences = const PreferenceSettings(),
this.privacy = const <String, Object?>{}, this.privacy = const <String, Object?>{},
this.notification = const <String, Object?>{}, this.notification = const NotificationSettings(),
}); });
final int version; final int version;
@@ -56,7 +76,7 @@ class ProfileSettingsV1 {
final String? avatarUrl; final String? avatarUrl;
final PreferenceSettings preferences; final PreferenceSettings preferences;
final Map<String, Object?> privacy; final Map<String, Object?> privacy;
final Map<String, Object?> notification; final NotificationSettings notification;
ProfileSettingsV1 copyWith({ ProfileSettingsV1 copyWith({
int? version, int? version,
@@ -66,7 +86,7 @@ class ProfileSettingsV1 {
String? avatarUrl, String? avatarUrl,
PreferenceSettings? preferences, PreferenceSettings? preferences,
Map<String, Object?>? privacy, Map<String, Object?>? privacy,
Map<String, Object?>? notification, NotificationSettings? notification,
}) { }) {
return ProfileSettingsV1( return ProfileSettingsV1(
version: version ?? this.version, version: version ?? this.version,
@@ -3,18 +3,18 @@ import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart'; import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/theme/design_tokens.dart';
import '../../data/models/profile_settings.dart'; import '../../data/models/profile_settings.dart';
import 'language_settings_screen.dart';
import '../widgets/settings_section_widgets.dart'; import '../widgets/settings_section_widgets.dart';
import 'language_settings_screen.dart';
class GeneralSettingsScreen extends StatefulWidget { class GeneralSettingsScreen extends StatefulWidget {
const GeneralSettingsScreen({ const GeneralSettingsScreen({
super.key, super.key,
required this.settings, required this.settings,
required this.onInterfaceLanguageChanged, required this.onSettingsChanged,
}); });
final ProfileSettingsV1 settings; final ProfileSettingsV1 settings;
final Future<void> Function(String languageTag) onInterfaceLanguageChanged; final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
@override @override
State<GeneralSettingsScreen> createState() => _GeneralSettingsScreenState(); State<GeneralSettingsScreen> createState() => _GeneralSettingsScreenState();
@@ -62,15 +62,87 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
children: [ children: [
SettingsMenuTile( SettingsMenuTile(
icon: Icons.language_rounded, icon: Icons.language_rounded,
title: l10n.language, title: l10n.settingsInterfaceLanguage,
subtitle: displayLanguageLabel( subtitle: displayLanguageLabel(
l10n, l10n,
_settings.preferences.interfaceLanguage, _settings.preferences.interfaceLanguage,
), ),
tint: colors.primary, tint: colors.primary,
background: colors.surfaceContainerHighest, background: colors.surfaceContainerHighest,
onTap: () => _selectLanguage(
_settings.preferences.interfaceLanguage,
(lang) => setState(() {
_settings = _settings.copyWith(
preferences: _settings.preferences.copyWith(
interfaceLanguage: lang,
),
);
}),
),
),
SettingsMenuTile(
icon: Icons.smart_toy_rounded,
title: l10n.settingsAiLanguage,
subtitle: displayLanguageLabel(
l10n,
_settings.preferences.aiLanguage,
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: true,
onTap: () => _selectLanguage(
_settings.preferences.aiLanguage,
(lang) => setState(() {
_settings = _settings.copyWith(
preferences: _settings.preferences.copyWith(
aiLanguage: lang,
),
);
}),
),
),
SettingsMenuTile(
icon: Icons.access_time_rounded,
title: l10n.settingsTimezone,
subtitle: _settings.preferences.timezone,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: true,
onTap: () => _selectTimezone(context),
),
SettingsMenuTile(
icon: Icons.public_rounded,
title: l10n.settingsCountry,
subtitle: _settings.preferences.country,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false, showDivider: false,
onTap: _openLanguageSettings, onTap: () => _selectCountry(context),
),
],
),
const SizedBox(height: AppSpacing.lg),
SectionLabel(text: l10n.settingsSectionNotification),
SettingsGroupCard(
children: [
SettingsSwitchTile(
icon: Icons.notifications_rounded,
title: l10n.settingsNotificationAllow,
value: _settings.notification.allowNotifications,
tint: colors.primary,
background: colors.surfaceContainerHighest,
onChanged: (value) =>
_updateNotification(allowNotifications: value),
),
SettingsSwitchTile(
icon: Icons.vibration_rounded,
title: l10n.settingsNotificationVibration,
value: _settings.notification.allowVibration,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onChanged: (value) =>
_updateNotification(allowVibration: value),
), ),
], ],
), ),
@@ -80,25 +152,181 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
); );
} }
Future<void> _openLanguageSettings() async { Future<void> _selectLanguage(
String currentLanguage,
void Function(String) onChanged,
) async {
final result = await Navigator.of(context).push<String>( final result = await Navigator.of(context).push<String>(
MaterialPageRoute<String>( MaterialPageRoute<String>(
builder: (_) => LanguageSettingsScreen( builder: (_) =>
selectedLanguageTag: _settings.preferences.interfaceLanguage, LanguageSettingsScreen(selectedLanguageTag: currentLanguage),
),
);
if (result == null || result == currentLanguage) {
return;
}
onChanged(result);
await widget.onSettingsChanged(_settings);
}
Future<void> _selectTimezone(BuildContext context) async {
final result = await Navigator.of(context).push<String>(
MaterialPageRoute<String>(
builder: (_) => TimezoneSettingsScreen(
selectedTimezone: _settings.preferences.timezone,
), ),
), ),
); );
if (result == null || result == _settings.preferences.interfaceLanguage) { if (result == null || result == _settings.preferences.timezone) {
return;
}
await widget.onInterfaceLanguageChanged(result);
if (!mounted) {
return; return;
} }
setState(() { setState(() {
_settings = _settings.copyWith( _settings = _settings.copyWith(
preferences: _settings.preferences.copyWith(interfaceLanguage: result), preferences: _settings.preferences.copyWith(timezone: result),
); );
}); });
await widget.onSettingsChanged(_settings);
}
Future<void> _selectCountry(BuildContext context) async {
final result = await Navigator.of(context).push<String>(
MaterialPageRoute<String>(
builder: (_) => CountrySettingsScreen(
selectedCountry: _settings.preferences.country,
),
),
);
if (result == null || result == _settings.preferences.country) {
return;
}
setState(() {
_settings = _settings.copyWith(
preferences: _settings.preferences.copyWith(country: result),
);
});
await widget.onSettingsChanged(_settings);
}
void _updateNotification({bool? allowNotifications, bool? allowVibration}) {
final newNotification = _settings.notification.copyWith(
allowNotifications: allowNotifications,
allowVibration: allowVibration,
);
final newSettings = _settings.copyWith(notification: newNotification);
setState(() {
_settings = newSettings;
});
widget.onSettingsChanged(newSettings);
}
}
class TimezoneSettingsScreen extends StatelessWidget {
const TimezoneSettingsScreen({super.key, required this.selectedTimezone});
final String selectedTimezone;
static const _timezones = [
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Tokyo',
'America/New_York',
'America/Los_Angeles',
'Europe/London',
'Europe/Paris',
];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.settingsTimezone),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
children: [
SettingsGroupCard(
children: [
for (int i = 0; i < _timezones.length; i++)
SettingsMenuTile(
icon: Icons.access_time_rounded,
title: _timezones[i],
subtitle: '',
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: i != _timezones.length - 1,
showChevron: false,
trailing: selectedTimezone == _timezones[i]
? Icon(Icons.check_rounded, color: colors.primary)
: null,
onTap: () => Navigator.of(context).pop(_timezones[i]),
),
],
),
],
),
);
}
}
class CountrySettingsScreen extends StatelessWidget {
const CountrySettingsScreen({super.key, required this.selectedCountry});
final String selectedCountry;
static const _countries = ['CN', 'HK', 'TW', 'US', 'JP', 'GB', 'FR'];
static const _labels = [
'China',
'Hong Kong',
'Taiwan',
'USA',
'Japan',
'UK',
'France',
];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.settingsCountry),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
children: [
SettingsGroupCard(
children: [
for (int i = 0; i < _countries.length; i++)
SettingsMenuTile(
icon: Icons.public_rounded,
title: _labels[i],
subtitle: _countries[i],
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: i != _countries.length - 1,
showChevron: false,
trailing: selectedCountry == _countries[i]
? Icon(Icons.check_rounded, color: colors.primary)
: null,
onTap: () => Navigator.of(context).pop(_countries[i]),
),
],
),
],
),
);
} }
} }
@@ -81,9 +81,7 @@ class PrivacyNotificationSettingsScreen extends StatelessWidget {
SettingsMenuTile( SettingsMenuTile(
icon: Icons.notifications_outlined, icon: Icons.notifications_outlined,
title: l10n.settingsNotificationSystem, title: l10n.settingsNotificationSystem,
subtitle: l10n.settingsPlaceholderState( subtitle: l10n.settingsPlaceholderState(1),
settings.notification.length,
),
tint: colors.secondary, tint: colors.secondary,
background: colors.surfaceContainerHighest, background: colors.surfaceContainerHighest,
onTap: () => _openPlaceholder( onTap: () => _openPlaceholder(
@@ -119,6 +119,64 @@ class SettingsMenuTile extends StatelessWidget {
} }
} }
class SettingsSwitchTile extends StatelessWidget {
const SettingsSwitchTile({
super.key,
required this.icon,
required this.title,
required this.value,
required this.onChanged,
required this.tint,
required this.background,
this.showDivider = true,
});
final IconData icon;
final String title;
final bool value;
final ValueChanged<bool> onChanged;
final Color tint;
final Color background;
final bool showDivider;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.sm,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(icon, color: tint),
),
title: Text(title),
trailing: Switch(
value: value,
onChanged: onChanged,
activeColor: colors.primary,
),
),
if (showDivider)
Divider(
height: 1,
indent: 72,
endIndent: AppSpacing.lg,
color: colors.outline,
),
],
);
}
}
class ProfileHeaderCard extends StatelessWidget { class ProfileHeaderCard extends StatelessWidget {
const ProfileHeaderCard({ const ProfileHeaderCard({
super.key, super.key,
@@ -0,0 +1,201 @@
"""update notification settings fields
Revision ID: 20260407_0001
Revises: 20260403_0004
Create Date: 2026-04-07 00:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260407_0001"
down_revision: Union[str, Sequence[str], None] = "202604030004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
create or replace function public.initialize_profile_and_points_on_signup()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_username text;
v_ledger_id uuid;
v_event_id text;
begin
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid;
v_event_id := 'register:' || new.id::text;
insert into public.profiles (id, username, avatar_url, bio, settings)
values (
new.id,
v_username,
null,
null,
jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object(
'interface_language', 'zh-CN',
'ai_language', 'zh-CN',
'timezone', 'Asia/Shanghai',
'country', 'CN'
),
'privacy', jsonb_build_object('profile_visibility', 'public'),
'notification', jsonb_build_object(
'allow_notifications', true,
'allow_vibration', true
)
)
)
on conflict (id) do nothing;
insert into public.user_points (
user_id,
balance,
frozen_balance,
lifetime_earned,
lifetime_spent,
version
)
values (new.id, 100, 0, 100, 0, 0)
on conflict (user_id) do nothing;
insert into public.points_ledger (
id,
user_id,
direction,
amount,
balance_after,
change_type,
biz_type,
biz_id,
event_id,
operator_id,
metadata
)
values (
v_ledger_id,
new.id,
1,
100,
100,
'register',
null,
null,
v_event_id,
null,
jsonb_build_object(
'schema_version', 1,
'reason_code', 'REGISTER_WELCOME',
'operator_type', 'system',
'run_id', v_event_id,
'request_id', null,
'ext', jsonb_build_object('source', 'auth_signup')
)
)
on conflict (user_id, event_id) do nothing;
return new;
end;
$$;
"""
)
def downgrade() -> None:
op.execute(
"""
create or replace function public.initialize_profile_and_points_on_signup()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_username text;
v_ledger_id uuid;
v_event_id text;
begin
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid;
v_event_id := 'register:' || new.id::text;
insert into public.profiles (id, username, avatar_url, bio, settings)
values (
new.id,
v_username,
null,
null,
jsonb_build_object(
'version', 1,
'preferences', jsonb_build_object(
'interface_language', 'zh-CN',
'ai_language', 'zh-CN',
'timezone', 'Asia/Shanghai',
'country', 'CN'
),
'privacy', jsonb_build_object('profile_visibility', 'public'),
'notification', jsonb_build_object('push_enabled', true)
)
)
on conflict (id) do nothing;
insert into public.user_points (
user_id,
balance,
frozen_balance,
lifetime_earned,
lifetime_spent,
version
)
values (new.id, 100, 0, 100, 0, 0)
on conflict (user_id) do nothing;
insert into public.points_ledger (
id,
user_id,
direction,
amount,
balance_after,
change_type,
biz_type,
biz_id,
event_id,
operator_id,
metadata
)
values (
v_ledger_id,
new.id,
1,
100,
100,
'register',
null,
null,
v_event_id,
null,
jsonb_build_object(
'schema_version', 1,
'reason_code', 'REGISTER_WELCOME',
'operator_type', 'system',
'run_id', v_event_id,
'request_id', null,
'ext', jsonb_build_object('source', 'auth_signup')
)
)
on conflict (user_id, event_id) do nothing;
return new;
end;
$$;
"""
)
+9
View File
@@ -8,6 +8,7 @@ from v1.users.schemas import (
AvatarUploadUrlResponse, AvatarUploadUrlResponse,
ProfileResponse, ProfileResponse,
UpdateProfileRequest, UpdateProfileRequest,
UpdateSettingsRequest,
) )
from v1.users.service import UserService from v1.users.service import UserService
@@ -30,6 +31,14 @@ async def update_my_profile(
return await service.update_profile(payload) return await service.update_profile(payload)
@router.patch("/me/settings", response_model=ProfileResponse)
async def update_my_settings(
payload: UpdateSettingsRequest,
service: UserService = Depends(get_user_service),
) -> ProfileResponse:
return await service.update_settings(payload)
@router.post("/me/avatar/upload-url", response_model=AvatarUploadUrlResponse) @router.post("/me/avatar/upload-url", response_model=AvatarUploadUrlResponse)
async def create_avatar_upload_url( async def create_avatar_upload_url(
payload: AvatarUploadUrlRequest, payload: AvatarUploadUrlRequest,
+9 -2
View File
@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from schemas.shared.user import ProfileSettingsV1
class ProfileResponse(BaseModel): class ProfileResponse(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -14,7 +15,7 @@ class ProfileResponse(BaseModel):
bio: str | None = None bio: str | None = None
avatar_path: str | None = None avatar_path: str | None = None
avatar_url: str | None = None avatar_url: str | None = None
settings: dict[str, Any] = Field(default_factory=dict) settings: ProfileSettingsV1
updated_at: datetime updated_at: datetime
@@ -26,6 +27,12 @@ class UpdateProfileRequest(BaseModel):
avatar_path: str | None = None avatar_path: str | None = None
class UpdateSettingsRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
settings: ProfileSettingsV1
class AvatarUploadUrlRequest(BaseModel): class AvatarUploadUrlRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
+24 -3
View File
@@ -11,12 +11,13 @@ from core.config.settings import config
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.http.errors import ApiProblemError, problem_payload from core.http.errors import ApiProblemError, problem_payload
from services.base.supabase import SupabaseService from services.base.supabase import SupabaseService
from schemas.shared.user import UserContext from schemas.shared.user import UserContext, parse_profile_settings
from v1.users.repository import SQLAlchemyUserRepository from v1.users.repository import SQLAlchemyUserRepository
from v1.users.schemas import ( from v1.users.schemas import (
AvatarUploadUrlRequest, AvatarUploadUrlRequest,
ProfileResponse, ProfileResponse,
UpdateProfileRequest, UpdateProfileRequest,
UpdateSettingsRequest,
) )
@@ -40,7 +41,9 @@ class UserService:
email=self.current_user.email, email=self.current_user.email,
avatar_url=profile.avatar_url if profile is not None else None, avatar_url=profile.avatar_url if profile is not None else None,
bio=profile.bio if profile is not None else None, bio=profile.bio if profile is not None else None,
settings=profile.settings if profile is not None else None, settings=parse_profile_settings(profile.settings)
if profile is not None
else None,
) )
async def get_profile(self) -> ProfileResponse: async def get_profile(self) -> ProfileResponse:
@@ -62,7 +65,7 @@ class UserService:
bio=profile.bio, bio=profile.bio,
avatar_path=profile.avatar_url, avatar_path=profile.avatar_url,
avatar_url=avatar_url, avatar_url=avatar_url,
settings=profile.settings, settings=parse_profile_settings(profile.settings),
updated_at=profile.updated_at, updated_at=profile.updated_at,
) )
@@ -122,6 +125,24 @@ class UserService:
await self.repository.save() await self.repository.save()
return await self.get_profile() return await self.get_profile()
async def update_settings(self, payload: UpdateSettingsRequest) -> ProfileResponse:
profile = await self.repository.get_profile_by_user_id(
user_id=self.current_user.id
)
if profile is None:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="PROFILE_NOT_FOUND",
detail="Profile not found",
),
)
profile.settings = payload.settings.model_dump(mode="json")
await self.repository.save()
return await self.get_profile()
async def create_avatar_upload_url( async def create_avatar_upload_url(
self, payload: AvatarUploadUrlRequest self, payload: AvatarUploadUrlRequest
) -> dict[str, str | int]: ) -> dict[str, str | int]: