Files
food-ai/client/lib/features/profile/profile_screen.dart
dbastrikin 54b10d51e2 feat: Flutter client localisation (12 languages)
Add flutter_localizations + intl, 12 ARB files (en/ru/es/de/fr/it/pt/zh/ja/ko/ar/hi),
replace all hardcoded Russian UI strings with AppLocalizations, detect system locale
on first launch, localise bottom nav bar labels, document rule in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:22:52 +02:00

683 lines
24 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
import '../../core/locale/language_provider.dart';
import '../../core/theme/app_colors.dart';
import '../../shared/models/meal_type.dart';
import '../../shared/models/user.dart';
import 'profile_provider.dart';
import 'profile_service.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final state = ref.watch(profileProvider);
return Scaffold(
appBar: AppBar(
title: Text(l10n.profileTitle),
actions: [
if (state.hasValue)
TextButton(
onPressed: () => _openEdit(context, state.value!),
child: Text(l10n.edit),
),
],
),
body: state.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: FilledButton(
onPressed: () => ref.read(profileProvider.notifier).load(),
child: Text(l10n.retry),
),
),
data: (user) => _ProfileBody(user: user),
),
);
}
void _openEdit(BuildContext context, User user) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => EditProfileSheet(user: user),
);
}
}
// ── Profile body ──────────────────────────────────────────────
class _ProfileBody extends ConsumerWidget {
final User user;
const _ProfileBody({required this.user});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
String? genderLabel(String? gender) => switch (gender) {
'male' => l10n.genderMale,
'female' => l10n.genderFemale,
_ => null,
};
String? goalLabel(String? goal) => switch (goal) {
'lose' => l10n.goalLoss,
'maintain' => l10n.goalMaintain,
'gain' => l10n.goalGain,
_ => null,
};
String? activityLabel(String? activity) => switch (activity) {
'low' => l10n.activityLow,
'moderate' => l10n.activityMedium,
'high' => l10n.activityHigh,
_ => null,
};
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_ProfileHeader(user: user),
const SizedBox(height: 24),
// Body params
_SectionLabel(l10n.bodyParams),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow(l10n.height, user.heightCm != null ? '${user.heightCm} см' : null),
const Divider(height: 1, indent: 16),
_InfoRow(l10n.weight,
user.weightKg != null ? '${_fmt(user.weightKg!)} кг' : null),
const Divider(height: 1, indent: 16),
_InfoRow(l10n.age, user.age != null ? '${user.age}' : null),
const Divider(height: 1, indent: 16),
_InfoRow(l10n.gender, genderLabel(user.gender)),
]),
if (user.heightCm == null && user.weightKg == null) ...[
const SizedBox(height: 8),
_HintBanner(l10n.calorieHint),
],
const SizedBox(height: 16),
// Goal & activity
_SectionLabel(l10n.goalActivity),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow(l10n.goalLabel.replaceAll(':', '').trim(), goalLabel(user.goal)),
const Divider(height: 1, indent: 16),
_InfoRow('Активность', activityLabel(user.activity)),
]),
const SizedBox(height: 16),
// Calories + meal types
_SectionLabel(l10n.nutrition),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow(
l10n.calorieGoal,
user.dailyCalories != null
? '${user.dailyCalories} ${l10n.caloriesUnit}/день'
: null,
),
const Divider(height: 1, indent: 16),
_InfoRow(
l10n.mealTypes,
user.mealTypes
.map((mealTypeId) => mealTypeLabel(mealTypeId, l10n))
.join(', '),
),
]),
if (user.dailyCalories != null) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
l10n.formulaNote,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppColors.textSecondary,
),
),
),
],
const SizedBox(height: 16),
// Settings
_SectionLabel(l10n.settings),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow(
l10n.language,
ref.watch(supportedLanguagesProvider).valueOrNull?[
user.preferences['language'] as String? ?? 'ru'] ??
'Русский',
),
]),
const SizedBox(height: 32),
_LogoutButton(),
],
);
}
static String _fmt(double weight) =>
weight == weight.truncateToDouble()
? weight.toInt().toString()
: weight.toStringAsFixed(1);
}
// ── Header ────────────────────────────────────────────────────
class _ProfileHeader extends StatelessWidget {
final User user;
const _ProfileHeader({required this.user});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final initials = user.name.isNotEmpty ? user.name[0].toUpperCase() : '?';
return Column(
children: [
const SizedBox(height: 8),
CircleAvatar(
radius: 40,
backgroundColor: AppColors.primary.withValues(alpha: 0.15),
child: Text(
initials,
style: theme.textTheme.headlineMedium?.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 12),
Text(user.name, style: theme.textTheme.titleMedium),
const SizedBox(height: 2),
Text(
user.email,
style: theme.textTheme.bodySmall
?.copyWith(color: AppColors.textSecondary),
),
],
);
}
}
// ── Shared widgets ────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
text,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppColors.textSecondary,
letterSpacing: 0.5,
),
),
);
}
class _InfoCard extends StatelessWidget {
final List<Widget> children;
const _InfoCard({required this.children});
@override
Widget build(BuildContext context) =>
Card(child: Column(children: children));
}
class _InfoRow extends StatelessWidget {
final String label;
final String? value;
const _InfoRow(this.label, this.value);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(
children: [
Text(label, style: theme.textTheme.bodyMedium),
const Spacer(),
Text(
value ?? l10n.notSet,
style: theme.textTheme.bodyMedium?.copyWith(
color: value != null
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
],
),
);
}
}
class _HintBanner extends StatelessWidget {
final String text;
const _HintBanner(this.text);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 16, color: AppColors.warning),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: AppColors.warning),
),
),
],
),
);
}
}
class _LogoutButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
),
onPressed: () => _confirmLogout(context, ref),
child: Text(l10n.logout),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text('${l10n.logout}?'),
content: const Text('Вы будете перенаправлены на экран входа.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
TextButton(
style: TextButton.styleFrom(foregroundColor: AppColors.error),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.logout),
),
],
),
);
if (confirmed == true) {
await ref.read(authProvider.notifier).signOut();
}
}
}
// ── Edit sheet ────────────────────────────────────────────────
class EditProfileSheet extends ConsumerStatefulWidget {
final User user;
const EditProfileSheet({super.key, required this.user});
@override
ConsumerState<EditProfileSheet> createState() => _EditProfileSheetState();
}
class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _heightController;
late final TextEditingController _weightController;
DateTime? _selectedDob;
String? _gender;
String? _goal;
String? _activity;
String? _language;
List<String> _mealTypes = [];
bool _saving = false;
@override
void initState() {
super.initState();
final user = widget.user;
_nameController = TextEditingController(text: user.name);
_heightController = TextEditingController(text: user.heightCm?.toString() ?? '');
_weightController = TextEditingController(
text: user.weightKg != null ? _fmt(user.weightKg!) : '');
_selectedDob =
user.dateOfBirth != null ? DateTime.tryParse(user.dateOfBirth!) : null;
_gender = user.gender;
_goal = user.goal;
_activity = user.activity;
_language = user.preferences['language'] as String? ?? 'ru';
_mealTypes = List<String>.from(user.mealTypes);
}
@override
void dispose() {
_nameController.dispose();
_heightController.dispose();
_weightController.dispose();
super.dispose();
}
String _fmt(double weight) =>
weight == weight.truncateToDouble()
? weight.toInt().toString()
: weight.toStringAsFixed(1);
Future<void> _pickDob() async {
final now = DateTime.now();
final initial = _selectedDob ?? DateTime(now.year - 25, now.month, now.day);
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(now.year - 120),
lastDate: DateTime(now.year - 10, now.month, now.day),
);
if (picked != null) setState(() => _selectedDob = picked);
}
Future<void> _save() async {
final l10n = AppLocalizations.of(context)!;
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final request = UpdateProfileRequest(
name: _nameController.text.trim(),
heightCm: int.tryParse(_heightController.text),
weightKg: double.tryParse(_weightController.text),
dateOfBirth: _selectedDob != null
? '${_selectedDob!.year.toString().padLeft(4, '0')}-'
'${_selectedDob!.month.toString().padLeft(2, '0')}-'
'${_selectedDob!.day.toString().padLeft(2, '0')}'
: null,
gender: _gender,
goal: _goal,
activity: _activity,
language: _language,
mealTypes: _mealTypes,
);
final ok = await ref.read(profileProvider.notifier).update(request);
if (!mounted) return;
setState(() => _saving = false);
if (ok) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.profileUpdated)),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.profileSaveFailed)),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
maxChildSize: 0.95,
builder: (_, controller) => Column(
children: [
const SizedBox(height: 12),
// Drag handle
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppColors.separator,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(l10n.editProfile, style: theme.textTheme.titleMedium),
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
],
),
),
const Divider(height: 1),
Expanded(
child: Form(
key: _formKey,
child: ListView(
controller: controller,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
// Name
TextFormField(
controller: _nameController,
decoration: InputDecoration(labelText: l10n.nameLabel),
textCapitalization: TextCapitalization.words,
validator: (value) =>
(value == null || value.trim().isEmpty)
? l10n.nameRequired
: null,
),
const SizedBox(height: 12),
// Height + Weight
Row(
children: [
Expanded(
child: TextFormField(
controller: _heightController,
decoration: InputDecoration(labelText: l10n.heightCm),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly
],
validator: (value) {
if (value == null || value.isEmpty) return null;
final number = int.tryParse(value);
if (number == null || number < 100 || number > 250) {
return '100250';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _weightController,
decoration: InputDecoration(labelText: l10n.weightKg),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[0-9.]'))
],
validator: (value) {
if (value == null || value.isEmpty) return null;
final number = double.tryParse(value);
if (number == null || number < 30 || number > 300) {
return '30300';
}
return null;
},
),
),
],
),
const SizedBox(height: 12),
// Date of birth
InkWell(
onTap: _pickDob,
child: InputDecorator(
decoration: InputDecoration(labelText: l10n.birthDate),
child: Text(
_selectedDob != null
? '${_selectedDob!.day.toString().padLeft(2, '0')}.'
'${_selectedDob!.month.toString().padLeft(2, '0')}.'
'${_selectedDob!.year}'
: l10n.notSet,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
const SizedBox(height: 20),
// Gender
Text(l10n.gender, style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: [
ButtonSegment(value: 'male', label: Text(l10n.genderMale)),
ButtonSegment(value: 'female', label: Text(l10n.genderFemale)),
],
selected: _gender != null ? {_gender!} : const {},
emptySelectionAllowed: true,
onSelectionChanged: (selection) =>
setState(() => _gender = selection.isEmpty ? null : selection.first),
),
const SizedBox(height: 20),
// Goal
Text(l10n.goalLabel.replaceAll(':', '').trim(),
style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: [
ButtonSegment(value: 'lose', label: Text(l10n.goalLoss)),
ButtonSegment(value: 'maintain', label: Text(l10n.goalMaintain)),
ButtonSegment(value: 'gain', label: Text(l10n.goalGain)),
],
selected: _goal != null ? {_goal!} : const {},
emptySelectionAllowed: true,
onSelectionChanged: (selection) =>
setState(() => _goal = selection.isEmpty ? null : selection.first),
),
const SizedBox(height: 20),
// Activity
Text('Активность', style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: [
ButtonSegment(value: 'low', label: Text(l10n.activityLow)),
ButtonSegment(value: 'moderate', label: Text(l10n.activityMedium)),
ButtonSegment(value: 'high', label: Text(l10n.activityHigh)),
],
selected: _activity != null ? {_activity!} : const {},
emptySelectionAllowed: true,
onSelectionChanged: (selection) => setState(
() => _activity = selection.isEmpty ? null : selection.first),
),
const SizedBox(height: 20),
// Meal types
Text(l10n.mealTypes, style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 6,
children: kAllMealTypes.map((mealTypeOption) {
final isSelected =
_mealTypes.contains(mealTypeOption.id);
return FilterChip(
label: Text(
'${mealTypeOption.emoji} ${mealTypeLabel(mealTypeOption.id, l10n)}'),
selected: isSelected,
onSelected: (selected) {
setState(() {
if (selected) {
_mealTypes.add(mealTypeOption.id);
} else if (_mealTypes.length > 1) {
_mealTypes.remove(mealTypeOption.id);
}
});
},
);
}).toList(),
),
const SizedBox(height: 20),
// Language
ref.watch(supportedLanguagesProvider).when(
data: (languages) => DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: l10n.language),
initialValue: _language,
items: languages.entries
.map((entry) => DropdownMenuItem(
value: entry.key,
child: Text(entry.value),
))
.toList(),
onChanged: (value) => setState(() => _language = value),
),
loading: () => const Center(
child: CircularProgressIndicator()),
error: (_, __) =>
Text(l10n.historyLoadError),
),
const SizedBox(height: 32),
// Save
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(l10n.save),
),
],
),
),
),
],
),
),
);
}
}