Frontend first draft
This commit is contained in:
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