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