feat(app): смена сервера API и очистка данных устройства при выходе
Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user