Add swipe gestures to drone profile editor (vertical=big, horizontal=small step)
Ersetzt die einfachen Zahlen-TextFormFields im Drohnenprofil-Editor durch tastaturlose Swipe-Chips (Doku 3.5, HTML-Demonstrator: SwipeValueInput) - vertikales Ziehen aendert den Wert in grossen Schritten (stepBig), horizontales Ziehen in kleinen Schritten (stepSmall). Die Zugrichtung wird beim ersten Ueberschreiten einer 8px-Schwelle einmalig festgelegt und bleibt fuer den Rest der Geste gesperrt, damit eine leicht schraege Geste nicht zwischen den beiden Schrittweiten hin- und herspringt. Min/Max/ Schrittweiten je Feld 1:1 aus DRONE_EDITOR_GROUPS im HTML-Demonstrator uebernommen. Zusaetzlich ein "modified"-Punkt neben dem Label, sobald ein Wert vom Ausgangswert beim Oeffnen des Editors abweicht (HTML: .modified- dot/.chip-modified). lib/ui/widgets/swipe_value_input.dart: neuer, wiederverwendbarer Chip. Gesten-Konflikt mit der scrollbaren Formular-Liste (wichtigster Teil dieser Aenderung): Die Chips sitzen in einer vertikal scrollbaren ListView (elf Felder in Gruppen passen nicht auf einen Bildschirm). Ein GestureDetector.onPan* auf dem Chip verlor dabei auf einem echten Geraet durchgehend gegen die eigene Scroll-Geste der ListView - onPanStart/ onPanUpdate feuerten nie, weder fuer vertikale noch horizontale Zuege (mit Debug-Prints auf dem Pixel_10a-Emulator verifiziert). Behoben durch: - SwipeValueInput nutzt jetzt rohe Pointer-Events (Listener statt GestureDetector.onPan*) - diese werden unabhaengig vom Gesture-Arena- Ausgang immer zugestellt. - Ein neuer onDragActiveChanged-Callback informiert das Elternwidget, solange eine Geste aktiv ist; DroneProfileEditor sperrt darueber die ListView (NeverScrollableScrollPhysics) fuer die Dauer des Ziehens, so dass die Liste waehrenddessen nicht mitscrollt. Getestet: 7 Widget-Tests fuer SwipeValueInput (grosse/kleine Schritte, Richtungswechsel, Klemmen auf min/max, Rundung auf Nachkommastellen, "modified"-Punkt, sowie ein Regressionstest fuer exakt dieses Szenario - Chip innerhalb einer scrollbaren ListView). Alle 117 Tests sowie flutter analyze bestehen. Manuell auf dem Pixel_10a-Emulator verifiziert: vertikaler Zug auf "Min (stall)" aendert den Wert in 10er-Schritten ohne die Liste zu scrollen, horizontaler Zug auf "Cruise" in 1er-Schritten - beide mit korrekt aufleuchtendem "modified"-Indikator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c5a8c68a8e
commit
f25c312697
@@ -4,11 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../domain/mission/drone_profile.dart';
|
import '../../domain/mission/drone_profile.dart';
|
||||||
import '../providers/active_drone_profile_provider.dart';
|
import '../providers/active_drone_profile_provider.dart';
|
||||||
import '../providers/database_provider.dart';
|
import '../providers/database_provider.dart';
|
||||||
|
import 'swipe_value_input.dart';
|
||||||
|
|
||||||
/// Formular zum Anlegen/Bearbeiten eines Drohnenprofils (Doku 3.5/7.3).
|
/// Formular zum Anlegen/Bearbeiten eines Drohnenprofils (Doku 3.5/7.3).
|
||||||
/// Vereinfacht gegenueber dem HTML-Demonstrator: normale Zahlenfelder statt
|
/// Numerische Felder als Swipe-Chips (HTML-Demonstrator: SwipeValueInput) -
|
||||||
/// Swipe-Gesten-Chips (SwipeValueInput) - bei elf Feldern in Gruppen ist ein
|
/// vertikales Ziehen aendert den Wert in grossen Schritten, horizontales
|
||||||
/// Formular auf einem Mobilgeraet die zugaenglichere Eingabemethode.
|
/// Ziehen in kleinen Schritten; nur der Name bleibt ein normales Textfeld.
|
||||||
class DroneProfileEditor extends ConsumerStatefulWidget {
|
class DroneProfileEditor extends ConsumerStatefulWidget {
|
||||||
const DroneProfileEditor({super.key, this.existing});
|
const DroneProfileEditor({super.key, this.existing});
|
||||||
|
|
||||||
@@ -22,38 +23,46 @@ class DroneProfileEditor extends ConsumerStatefulWidget {
|
|||||||
class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
late final TextEditingController _nameController;
|
late final TextEditingController _nameController;
|
||||||
late final Map<String, TextEditingController> _fieldControllers;
|
late final Map<String, double> _initialValues;
|
||||||
|
late Map<String, double> _fieldValues;
|
||||||
|
|
||||||
|
// Waehrend eine Swipe-Geste auf einem Chip aktiv ist, wird das Formular
|
||||||
|
// nicht scrollbar (Doku 3.5) - sonst gewinnt die ListView-eigene
|
||||||
|
// Scroll-Geste das Gesture-Arena gegen den Chip, siehe SwipeValueInput.
|
||||||
|
bool _scrolling = true;
|
||||||
|
|
||||||
|
// Min/Max/Schrittweiten 1:1 aus DRONE_EDITOR_GROUPS im HTML-Demonstrator
|
||||||
|
// uebernommen.
|
||||||
static const _groups = [
|
static const _groups = [
|
||||||
(
|
(
|
||||||
title: 'Speed (m/s)',
|
title: 'Speed (m/s)',
|
||||||
fields: [
|
fields: [
|
||||||
('minSpeed', 'Min (stall)'),
|
(key: 'minSpeed', label: 'Min (stall)', min: 3.0, max: 40.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
||||||
('cruiseSpeed', 'Cruise'),
|
(key: 'cruiseSpeed', label: 'Cruise', min: 3.0, max: 50.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
||||||
('maxSpeed', 'Max'),
|
(key: 'maxSpeed', label: 'Max', min: 5.0, max: 80.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm/s'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
title: 'Radii (m)',
|
title: 'Radii (m)',
|
||||||
fields: [
|
fields: [
|
||||||
('minTurnRadius', 'Min turn'),
|
(key: 'minTurnRadius', label: 'Min turn', min: 5.0, max: 500.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm'),
|
||||||
('loiterRadius', 'Loiter'),
|
(key: 'loiterRadius', label: 'Loiter', min: 20.0, max: 1000.0, stepBig: 10.0, stepSmall: 5.0, decimals: 0, unit: 'm'),
|
||||||
('catchRadius', 'Waypoint catch'),
|
(key: 'catchRadius', label: 'Waypoint catch', min: 5.0, max: 500.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'm'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
title: 'Climb / Descent (m/s)',
|
title: 'Climb / Descent (m/s)',
|
||||||
fields: [
|
fields: [
|
||||||
('maxClimbRate', 'Max climb'),
|
(key: 'maxClimbRate', label: 'Max climb', min: 0.5, max: 20.0, stepBig: 1.0, stepSmall: 0.5, decimals: 1, unit: 'm/s'),
|
||||||
('maxDescentRate', 'Max descent'),
|
(key: 'maxDescentRate', label: 'Max descent', min: 0.5, max: 20.0, stepBig: 1.0, stepSmall: 0.5, decimals: 1, unit: 'm/s'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
title: 'Limits',
|
title: 'Limits',
|
||||||
fields: [
|
fields: [
|
||||||
('maxRangeM', 'Range (m)'),
|
(key: 'maxRangeM', label: 'Range', min: 500.0, max: 200000.0, stepBig: 1000.0, stepSmall: 100.0, decimals: 0, unit: 'm'),
|
||||||
('maxEnduranceMin', 'Endurance (min)'),
|
(key: 'maxEnduranceMin', label: 'Endurance', min: 5.0, max: 600.0, stepBig: 10.0, stepSmall: 1.0, decimals: 0, unit: 'min'),
|
||||||
('maxAltitudeM', 'Ceiling (m)'),
|
(key: 'maxAltitudeM', label: 'Ceiling', min: 50.0, max: 10000.0, stepBig: 100.0, stepSmall: 10.0, decimals: 0, unit: 'm'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -80,25 +89,22 @@ class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
|||||||
_nameController = TextEditingController(
|
_nameController = TextEditingController(
|
||||||
text: widget.existing != null ? base.name : 'New profile',
|
text: widget.existing != null ? base.name : 'New profile',
|
||||||
);
|
);
|
||||||
_fieldControllers = {
|
_initialValues = {
|
||||||
for (final group in _groups)
|
for (final group in _groups)
|
||||||
for (final (key, _) in group.fields)
|
for (final field in group.fields) field.key: _fieldValue(base, field.key),
|
||||||
key: TextEditingController(text: _fieldValue(base, key).toString()),
|
|
||||||
};
|
};
|
||||||
|
_fieldValues = Map.of(_initialValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
for (final c in _fieldControllers.values) {
|
|
||||||
c.dispose();
|
|
||||||
}
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
double v(String key) => double.parse(_fieldControllers[key]!.text);
|
double v(String key) => _fieldValues[key]!;
|
||||||
|
|
||||||
final profile = DroneProfile(
|
final profile = DroneProfile(
|
||||||
id: widget.existing?.id,
|
id: widget.existing?.id,
|
||||||
@@ -160,6 +166,9 @@ class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
|||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
physics: _scrolling
|
||||||
|
? const ClampingScrollPhysics()
|
||||||
|
: const NeverScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -181,20 +190,26 @@ class _DroneProfileEditorState extends ConsumerState<DroneProfileEditor> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (final (key, label) in group.fields)
|
for (final field in group.fields)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: TextFormField(
|
child: SwipeValueInput(
|
||||||
controller: _fieldControllers[key],
|
label: field.label,
|
||||||
style: const TextStyle(color: Colors.white),
|
value: _fieldValues[field.key]!,
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
initialValue: _initialValues[field.key]!,
|
||||||
decimal: true,
|
min: field.min,
|
||||||
),
|
max: field.max,
|
||||||
decoration: InputDecoration(labelText: label),
|
stepBig: field.stepBig,
|
||||||
validator: (v) =>
|
stepSmall: field.stepSmall,
|
||||||
double.tryParse(v ?? '') == null ? 'Number' : null,
|
decimals: field.decimals,
|
||||||
|
unit: field.unit,
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _fieldValues[field.key] = v),
|
||||||
|
onDragActiveChanged: (active) =>
|
||||||
|
setState(() => _scrolling = !active),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum _DragAxis { vertical, horizontal }
|
||||||
|
|
||||||
|
/// Tastaturloser Zahlen-Eingabe-Chip mit richtungsabhaengiger Schrittweite
|
||||||
|
/// (Doku 3.5, HTML-Demonstrator: SwipeValueInput) - vertikales Ziehen
|
||||||
|
/// aendert den Wert in grossen Schritten ([stepBig]), horizontales Ziehen
|
||||||
|
/// in kleinen Schritten ([stepSmall]). Die Zugrichtung wird beim ersten
|
||||||
|
/// Ueberschreiten von [_axisLockThreshold] einmalig festgelegt und bleibt
|
||||||
|
/// fuer den Rest der Geste gesperrt - eine leicht schraege Geste springt
|
||||||
|
/// so nicht zwischen grossem und kleinem Schritt hin und her.
|
||||||
|
class SwipeValueInput extends StatefulWidget {
|
||||||
|
const SwipeValueInput({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.initialValue,
|
||||||
|
required this.min,
|
||||||
|
required this.max,
|
||||||
|
required this.stepBig,
|
||||||
|
required this.stepSmall,
|
||||||
|
this.decimals = 0,
|
||||||
|
required this.unit,
|
||||||
|
required this.onChanged,
|
||||||
|
this.onDragActiveChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
|
||||||
|
/// Ausgangswert beim Oeffnen des Editors - weicht [value] davon ab,
|
||||||
|
/// zeigt der Chip einen kleinen Punkt neben dem Label (HTML-
|
||||||
|
/// Demonstrator: .modified-dot), analog dem gelben "chip-modified"-Rand.
|
||||||
|
final double initialValue;
|
||||||
|
final double min;
|
||||||
|
final double max;
|
||||||
|
final double stepBig;
|
||||||
|
final double stepSmall;
|
||||||
|
final int decimals;
|
||||||
|
final String unit;
|
||||||
|
final ValueChanged<double> onChanged;
|
||||||
|
|
||||||
|
/// Wird true, solange eine Geste auf diesem Chip aktiv ist - der
|
||||||
|
/// Elternwidget nutzt dies, um eine umgebende scrollbare Liste waehrend
|
||||||
|
/// des Ziehens zu sperren (sonst gewinnt deren eigene Scroll-Geste das
|
||||||
|
/// Gesture-Arena gegen den Chip, siehe DroneProfileEditor).
|
||||||
|
final ValueChanged<bool>? onDragActiveChanged;
|
||||||
|
|
||||||
|
static const _pxPerStep = 14.0;
|
||||||
|
static const _axisLockThreshold = 8.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SwipeValueInput> createState() => _SwipeValueInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwipeValueInputState extends State<SwipeValueInput> {
|
||||||
|
Offset? _dragStart;
|
||||||
|
double _dragStartValue = 0;
|
||||||
|
_DragAxis? _axis;
|
||||||
|
int? _activePointer;
|
||||||
|
bool _flash = false;
|
||||||
|
Timer? _flashTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_flashTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rohe Pointer-Events statt GestureDetector.onPan* (Doku 3.5): der Chip
|
||||||
|
// sitzt in einer vertikal scrollbaren Liste (Formular mit vielen
|
||||||
|
// Feldern), deren eigene Scroll-Geste im Gesture-Arena sonst gegen ein
|
||||||
|
// GestureDetector-Pan gewinnt - Listener-Events werden unabhaengig davon
|
||||||
|
// immer zugestellt, dafuer sperrt onDragActiveChanged() die Liste
|
||||||
|
// explizit waehrend des Ziehens (siehe DroneProfileEditor).
|
||||||
|
void _onPointerDown(PointerDownEvent event) {
|
||||||
|
_activePointer = event.pointer;
|
||||||
|
_dragStart = event.position;
|
||||||
|
_dragStartValue = widget.value;
|
||||||
|
_axis = null;
|
||||||
|
widget.onDragActiveChanged?.call(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerMove(PointerMoveEvent event) {
|
||||||
|
if (event.pointer != _activePointer) return;
|
||||||
|
final start = _dragStart;
|
||||||
|
if (start == null) return;
|
||||||
|
final dx = event.position.dx - start.dx;
|
||||||
|
final dy = event.position.dy - start.dy;
|
||||||
|
|
||||||
|
if (_axis == null) {
|
||||||
|
if (dx.abs() < SwipeValueInput._axisLockThreshold &&
|
||||||
|
dy.abs() < SwipeValueInput._axisLockThreshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_axis = dy.abs() >= dx.abs() ? _DragAxis.vertical : _DragAxis.horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stepSize = _axis == _DragAxis.vertical ? widget.stepBig : widget.stepSmall;
|
||||||
|
final travel = _axis == _DragAxis.vertical ? -dy : dx;
|
||||||
|
final steps = (travel / SwipeValueInput._pxPerStep).round();
|
||||||
|
var val = _dragStartValue + steps * stepSize;
|
||||||
|
val = double.parse(val.toStringAsFixed(widget.decimals));
|
||||||
|
val = val.clamp(widget.min, widget.max);
|
||||||
|
if (val != widget.value) {
|
||||||
|
widget.onChanged(val);
|
||||||
|
_flashTimer?.cancel();
|
||||||
|
setState(() => _flash = true);
|
||||||
|
_flashTimer = Timer(const Duration(milliseconds: 120), () {
|
||||||
|
if (mounted) setState(() => _flash = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerUp(PointerEvent event) {
|
||||||
|
if (event.pointer != _activePointer) return;
|
||||||
|
_endDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _endDrag() {
|
||||||
|
_dragStart = null;
|
||||||
|
_axis = null;
|
||||||
|
_activePointer = null;
|
||||||
|
widget.onDragActiveChanged?.call(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final modified = widget.value != widget.initialValue;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(widget.label, style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: modified ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
child: Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Color(0xFFF0C86E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Listener(
|
||||||
|
onPointerDown: _onPointerDown,
|
||||||
|
onPointerMove: _onPointerMove,
|
||||||
|
onPointerUp: _onPointerUp,
|
||||||
|
onPointerCancel: _onPointerUp,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _flash
|
||||||
|
? const Color(0xFF4D9DFF).withValues(alpha: 0.4)
|
||||||
|
: modified
|
||||||
|
? const Color(0xFFC8A04D).withValues(alpha: 0.16)
|
||||||
|
: Colors.black.withValues(alpha: 0.85),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _flash
|
||||||
|
? const Color(0xFF8FD0FF)
|
||||||
|
: modified
|
||||||
|
? const Color(0xFFF0C86E).withValues(alpha: 0.85)
|
||||||
|
: const Color(0xFF4D9DFF).withValues(alpha: 0.7),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'↕↔',
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 10),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
'${widget.value.toStringAsFixed(widget.decimals)} ${widget.unit}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:dmc_app/ui/widgets/swipe_value_input.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Widget wrap({
|
||||||
|
required double value,
|
||||||
|
required double initialValue,
|
||||||
|
required void Function(double) onChanged,
|
||||||
|
double min = 0,
|
||||||
|
double max = 200,
|
||||||
|
double stepBig = 10,
|
||||||
|
double stepSmall = 1,
|
||||||
|
int decimals = 0,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SwipeValueInput(
|
||||||
|
label: 'Test',
|
||||||
|
value: value,
|
||||||
|
initialValue: initialValue,
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
stepBig: stepBig,
|
||||||
|
stepSmall: stepSmall,
|
||||||
|
decimals: decimals,
|
||||||
|
unit: 'm',
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('vertikales Ziehen aendert den Wert in grossen Schritten (stepBig)',
|
||||||
|
(tester) async {
|
||||||
|
final changes = <double>[];
|
||||||
|
await tester.pumpWidget(wrap(value: 50, initialValue: 50, onChanged: changes.add));
|
||||||
|
|
||||||
|
// 3 Schritte a 14px nach oben -> +3*stepBig(10) = +30.
|
||||||
|
await tester.drag(find.byType(SwipeValueInput), const Offset(0, -42));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(changes, isNotEmpty);
|
||||||
|
expect(changes.last, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('horizontales Ziehen aendert den Wert in kleinen Schritten (stepSmall)',
|
||||||
|
(tester) async {
|
||||||
|
final changes = <double>[];
|
||||||
|
await tester.pumpWidget(wrap(value: 50, initialValue: 50, onChanged: changes.add));
|
||||||
|
|
||||||
|
// 3 Schritte a 14px nach rechts -> +3*stepSmall(1) = +3.
|
||||||
|
await tester.drag(find.byType(SwipeValueInput), const Offset(42, 0));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(changes, isNotEmpty);
|
||||||
|
expect(changes.last, 53);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('nach links/unten ziehen verringert den Wert', (tester) async {
|
||||||
|
final changes = <double>[];
|
||||||
|
await tester.pumpWidget(wrap(value: 50, initialValue: 50, onChanged: changes.add));
|
||||||
|
|
||||||
|
await tester.drag(find.byType(SwipeValueInput), const Offset(0, 42));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(changes.last, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('klemmt auf max, wenn die grosse Schrittweite darueber hinausschiesst',
|
||||||
|
(tester) async {
|
||||||
|
final changes = <double>[];
|
||||||
|
await tester.pumpWidget(wrap(value: 190, initialValue: 190, onChanged: changes.add));
|
||||||
|
|
||||||
|
await tester.drag(find.byType(SwipeValueInput), const Offset(0, -140));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(changes.last, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('rundet auf die konfigurierte Nachkommastellenzahl', (tester) async {
|
||||||
|
final changes = <double>[];
|
||||||
|
await tester.pumpWidget(wrap(
|
||||||
|
value: 1.0,
|
||||||
|
initialValue: 1.0,
|
||||||
|
stepSmall: 0.5,
|
||||||
|
decimals: 1,
|
||||||
|
onChanged: changes.add,
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.drag(find.byType(SwipeValueInput), const Offset(14, 0));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(changes.last, 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'aendert den Wert auch innerhalb einer scrollbaren Liste (Regression: '
|
||||||
|
'GestureDetector.onPan* verlor frueher gegen die Scroll-Geste der Liste)',
|
||||||
|
(tester) async {
|
||||||
|
final changes = <double>[];
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 400),
|
||||||
|
SwipeValueInput(
|
||||||
|
label: 'Test',
|
||||||
|
value: 50,
|
||||||
|
initialValue: 50,
|
||||||
|
min: 0,
|
||||||
|
max: 200,
|
||||||
|
stepBig: 10,
|
||||||
|
stepSmall: 1,
|
||||||
|
unit: 'm',
|
||||||
|
onChanged: changes.add,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 400),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.drag(find.byType(SwipeValueInput), const Offset(0, -42));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(changes, isNotEmpty);
|
||||||
|
expect(changes.last, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('zeigt den "modified"-Punkt nur, wenn der Wert vom Ausgangswert abweicht',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(wrap(value: 50, initialValue: 50, onChanged: (_) {}));
|
||||||
|
var opacity = tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity));
|
||||||
|
expect(opacity.opacity, 0);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(value: 60, initialValue: 50, onChanged: (_) {}));
|
||||||
|
opacity = tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity));
|
||||||
|
expect(opacity.opacity, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user