diff --git a/apps/lib/features/settings/data/apis/profile_api.dart b/apps/lib/features/settings/data/apis/profile_api.dart index 5d4df6d..0d2de44 100644 --- a/apps/lib/features/settings/data/apis/profile_api.dart +++ b/apps/lib/features/settings/data/apis/profile_api.dart @@ -36,6 +36,38 @@ class ProfileApi { return _toSettings(data); } + Future updateSettings(ProfileSettingsV1 settings) async { + final payload = { + '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>( + '/api/v1/users/me/settings', + data: payload, + ); + final data = json.data; + if (data is! Map) { + throw ApiProblem( + status: 502, + title: 'Invalid settings payload', + detail: 'Expected settings response object', + ); + } + return _toSettings(data); + } + Future 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 + ? settingsRaw['notification'] + : null; + final notification = notificationRaw is Map + ? 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? ?? const {}) : const {}, - notification: settingsRaw is Map - ? (settingsRaw['notification'] as Map? ?? - const {}) - : const {}, + notification: notification, ); } } diff --git a/apps/lib/features/settings/data/models/profile_settings.dart b/apps/lib/features/settings/data/models/profile_settings.dart index 4ef5038..2e6e264 100644 --- a/apps/lib/features/settings/data/models/profile_settings.dart +++ b/apps/lib/features/settings/data/models/profile_settings.dart @@ -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 {}, - this.notification = const {}, + this.notification = const NotificationSettings(), }); final int version; @@ -56,7 +76,7 @@ class ProfileSettingsV1 { final String? avatarUrl; final PreferenceSettings preferences; final Map privacy; - final Map notification; + final NotificationSettings notification; ProfileSettingsV1 copyWith({ int? version, @@ -66,7 +86,7 @@ class ProfileSettingsV1 { String? avatarUrl, PreferenceSettings? preferences, Map? privacy, - Map? notification, + NotificationSettings? notification, }) { return ProfileSettingsV1( version: version ?? this.version, diff --git a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart index 6c1dd80..b7d4f86 100644 --- a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart @@ -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 Function(String languageTag) onInterfaceLanguageChanged; + final Future Function(ProfileSettingsV1 settings) onSettingsChanged; @override State createState() => _GeneralSettingsScreenState(); @@ -62,15 +62,87 @@ class _GeneralSettingsScreenState extends State { 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 { ); } - Future _openLanguageSettings() async { + Future _selectLanguage( + String currentLanguage, + void Function(String) onChanged, + ) async { final result = await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => LanguageSettingsScreen( - selectedLanguageTag: _settings.preferences.interfaceLanguage, + builder: (_) => + LanguageSettingsScreen(selectedLanguageTag: currentLanguage), + ), + ); + if (result == null || result == currentLanguage) { + return; + } + onChanged(result); + await widget.onSettingsChanged(_settings); + } + + Future _selectTimezone(BuildContext context) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + 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 _selectCountry(BuildContext context) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + 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]), + ), + ], + ), + ], + ), + ); } } diff --git a/apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart b/apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart index bfad56f..817a6a4 100644 --- a/apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart @@ -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( diff --git a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart index 9ee8c17..37fb261 100644 --- a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart +++ b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart @@ -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 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, diff --git a/backend/alembic/versions/20260407_0001_update_notification_settings.py b/backend/alembic/versions/20260407_0001_update_notification_settings.py new file mode 100644 index 0000000..4d6e69f --- /dev/null +++ b/backend/alembic/versions/20260407_0001_update_notification_settings.py @@ -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; + $$; + """ + ) diff --git a/backend/src/v1/users/router.py b/backend/src/v1/users/router.py index c9ca84c..08b70ec 100644 --- a/backend/src/v1/users/router.py +++ b/backend/src/v1/users/router.py @@ -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, diff --git a/backend/src/v1/users/schemas.py b/backend/src/v1/users/schemas.py index 5930eee..d930b9c 100644 --- a/backend/src/v1/users/schemas.py +++ b/backend/src/v1/users/schemas.py @@ -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") diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py index cf725c3..fadfc2f 100644 --- a/backend/src/v1/users/service.py +++ b/backend/src/v1/users/service.py @@ -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]: