import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/common_providers.dart'; import '../../core/app_info.dart'; import '../../core/auth/auth_controller.dart'; import '../../core/config.dart'; import '../../core/theme/theme_controller.dart'; import '../../core/widgets/async_value_view.dart'; import 'api_base_url_dialog.dart'; /// One entry of the left menu and the panel it opens. class _Section { const _Section(this.title, this.build); final String title; final Widget Function(BuildContext context, WidgetRef ref) build; } /// A tab along the top: a group of sections. class _Group { const _Group(this.title, this.icon, this.sections); final String title; final IconData icon; final List<_Section> sections; } final _groups = [ _Group('Аккаунт', Icons.person_outline, [ _Section('Приватные данные', _privateData), _Section('Безопасность', _security), ]), _Group('Отображение', Icons.palette_outlined, [_Section('Тема', _theme)]), _Group('Сервер', Icons.dns_outlined, [ _Section('Адрес API', _apiAddress), _Section('О приложении', _about), ]), ]; /// Настройки, laid out like Snowball's account page: tabs along the top and, inside the tab, /// a menu of its sections on the left. Below 720 px the menu becomes a row of chips above the /// panel — a 240 px menu beside a form would leave the form nothing. class SettingsPage extends ConsumerStatefulWidget { const SettingsPage({super.key}); @override ConsumerState createState() => _SettingsPageState(); } class _SettingsPageState extends ConsumerState { int _group = 0; int _section = 0; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final group = _groups[_group]; final section = group.sections[_section.clamp(0, group.sections.length - 1)]; final wide = MediaQuery.sizeOf(context).width >= 720; final menu = wide ? SizedBox( width: 240, child: Column( children: [ for (var i = 0; i < group.sections.length; i++) _MenuItem( title: group.sections[i].title, selected: group.sections[i] == section, onTap: () => setState(() => _section = i), ), ], ), ) : Wrap( spacing: 8, children: [ for (var i = 0; i < group.sections.length; i++) ChoiceChip( label: Text(group.sections[i].title), selected: group.sections[i] == section, onSelected: (_) => setState(() => _section = i), ), ], ); return ListView( padding: const EdgeInsets.all(16), children: [ Card( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( border: Border( bottom: BorderSide(color: scheme.outlineVariant), ), ), padding: const EdgeInsets.symmetric(horizontal: 12), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (var i = 0; i < _groups.length; i++) _TopTab( icon: _groups[i].icon, title: _groups[i].title, selected: i == _group, onTap: () => setState(() { _group = i; _section = 0; }), ), ], ), ), ), Padding( padding: const EdgeInsets.all(24), child: wide ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ menu, const SizedBox(width: 24), Expanded(child: section.build(context, ref)), ], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ menu, const SizedBox(height: 20), section.build(context, ref), ], ), ), ], ), ), ], ); } } class _TopTab extends StatelessWidget { const _TopTab({ required this.icon, required this.title, required this.selected, required this.onTap, }); final IconData icon; final String title; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return InkWell( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: selected ? scheme.primary : Colors.transparent, width: 2, ), ), ), child: Row( children: [ Icon( icon, size: 20, color: selected ? scheme.primary : scheme.onSurfaceVariant, ), const SizedBox(width: 8), Text( title, style: TextStyle( color: selected ? scheme.onSurface : scheme.onSurfaceVariant, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, ), ), ], ), ), ); } } class _MenuItem extends StatelessWidget { const _MenuItem({ required this.title, required this.selected, required this.onTap, }); final String title; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 4), child: Material( color: selected ? scheme.surfaceContainerHigh : Colors.transparent, borderRadius: BorderRadius.circular(8), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: onTap, child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Text( title, style: TextStyle( color: selected ? scheme.onSurface : scheme.onSurfaceVariant, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), ), ), ); } } // --- panels ------------------------------------------------------------------------------ /// The muted banner at the top of a panel, like Snowball's «Это ваша конфиденциальная …». Widget _note(BuildContext context, IconData icon, String text) { final scheme = Theme.of(context).colorScheme; return Container( width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Icon(icon, size: 18, color: scheme.onSurfaceVariant), const SizedBox(width: 12), Expanded(child: Text(text)), ], ), ); } Widget _field( BuildContext context, String label, String value, { Widget? trailing, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 8), Row( children: [ Expanded( child: TextFormField( key: ValueKey('$label:$value'), initialValue: value, readOnly: true, ), ), if (trailing != null) ...[const SizedBox(width: 12), trailing], ], ), ], ); } Widget _privateData(BuildContext context, WidgetRef ref) { final me = ref.watch(meProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _note( context, Icons.lock_outline, 'Это ваша конфиденциальная информация, она недоступна другим', ), const SizedBox(height: 20), AsyncValueView( value: me, onRetry: () => ref.invalidate(meProvider), data: (u) => _field(context, 'Email', u.email), ), ], ); } Widget _security(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _note( context, Icons.shield_outlined, 'Выход завершает сессию на этом устройстве и удаляет с него сохранённый токен и кэш данных', ), const SizedBox(height: 20), FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: scheme.error, foregroundColor: scheme.onError, ), onPressed: () => ref.read(authControllerProvider.notifier).logout(), icon: const Icon(Icons.logout), label: const Text('Выйти'), ), ], ); } Widget _theme(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(themeModeProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Тема оформления', style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 12), SegmentedButton( segments: const [ ButtonSegment( value: ThemeMode.system, label: Text('Системная'), icon: Icon(Icons.brightness_auto_outlined), ), ButtonSegment( value: ThemeMode.light, label: Text('Светлая'), icon: Icon(Icons.light_mode_outlined), ), ButtonSegment( value: ThemeMode.dark, label: Text('Тёмная'), icon: Icon(Icons.dark_mode_outlined), ), ], selected: {themeMode}, onSelectionChanged: (selection) => ref.read(themeModeProvider.notifier).setMode(selection.first), ), ], ); } Widget _apiAddress(BuildContext context, WidgetRef ref) { return _field( context, 'Адрес API', ref.watch(apiBaseUrlProvider), trailing: FilledButton.tonalIcon( onPressed: () => showApiBaseUrlDialog(context), icon: const Icon(Icons.edit_outlined), label: const Text('Изменить'), ), ); } Widget _about(BuildContext context, WidgetRef ref) => _field(context, 'Версия приложения', appVersion);