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>
683 lines
24 KiB
Dart
683 lines
24 KiB
Dart
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 '100–250';
|
||
}
|
||
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 '30–300';
|
||
}
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|