feat(app): смена сервера API и очистка данных устройства при выходе

Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
This commit is contained in:
Dmitry
2026-09-19 22:14:14 +03:00
parent bfe74ca47c
commit a559d6de3e
17 changed files with 887 additions and 135 deletions
+357 -68
View File
@@ -7,83 +7,372 @@ 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';
/// Настройки: API endpoint, signed-in account, theme and logout.
class SettingsPage extends ConsumerWidget {
/// 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
Widget build(BuildContext context, WidgetRef ref) {
final me = ref.watch(meProvider);
final themeMode = ref.watch(themeModeProvider);
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
return Scaffold(
appBar: AppBar(title: const Text('Настройки')),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
const ListTile(
leading: Icon(Icons.dns_outlined),
title: Text('Адрес API'),
),
ListTile(
contentPadding: const EdgeInsets.only(left: 56, right: 16),
title: SelectableText(apiBaseUrl()),
),
const Divider(),
ListTile(
leading: const Icon(Icons.account_circle_outlined),
title: const Text('Аккаунт'),
subtitle: AsyncValueView(
value: me,
data: (u) => Text(u.email),
onRetry: () => ref.invalidate(meProvider),
),
),
const Divider(),
const ListTile(
leading: Icon(Icons.palette_outlined),
title: Text('Тема'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SegmentedButton<ThemeMode>(
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),
),
class _SettingsPageState extends ConsumerState<SettingsPage> {
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),
),
],
selected: {themeMode},
onSelectionChanged: (selection) =>
ref.read(themeModeProvider.notifier).setMode(selection.first),
),
)
: 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,
),
),
const Divider(),
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('Версия приложения'),
subtitle: Text(appVersion),
),
const Divider(),
ListTile(
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
title: Text('Выйти', style: TextStyle(color: Theme.of(context).colorScheme.error)),
onTap: () => ref.read(authControllerProvider.notifier).logout(),
),
],
),
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<ThemeMode>(
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);