Frontend first draft

This commit is contained in:
Arseni
2025-11-13 15:06:15 +03:00
parent e47f343afb
commit ddb54ddfdc
504 changed files with 25498 additions and 1 deletions

View 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,
);
}
}

View 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();
}
}
}

View 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,
),
),
],
);
}
}

View 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,
),
],
),
),
);
}
}

View 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);
},
);
}
}

View 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 its 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),
);
}

View 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;
},
);
}
}

View 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,
),
),
],
),
),
],
);
}
}