import 'package:flutter/material.dart'; import '../../../../dji_service.dart'; import '../../../../fly/camera_menu.dart'; import '../../../../fly/product_profile.dart'; import '../../../../fly/state/camera_state.dart'; import '../../../../theme.dart'; import '../../dialogs/slide_to_confirm.dart'; import '../../hud_kit.dart'; import '../camera_write.dart'; /// Renders one [CameraRow] spec (plan P4's row kit: choice, switch, stepper, /// action). Writes go through [writeCameraSetting], so every row shows its /// pending value at once and falls back to what the camera reports. class CameraRowTile extends StatelessWidget { const CameraRowTile({ super.key, required this.row, required this.profile, required this.camera, required this.dji, required this.onError, }); final CameraRow row; final ProductProfile profile; final CameraState camera; final DjiService dji; final void Function(String label, Object error) onError; @override Widget build(BuildContext context) { final CameraRow r = row; return switch (r) { ChoiceRow() => ChoiceTile( key: ValueKey('row-${r.id}'), label: r.label, options: <(String, String)>[for (final CameraOption o in r.options(profile, camera)) (o.value, o.label)], selected: r.current(camera), pending: r.writes.any(camera.isPending), onSelect: (String v) => writeCameraSetting( camera, label: r.label, pending: r.pending(v), send: () => r.apply(dji, camera, v), onError: onError, ), ), SwitchRow() => SwitchTile( key: ValueKey('row-${r.id}'), label: r.label, value: r.current(camera), onChanged: (bool on) => writeCameraSetting( camera, label: r.label, pending: {r.id: on}, send: () => r.apply(dji, on), onError: onError, ), ), StepperRow() => _StepperTile( key: ValueKey('row-${r.id}'), row: r, value: r.current(camera), onChanged: (int v) => writeCameraSetting( camera, label: r.label, pending: {r.id: v}, send: () => r.apply(dji, camera, v), onError: onError, ), ), ActionRow() => _ActionTile(key: ValueKey('row-${r.id}'), row: r, camera: camera, dji: dji, onError: onError), }; } } /// A row that shows its current choice and expands into chips. Also used for /// the menu's device-local display settings. class ChoiceTile extends StatefulWidget { const ChoiceTile({ super.key, required this.label, required this.options, required this.selected, required this.onSelect, this.pending = false, this.hint, }); final String label; final String? hint; /// (value, label) pairs. final List<(String, String)> options; final String? selected; final ValueChanged onSelect; /// A write is waiting for the camera's answer. final bool pending; @override State createState() => _ChoiceTileState(); } class _ChoiceTileState extends State { bool _open = false; @override Widget build(BuildContext context) { String current = '—'; for (final (String v, String l) in widget.options) { if (v == widget.selected) current = l; } return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _RowFrame( label: widget.label, hint: widget.hint, onTap: widget.options.isEmpty ? null : () => setState(() => _open = !_open), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ if (widget.pending) const Padding( padding: EdgeInsets.only(right: 6), child: SizedBox(width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 1.5, color: HudText.dim)), ), Text(current, style: HudText.mono.copyWith(color: Glass.accent, fontWeight: FontWeight.w700)), const SizedBox(width: 4), _Chevron(open: _open), ]), ), if (_open) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), child: Wrap(spacing: 6, runSpacing: 6, children: [ for (final (String v, String l) in widget.options) GestureDetector( key: ValueKey('opt-$v'), onTap: () { setState(() => _open = false); if (v != widget.selected) widget.onSelect(v); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: v == widget.selected ? Glass.accent : Glass.pill, borderRadius: BorderRadius.circular(7), border: Border.all(color: v == widget.selected ? Glass.accent : Glass.hairline), ), child: Text(l, style: HudText.sans), ), ), ]), ), ]); } } class _Chevron extends StatelessWidget { const _Chevron({required this.open}); final bool open; @override Widget build(BuildContext context) => CustomPaint(size: const Size(10, 10), painter: _ChevronPainter(open)); } class _ChevronPainter extends CustomPainter { const _ChevronPainter(this.open); final bool open; @override void paint(Canvas canvas, Size size) { final Path p = open ? (Path() ..moveTo(1, 7) ..lineTo(5, 3) ..lineTo(9, 7)) : (Path() ..moveTo(1, 3) ..lineTo(5, 7) ..lineTo(9, 3)); canvas.drawPath( p, Paint() ..color = HudText.dim ..style = PaintingStyle.stroke ..strokeWidth = 1.4, ); } @override bool shouldRepaint(covariant _ChevronPainter old) => old.open != open; } class _RowFrame extends StatelessWidget { const _RowFrame({required this.label, required this.trailing, this.onTap, this.hint}); final String label; final Widget trailing; final VoidCallback? onTap; final String? hint; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 44), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row(children: [ Expanded( child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: HudText.sans.copyWith(fontSize: 13)), if (hint != null) ...[ const SizedBox(height: 2), Text(hint!, style: HudText.label), ], ]), ), trailing, ]), ), ), ); } } /// An on / off row; a null [value] (not reported yet) shows as off. class SwitchTile extends StatelessWidget { const SwitchTile({super.key, required this.label, required this.value, required this.onChanged, this.hint, this.pending = false}); final String label; final bool? value; final ValueChanged onChanged; final String? hint; /// A write is waiting for the component's answer. final bool pending; @override Widget build(BuildContext context) => _RowFrame( label: label, hint: hint, onTap: () => onChanged(!(value ?? false)), trailing: SizedBox( height: 24, child: Switch( value: value ?? false, onChanged: onChanged, activeThumbColor: Colors.white, activeTrackColor: pending ? HudText.dim : Glass.accent, inactiveThumbColor: HudText.dim, inactiveTrackColor: HudColors.off, ), ), ); } /// A device-local on/off (display settings), with the same look as camera rows. class PrefSwitchTile extends StatelessWidget { const PrefSwitchTile({super.key, required this.label, required this.value, required this.onChanged}); final String label; final bool value; final ValueChanged onChanged; @override Widget build(BuildContext context) => SwitchTile(label: label, value: value, onChanged: onChanged); } class _StepperTile extends StatelessWidget { const _StepperTile({super.key, required this.row, required this.value, required this.onChanged}); final StepperRow row; final int? value; final ValueChanged onChanged; @override Widget build(BuildContext context) { final int? v = value; bool can(int delta) => v != null && (v + delta) >= row.min && (v + delta) <= row.max; return StepperTile( id: row.id, label: row.label, shown: v == null ? '—' : '${v > 0 && row.min < 0 ? '+' : ''}$v${row.unit.isEmpty ? '' : ' ${row.unit}'}', canDown: can(-row.step), canUp: can(row.step), onStep: (int dir) => onChanged(v! + dir * row.step), ); } } /// A − value + row. [onStep] gets −1 or +1; the caller owns the arithmetic, /// so it fits integer settings and relative nudges (gimbal fine-tune) alike. class StepperTile extends StatelessWidget { const StepperTile({ super.key, required this.id, required this.label, required this.shown, required this.canDown, required this.canUp, required this.onStep, this.hint, this.pending = false, this.onValueTap, }); /// Keys the buttons as `step--minus` / `step--plus`. final String id; final String label; final String shown; final bool canDown; final bool canUp; final ValueChanged onStep; final String? hint; final bool pending; /// Tapping the value (keyed `value-`) — to type a far-off number. final VoidCallback? onValueTap; @override Widget build(BuildContext context) { Widget step(String label, int dir) { final bool enabled = dir > 0 ? canUp : canDown; return Opacity( opacity: enabled ? 1 : 0.35, child: GestureDetector( key: ValueKey('step-$id-${dir > 0 ? 'plus' : 'minus'}'), behavior: HitTestBehavior.opaque, onTap: enabled ? () => onStep(dir) : null, child: Container( width: 30, height: 28, decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(7), border: Border.all(color: Glass.hairline)), alignment: Alignment.center, child: Text(label, style: HudText.value), ), ), ); } return _RowFrame( label: label, hint: hint, trailing: Row(mainAxisSize: MainAxisSize.min, children: [ step('−', -1), GestureDetector( key: ValueKey('value-$id'), behavior: HitTestBehavior.opaque, onTap: onValueTap, child: SizedBox( width: 66, height: 28, child: Center( child: Text( shown, textAlign: TextAlign.center, style: HudText.mono.copyWith(color: pending ? HudText.dim : Glass.accent, fontWeight: FontWeight.w700), ), ), ), ), step('+', 1), ]), ); } } class _ActionTile extends StatelessWidget { const _ActionTile({super.key, required this.row, required this.camera, required this.dji, required this.onError}); final ActionRow row; final CameraState camera; final DjiService dji; final void Function(String label, Object error) onError; Future _run(BuildContext context) async { if (row.confirmTitle != null) { final SlideResult? ok = await showSlideToConfirm( context, SlideConfirmSpec( icon: row.danger ? 'sdcard' : 'aperture', title: row.confirmTitle!, description: row.confirmText ?? '', sliderLabel: row.danger ? 'Slide to erase' : 'Slide to confirm', color: row.danger ? HudColors.danger : Glass.accent, ), ); if (ok == null) return; } try { await row.run(dji); } catch (e) { onError(row.label, e); } } @override Widget build(BuildContext context) { final String? blocked = row.blockedReason(camera); return ButtonTile( id: row.id, label: row.label, button: row.button, hint: blocked, danger: row.danger, onTap: blocked == null ? () => _run(context) : null, ); } } /// A read-only label + value row. class ValueTile extends StatelessWidget { const ValueTile({super.key, required this.label, required this.value, this.hint, this.onTap, this.valueColor}); final String label; final String value; final String? hint; final VoidCallback? onTap; final Color? valueColor; @override Widget build(BuildContext context) => _RowFrame( label: label, hint: hint, onTap: onTap, trailing: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 200), child: Text( value, textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, maxLines: 2, style: HudText.mono.copyWith(color: valueColor ?? (onTap != null ? Glass.accent : HudText.dim)), ), ), ); } /// A row with one outlined button, keyed `action-`; a null [onTap] greys it out. class ButtonTile extends StatelessWidget { const ButtonTile({ super.key, required this.id, required this.label, required this.button, required this.onTap, this.hint, this.danger = false, }); final String id; final String label; final String button; final VoidCallback? onTap; final String? hint; final bool danger; @override Widget build(BuildContext context) { final Color color = danger ? HudColors.danger : Glass.accent; return _RowFrame( label: label, hint: hint, trailing: Opacity( opacity: onTap != null ? 1 : 0.35, child: GestureDetector( key: ValueKey('action-$id'), onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration(borderRadius: BorderRadius.circular(7), border: Border.all(color: color)), child: Text(button, style: HudText.sans.copyWith(color: color)), ), ), ), ); } }