Frontend first draft
This commit is contained in:
107
frontend/pweb/lib/pages/settings/profile/account/avatar.dart
Normal file
107
frontend/pweb/lib/pages/settings/profile/account/avatar.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
//import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class AvatarTile extends StatefulWidget {
|
||||
final String? avatarUrl;
|
||||
final String title;
|
||||
final String description;
|
||||
final String errorText;
|
||||
|
||||
const AvatarTile({
|
||||
super.key,
|
||||
required this.avatarUrl,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.errorText,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AvatarTile> createState() => _AvatarTileState();
|
||||
}
|
||||
|
||||
class _AvatarTileState extends State<AvatarTile> {
|
||||
static const double _avatarSize = 96.0;
|
||||
static const double _iconSize = 32.0;
|
||||
static const double _titleSpacing = 4.0;
|
||||
static const String _placeholderAsset = 'assets/images/avatar_placeholder.png';
|
||||
|
||||
bool _isHovering = false;
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final picker = ImagePicker();
|
||||
final file = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (file != null) {
|
||||
debugPrint('Selected new avatar: ${file.path}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final safeUrl =
|
||||
widget.avatarUrl?.trim().isNotEmpty == true ? widget.avatarUrl : null;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovering = true),
|
||||
onExit: (_) => setState(() => _isHovering = false),
|
||||
child: GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipOval(
|
||||
child: safeUrl != null
|
||||
? Image.network(
|
||||
safeUrl,
|
||||
width: _avatarSize,
|
||||
height: _avatarSize,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildPlaceholder(),
|
||||
)
|
||||
: _buildPlaceholder(),
|
||||
),
|
||||
if (_isHovering)
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: _avatarSize,
|
||||
height: _avatarSize,
|
||||
color: theme.colorScheme.primary.withAlpha(90),
|
||||
child: Icon(
|
||||
Icons.camera_alt,
|
||||
color: theme.colorScheme.onSecondary,
|
||||
size: _iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: _titleSpacing),
|
||||
Text(
|
||||
loc.avatarHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder() {
|
||||
return Image.asset(
|
||||
_placeholderAsset,
|
||||
width: _avatarSize,
|
||||
height: _avatarSize,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
}
|
||||
86
frontend/pweb/lib/pages/settings/profile/account/locale.dart
Normal file
86
frontend/pweb/lib/pages/settings/profile/account/locale.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/locale.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
import 'package:pweb/services/amplitude.dart';
|
||||
|
||||
|
||||
class LocalePicker extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const LocalePicker({
|
||||
super.key,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
static const double _pickerWidth = 300;
|
||||
static const double _iconSize = 20;
|
||||
static const double _gapMedium = 6;
|
||||
static const double _gapLarge = 8;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
|
||||
return Consumer<LocaleProvider>(
|
||||
builder: (context, localeProvider, _) {
|
||||
final currentLocale = localeProvider.locale;
|
||||
final options = AppLocalizations.supportedLocales;
|
||||
|
||||
return SizedBox(
|
||||
width: _pickerWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.language_outlined, color: theme.colorScheme.primary, size: _iconSize),
|
||||
const SizedBox(width: _gapMedium),
|
||||
Text(title, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: _gapLarge),
|
||||
DropdownButtonFormField<Locale>(
|
||||
initialValue: currentLocale,
|
||||
items: options
|
||||
.map(
|
||||
(locale) => DropdownMenuItem(
|
||||
value: locale,
|
||||
child: Text(_localizedLocaleName(locale, loc)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (locale) {
|
||||
if (locale != null) {
|
||||
localeProvider.setLocale(locale);
|
||||
AmplitudeService.localeChanged(locale);
|
||||
}
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _localizedLocaleName(Locale locale, AppLocalizations loc) {
|
||||
switch (locale.languageCode) {
|
||||
case 'en':
|
||||
return 'English';
|
||||
case 'ru':
|
||||
return 'Русский';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
default:
|
||||
return locale.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
127
frontend/pweb/lib/pages/settings/profile/account/name.dart
Normal file
127
frontend/pweb/lib/pages/settings/profile/account/name.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class AccountName extends StatefulWidget {
|
||||
final String name;
|
||||
final String title;
|
||||
final String hintText;
|
||||
final String errorText;
|
||||
|
||||
const AccountName({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.title,
|
||||
required this.hintText,
|
||||
required this.errorText,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AccountName> createState() => _AccountNameState();
|
||||
}
|
||||
|
||||
class _AccountNameState extends State<AccountName> {
|
||||
static const double _inputWidth = 200;
|
||||
static const double _spacing = 8;
|
||||
static const double _errorSpacing = 4;
|
||||
static const double _borderWidth = 2;
|
||||
|
||||
late final TextEditingController _controller;
|
||||
bool _isEditing = false;
|
||||
late String _originalName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.name);
|
||||
_originalName = widget.name;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startEditing() => setState(() => _isEditing = true);
|
||||
|
||||
void _cancelEditing() {
|
||||
setState(() {
|
||||
_controller.text = _originalName;
|
||||
_isEditing = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _saveEditing() {
|
||||
setState(() {
|
||||
_originalName = _controller.text;
|
||||
_isEditing = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isEditing)
|
||||
SizedBox(
|
||||
width: _inputWidth,
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
isDense: true,
|
||||
border: UnderlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: _borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
_originalName,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _spacing),
|
||||
if (_isEditing) ...[
|
||||
IconButton(
|
||||
icon: Icon(Icons.check, color: theme.colorScheme.primary),
|
||||
onPressed: _saveEditing,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: theme.colorScheme.error),
|
||||
onPressed: _cancelEditing,
|
||||
),
|
||||
] else
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit, color: theme.colorScheme.primary),
|
||||
onPressed: _startEditing,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: _errorSpacing),
|
||||
if (widget.errorText.isEmpty)
|
||||
Text(
|
||||
widget.errorText,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
53
frontend/pweb/lib/pages/settings/profile/page.dart
Normal file
53
frontend/pweb/lib/pages/settings/profile/page.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/settings/profile/account/avatar.dart';
|
||||
import 'package:pweb/pages/settings/profile/account/locale.dart';
|
||||
import 'package:pweb/pages/settings/profile/account/name.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class ProfileSettingsPage extends StatelessWidget {
|
||||
const ProfileSettingsPage({super.key});
|
||||
|
||||
static const _cardPadding = EdgeInsets.symmetric(vertical: 32, horizontal: 16);
|
||||
static const _cardRadius = 16.0;
|
||||
static const _itemSpacing = 12.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(_cardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: theme.colorScheme.onSecondary,
|
||||
child: Padding(
|
||||
padding: _cardPadding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: _itemSpacing,
|
||||
children: [
|
||||
AvatarTile(
|
||||
avatarUrl: 'https://avatars.githubusercontent.com/u/65651201',
|
||||
title: loc.avatar,
|
||||
description: loc.avatarHint,
|
||||
errorText: loc.avatarUpdateError,
|
||||
),
|
||||
AccountName(
|
||||
name: 'User Name',
|
||||
title: loc.accountName,
|
||||
hintText: loc.accountNameHint,
|
||||
errorText: loc.accountNameUpdateError,
|
||||
),
|
||||
LocalePicker(
|
||||
title: loc.language,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
143
frontend/pweb/lib/pages/settings/widgets/base.dart
Normal file
143
frontend/pweb/lib/pages/settings/widgets/base.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
import 'package:pweb/utils/error/snackbar.dart';
|
||||
|
||||
enum _EditState { view, edit, saving }
|
||||
|
||||
/// Базовый класс, управляющий состояниями (view/edit/saving),
|
||||
/// показом snackbar ошибок и успешного сохранения.
|
||||
abstract class BaseEditTile<T> extends AbstractSettingsTile {
|
||||
const BaseEditTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.valueGetter,
|
||||
required this.valueSetter,
|
||||
required this.errorSituation,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final ValueGetter<T?> valueGetter;
|
||||
final Future<void> Function(T) valueSetter;
|
||||
final String errorSituation;
|
||||
|
||||
/// Рисует в режиме просмотра (read-only).
|
||||
Widget buildView(BuildContext context, T? value);
|
||||
|
||||
/// Рисует UI редактора.
|
||||
/// Если [useDialogEditor]==true, его обернут в диалог.
|
||||
Widget buildEditor(
|
||||
BuildContext context,
|
||||
T? initial,
|
||||
void Function(T) onSave,
|
||||
VoidCallback onCancel,
|
||||
bool isSaving,
|
||||
);
|
||||
|
||||
/// true → показывать редактор в диалоге, false → inline под заголовком.
|
||||
bool get useDialogEditor => false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _BaseEditTileBody<T>(delegate: this);
|
||||
}
|
||||
|
||||
class _BaseEditTileBody<T> extends StatefulWidget {
|
||||
const _BaseEditTileBody({required this.delegate});
|
||||
final BaseEditTile<T> delegate;
|
||||
@override
|
||||
State<_BaseEditTileBody<T>> createState() => _BaseEditTileBodyState<T>();
|
||||
}
|
||||
|
||||
class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
|
||||
_EditState _state = _EditState.view;
|
||||
bool get _isSaving => _state == _EditState.saving;
|
||||
|
||||
Future<void> _performSave(T newValue) async {
|
||||
final current = widget.delegate.valueGetter();
|
||||
if (newValue == current) {
|
||||
setState(() => _state = _EditState.view);
|
||||
return;
|
||||
}
|
||||
setState(() => _state = _EditState.saving);
|
||||
final sms = ScaffoldMessenger.of(context);
|
||||
final locs = AppLocalizations.of(context)!;
|
||||
try {
|
||||
await widget.delegate.valueSetter(newValue);
|
||||
sms.showSnackBar(SnackBar(
|
||||
content: Text(locs.settingsSuccessfullyUpdated),
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
));
|
||||
} catch (e) {
|
||||
notifyUserOfErrorX(
|
||||
scaffoldMessenger: sms,
|
||||
errorSituation: widget.delegate.errorSituation,
|
||||
appLocalizations: locs,
|
||||
exception: e,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _state = _EditState.view);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openDialogEditor() async {
|
||||
final initial = widget.delegate.valueGetter();
|
||||
final T? result = await showDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (ctx) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: widget.delegate.buildEditor(
|
||||
ctx,
|
||||
initial,
|
||||
(v) => Navigator.of(ctx).pop(v),
|
||||
() => Navigator.of(ctx).pop(),
|
||||
_isSaving,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result != null) await _performSave(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final delegate = widget.delegate;
|
||||
final current = delegate.valueGetter();
|
||||
|
||||
// Диалоговый режим
|
||||
if (delegate.useDialogEditor) {
|
||||
return SettingsTile.navigation(
|
||||
leading: Icon(delegate.icon),
|
||||
title: Text(delegate.title),
|
||||
value: delegate.buildView(context, current),
|
||||
onPressed: (_) => _openDialogEditor(),
|
||||
);
|
||||
}
|
||||
|
||||
// Inline-режим (под заголовком будет редактор прямо в tile)
|
||||
return SettingsTile.navigation(
|
||||
leading: Icon(delegate.icon),
|
||||
title: Text(delegate.title),
|
||||
value: _state == _EditState.view
|
||||
? delegate.buildView(context, current)
|
||||
: delegate.buildEditor(
|
||||
context,
|
||||
current,
|
||||
_performSave,
|
||||
() => setState(() => _state = _EditState.view),
|
||||
_isSaving,
|
||||
),
|
||||
onPressed: (_) {
|
||||
if (_state == _EditState.view) setState(() => _state = _EditState.edit);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
112
frontend/pweb/lib/pages/settings/widgets/image.dart
Normal file
112
frontend/pweb/lib/pages/settings/widgets/image.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import 'package:flutter_settings_ui/flutter_settings_ui.dart';
|
||||
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:pweb/utils/error/snackbar.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class ImageTile extends AbstractSettingsTile {
|
||||
final String? imageUrl;
|
||||
final double? maxWidth;
|
||||
final double? maxHeight;
|
||||
final String? imageUpdateError;
|
||||
final Future<void> Function(XFile?) onUpdate;
|
||||
final String? title;
|
||||
final String? description;
|
||||
final Widget? imagePreview;
|
||||
final double previewWidth;
|
||||
final double previewHeight;
|
||||
|
||||
const ImageTile({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.maxWidth,
|
||||
this.maxHeight,
|
||||
this.imageUpdateError,
|
||||
required this.onUpdate,
|
||||
this.title,
|
||||
this.description,
|
||||
this.imagePreview,
|
||||
this.previewHeight = 40.0,
|
||||
this.previewWidth = 40.0,
|
||||
});
|
||||
|
||||
Future<void> _pickImage(BuildContext context) async {
|
||||
final picker = ImagePicker();
|
||||
final locs = AppLocalizations.of(context)!;
|
||||
final sm = ScaffoldMessenger.of(context);
|
||||
final picked = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: maxHeight,
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
try {
|
||||
await onUpdate(picked);
|
||||
if (imageUrl != null) {
|
||||
CachedNetworkImage.evictFromCache(imageUrl!);
|
||||
}
|
||||
} catch (e) {
|
||||
notifyUserOfErrorX(
|
||||
scaffoldMessenger: sm,
|
||||
errorSituation: imageUpdateError ?? locs.settingsImageUpdateError,
|
||||
exception: e,
|
||||
appLocalizations: locs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SettingsTile.navigation(
|
||||
leading: imagePreview ??
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(0.1 * (previewWidth < previewHeight ? previewWidth : previewHeight)),
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl!,
|
||||
width: previewWidth,
|
||||
height: previewHeight,
|
||||
fit: BoxFit.cover,
|
||||
progressIndicatorBuilder: (ctx, url, downloadProgress) {
|
||||
// compute 10% of the smaller image dimension, but no more than 40px
|
||||
final baseSize = min(previewWidth, previewHeight) * 0.1;
|
||||
final indicatorSize = baseSize.clamp(0.0, 40.0);
|
||||
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: indicatorSize,
|
||||
height: indicatorSize,
|
||||
child: CircularProgressIndicator(
|
||||
value: downloadProgress.progress, // from 0.0 to 1.0
|
||||
strokeWidth: max(indicatorSize * 0.1, 2.0), // 10% of size, but at least 2px so it’s visible
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (ctx, url, err) => const Icon(Icons.error),
|
||||
)
|
||||
: Container(
|
||||
width: previewWidth,
|
||||
height: previewHeight,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
size: previewWidth * 0.6,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(title ?? AppLocalizations.of(context)!.settingsImageTitle),
|
||||
description: Text(description ?? AppLocalizations.of(context)!.settingsImageHint),
|
||||
onPressed: (_) => _pickImage(context),
|
||||
);
|
||||
}
|
||||
114
frontend/pweb/lib/pages/settings/widgets/pick.dart
Normal file
114
frontend/pweb/lib/pages/settings/widgets/pick.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
import 'package:pweb/pages/settings/widgets/base.dart';
|
||||
|
||||
|
||||
const _kSearchThreshold = 12;
|
||||
|
||||
class SelectValueTile<T> extends BaseEditTile<T> {
|
||||
final double? maxEditorHeight;
|
||||
final double? maxEditorWidth;
|
||||
|
||||
const SelectValueTile({
|
||||
super.key,
|
||||
required super.icon,
|
||||
required super.title,
|
||||
required super.valueGetter,
|
||||
required super.valueSetter,
|
||||
required super.errorSituation,
|
||||
required this.options,
|
||||
required this.labelBuilder,
|
||||
this.filterOptions,
|
||||
this.maxEditorHeight,
|
||||
this.maxEditorWidth,
|
||||
});
|
||||
|
||||
final List<T> options;
|
||||
final String Function(T) labelBuilder;
|
||||
final List<T> Function(String)? filterOptions;
|
||||
|
||||
@override
|
||||
bool get useDialogEditor => true;
|
||||
|
||||
@override
|
||||
Widget buildView(BuildContext context, T? value) {
|
||||
return Text(
|
||||
value == null ? AppLocalizations.of(context)!.notSet : labelBuilder(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildEditor(
|
||||
BuildContext context,
|
||||
T? initial,
|
||||
void Function(T) onSave,
|
||||
VoidCallback onCancel,
|
||||
bool isSaving,
|
||||
) {
|
||||
// local state for the current search query
|
||||
String searchText = '';
|
||||
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
// decide which list to show
|
||||
final displayedOptions = (options.length > _kSearchThreshold && filterOptions != null && searchText.isNotEmpty)
|
||||
? filterOptions!(searchText)
|
||||
: options;
|
||||
|
||||
final content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (options.length > _kSearchThreshold && filterOptions != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(context)!.search,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
// update the local searchText and rebuild the list
|
||||
setState(() {
|
||||
searchText = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: displayedOptions.map((o) => RadioListTile<T>(
|
||||
value: o,
|
||||
groupValue: initial,
|
||||
title: Text(labelBuilder(o)),
|
||||
onChanged: isSaving ? null : (v) { if (v != null) onSave(v); },
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
TextButton(
|
||||
onPressed: onCancel,
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// if the caller passed a max size, enforce it:
|
||||
if (maxEditorHeight != null || maxEditorWidth != null) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: maxEditorHeight ?? double.infinity,
|
||||
maxWidth: maxEditorWidth ?? double.infinity,
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
88
frontend/pweb/lib/pages/settings/widgets/text.dart
Normal file
88
frontend/pweb/lib/pages/settings/widgets/text.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/settings/widgets/base.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class TextEditTile extends BaseEditTile<String> {
|
||||
const TextEditTile({
|
||||
super.key,
|
||||
required super.icon,
|
||||
required super.title,
|
||||
required super.valueGetter,
|
||||
required super.valueSetter,
|
||||
required super.errorSituation,
|
||||
required this.hintText,
|
||||
});
|
||||
|
||||
final String hintText;
|
||||
|
||||
@override
|
||||
Widget buildView(BuildContext context, String? value) {
|
||||
final locs = AppLocalizations.of(context)!;
|
||||
final display = (value ?? '').isEmpty ? locs.notSet : value!;
|
||||
return Text(
|
||||
display,
|
||||
semanticsLabel: (value ?? '').isEmpty ? locs.notSet : '$title: $display',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildEditor(
|
||||
BuildContext context,
|
||||
String? initial,
|
||||
void Function(String) onSave,
|
||||
VoidCallback onCancel,
|
||||
bool isSaving,
|
||||
) {
|
||||
final controller = TextEditingController(text: initial ?? '');
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
|
||||
),
|
||||
onSubmitted: (_) => onSave(controller.text.trim()),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isSaving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Row(
|
||||
key: const ValueKey('actions'),
|
||||
children: [
|
||||
Tooltip(
|
||||
message: AppLocalizations.of(context)!.ok,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: () => onSave(controller.text.trim()),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: AppLocalizations.of(context)!.cancel,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onCancel,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user