feat(settings): 添加通知偏好设置和后端 API 集成
This commit is contained in:
@@ -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]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -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;
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
Reference in New Issue
Block a user