255 lines
7.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:manager_app/constants.dart';
/// Carte de contenu d'un écran d'édition : fond blanc, bordure 1 px, titre en
/// `kTitleCard` précédé d'une icône. Remplace les `Card(elevation: 0, radius: 16)`
/// dont le titre répétait le nom du champ qu'elles contenaient.
class Pane extends StatelessWidget {
const Pane({
Key? key,
required this.icon,
required this.title,
required this.child,
this.action,
}) : super(key: key);
final IconData icon;
final String title;
final Widget child;
final Widget? action;
@override
Widget build(BuildContext context) {
return Container(
padding: kCardPadding,
decoration: BoxDecoration(
color: kSurface,
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusCard),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 14, color: kPrimaryColor),
const SizedBox(width: kSpace2),
Expanded(child: Text(title, style: kTitleCard)),
if (action != null) action!,
],
),
const SizedBox(height: kSpace4),
child,
],
),
);
}
}
/// Les deux colonnes des écrans d'édition : le travail à gauche, le rail à
/// droite. Sous 900 px, tout s'empile — responsive jusqu'à la tablette, pas
/// d'éditeur mobile.
class EditorColumns extends StatelessWidget {
const EditorColumns({Key? key, required this.main, required this.rail})
: super(key: key);
final List<Widget> main;
final List<Widget> rail;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final stacked = constraints.maxWidth < 900;
final mainColumn = _stack(main);
final railColumn = _stack(rail);
if (stacked) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
mainColumn,
const SizedBox(height: kSpace5),
railColumn,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: mainColumn),
const SizedBox(width: kSpace5),
SizedBox(width: 300, child: railColumn),
],
);
},
);
}
Widget _stack(List<Widget> panes) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < panes.length; i++) ...[
if (i > 0) const SizedBox(height: kSpace5),
panes[i],
],
],
);
}
}
/// En-tête d'un écran d'édition : le retour, le titre et son contexte, puis les
/// actions. Elles étaient dans une barre flottante en bas de l'écran, qui
/// mangeait 60 px de contenu.
class EditorHeader extends StatelessWidget {
const EditorHeader({
Key? key,
required this.title,
required this.subtitle,
required this.onBack,
this.leadingIcon,
this.chip,
this.actions = const [],
}) : super(key: key);
final String title;
final String subtitle;
final VoidCallback onBack;
final IconData? leadingIcon;
final Widget? chip;
final List<Widget> actions;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace6, vertical: kSpace4),
decoration: const BoxDecoration(
color: kSurface,
border: Border(bottom: BorderSide(color: kLine)),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: kPrimaryColor, size: 20),
onPressed: onBack,
),
const SizedBox(width: kSpace2),
if (leadingIcon != null) ...[
Icon(leadingIcon, color: kPrimaryColor, size: 20),
const SizedBox(width: kSpace3),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: kInk),
overflow: TextOverflow.ellipsis),
Text(subtitle,
style: const TextStyle(fontSize: 11.5, color: kInk3),
overflow: TextOverflow.ellipsis),
],
),
),
if (chip != null) ...[
const SizedBox(width: kSpace4),
chip!,
],
for (final action in actions) ...[
const SizedBox(width: kSpace3),
action,
],
],
),
);
}
}
/// Les trois boutons de la convention `confirmation_dialog.dart` : pastille,
/// 13 px, w600.
class PillButton extends StatelessWidget {
const PillButton({
Key? key,
required this.label,
required this.onPressed,
this.icon,
this.style = PillButtonStyle.ghost,
}) : super(key: key);
final String label;
final VoidCallback onPressed;
final IconData? icon;
final PillButtonStyle style;
@override
Widget build(BuildContext context) {
const textStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w600);
if (style == PillButtonStyle.ghost) {
return TextButton.icon(
onPressed: onPressed,
icon: icon == null ? null : Icon(icon, size: 15, color: kInk2),
label: Text(label, style: textStyle.copyWith(color: kInk2)),
);
}
return FilledButton.icon(
onPressed: onPressed,
icon: icon == null ? null : Icon(icon, size: 15),
label: Text(label, style: textStyle),
style: FilledButton.styleFrom(
backgroundColor:
style == PillButtonStyle.danger ? kError : kPrimaryColor,
foregroundColor: kWhite,
padding: const EdgeInsets.symmetric(
horizontal: kSpace5, vertical: kSpace3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadiusPill)),
),
);
}
}
enum PillButtonStyle { ghost, solid, danger }
/// Bouton icône encadré de l'en-tête (export, aide…).
class SquareIconButton extends StatelessWidget {
const SquareIconButton({
Key? key,
required this.icon,
required this.onPressed,
this.tooltip,
}) : super(key: key);
final IconData icon;
final VoidCallback onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip ?? "",
child: Material(
color: kSurface,
borderRadius: BorderRadius.circular(kRadiusInput),
child: InkWell(
borderRadius: BorderRadius.circular(kRadiusInput),
onTap: onPressed,
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Icon(icon, size: 16, color: kInk2),
),
),
),
);
}
}