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);
}
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 {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath),
@@ -71,6 +103,18 @@ class ProfileApi {
)
: 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(
displayName: (json['display_name'] as String?) ?? '',
bio: (json['bio'] as String?) ?? '',
@@ -81,10 +125,7 @@ class ProfileApi {
? (settingsRaw['privacy'] as Map<String, dynamic>? ??
const <String, dynamic>{})
: const <String, dynamic>{},
notification: settingsRaw is Map<String, dynamic>
? (settingsRaw['notification'] as Map<String, dynamic>? ??
const <String, dynamic>{})
: const <String, dynamic>{},
notification: notification,
);
}
}
@@ -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 {
const ProfileSettingsV1({
this.version = 1,
@@ -46,7 +66,7 @@ class ProfileSettingsV1 {
this.avatarUrl,
this.preferences = const PreferenceSettings(),
this.privacy = const <String, Object?>{},
this.notification = const <String, Object?>{},
this.notification = const NotificationSettings(),
});
final int version;
@@ -56,7 +76,7 @@ class ProfileSettingsV1 {
final String? avatarUrl;
final PreferenceSettings preferences;
final Map<String, Object?> privacy;
final Map<String, Object?> notification;
final NotificationSettings notification;
ProfileSettingsV1 copyWith({
int? version,
@@ -66,7 +86,7 @@ class ProfileSettingsV1 {
String? avatarUrl,
PreferenceSettings? preferences,
Map<String, Object?>? privacy,
Map<String, Object?>? notification,
NotificationSettings? notification,
}) {
return ProfileSettingsV1(
version: version ?? this.version,
@@ -3,18 +3,18 @@ import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../data/models/profile_settings.dart';
import 'language_settings_screen.dart';
import '../widgets/settings_section_widgets.dart';
import 'language_settings_screen.dart';
class GeneralSettingsScreen extends StatefulWidget {
const GeneralSettingsScreen({
super.key,
required this.settings,
required this.onInterfaceLanguageChanged,
required this.onSettingsChanged,
});
final ProfileSettingsV1 settings;
final Future<void> Function(String languageTag) onInterfaceLanguageChanged;
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
@override
State<GeneralSettingsScreen> createState() => _GeneralSettingsScreenState();
@@ -62,15 +62,87 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
children: [
SettingsMenuTile(
icon: Icons.language_rounded,
title: l10n.language,
title: l10n.settingsInterfaceLanguage,
subtitle: displayLanguageLabel(
l10n,
_settings.preferences.interfaceLanguage,
),
tint: colors.primary,
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,
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>(
MaterialPageRoute<String>(
builder: (_) => LanguageSettingsScreen(
selectedLanguageTag: _settings.preferences.interfaceLanguage,
builder: (_) =>
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) {
return;
}
await widget.onInterfaceLanguageChanged(result);
if (!mounted) {
if (result == null || result == _settings.preferences.timezone) {
return;
}
setState(() {
_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(
icon: Icons.notifications_outlined,
title: l10n.settingsNotificationSystem,
subtitle: l10n.settingsPlaceholderState(
settings.notification.length,
),
subtitle: l10n.settingsPlaceholderState(1),
tint: colors.secondary,
background: colors.surfaceContainerHighest,
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 {
const ProfileHeaderCard({
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,
ProfileResponse,
UpdateProfileRequest,
UpdateSettingsRequest,
)
from v1.users.service import UserService
@@ -30,6 +31,14 @@ async def update_my_profile(
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)
async def create_avatar_upload_url(
payload: AvatarUploadUrlRequest,
+9 -2
View File
@@ -1,10 +1,11 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from schemas.shared.user import ProfileSettingsV1
class ProfileResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -14,7 +15,7 @@ class ProfileResponse(BaseModel):
bio: str | None = None
avatar_path: str | None = None
avatar_url: str | None = None
settings: dict[str, Any] = Field(default_factory=dict)
settings: ProfileSettingsV1
updated_at: datetime
@@ -26,6 +27,12 @@ class UpdateProfileRequest(BaseModel):
avatar_path: str | None = None
class UpdateSettingsRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
settings: ProfileSettingsV1
class AvatarUploadUrlRequest(BaseModel):
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.http.errors import ApiProblemError, problem_payload
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.schemas import (
AvatarUploadUrlRequest,
ProfileResponse,
UpdateProfileRequest,
UpdateSettingsRequest,
)
@@ -40,7 +41,9 @@ class UserService:
email=self.current_user.email,
avatar_url=profile.avatar_url 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:
@@ -62,7 +65,7 @@ class UserService:
bio=profile.bio,
avatar_path=profile.avatar_url,
avatar_url=avatar_url,
settings=profile.settings,
settings=parse_profile_settings(profile.settings),
updated_at=profile.updated_at,
)
@@ -122,6 +125,24 @@ class UserService:
await self.repository.save()
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(
self, payload: AvatarUploadUrlRequest
) -> dict[str, str | int]: