The GO 4-parity HUD has to work on every aircraft MSDK V4 drives, so no screen may assume one model. This adds the capability layer the rest of the plan builds on (P0). ProductProfileBridge reads what the SDK can report at runtime (cameras and their ISO/shutter/aperture/EV ranges, gimbal axes, air link, FC and RC feature flags, batteries) and emits it as a productProfile event on connect and component change. Capability ranges stay empty until the camera handshakes, so it re-probes at 3, 10 and 30 s and emits only when something changed. On the Dart side, ProductProfile merges that with a static product_matrix keyed by the MSDK Model enum name, for facts the SDK cannot report: vision sensor layout, battery bay count, intelligent modes. SDK answers win and every fact records its source. HudFeatures lists each capability-gated element with its predicate, and Requires hides a widget the profile rules out. The flight page now gates its gimbal slider, camera column and vision pill, labels the link Wi-Fi or HD, and takes the gimbal pitch range from the profile. Settings > About > Capability profile shows the active profile, fact sources and which features are on. Matrix rows are seeded from published specs until runtime capture confirms them; nothing has run on a real aircraft yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
573 lines
22 KiB
Dart
573 lines
22 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../dji_service.dart';
|
||
import '../flight_model.dart';
|
||
import '../fly/hud_features.dart';
|
||
import '../fly/product_profile.dart';
|
||
import '../theme.dart';
|
||
import 'camera_settings_page.dart';
|
||
import 'capture_modes_page.dart';
|
||
import 'dji_video_view.dart';
|
||
import 'map_page.dart';
|
||
import 'pv_icons.dart';
|
||
import 'settings_menu_page.dart';
|
||
|
||
/// Landscape live-flight overlay — mirrors the v2 "Main flight view" mockup.
|
||
/// Live camera feed behind a glass HUD; real telemetry bound throughout, and
|
||
/// the shutter / mode switch / gimbal slider / RTH / take-off controls issue
|
||
/// real SDK commands via [DjiService].
|
||
class FlightControlPage extends StatefulWidget {
|
||
const FlightControlPage({super.key, required this.model, required this.dji});
|
||
|
||
final FlightModel model;
|
||
final DjiService dji;
|
||
|
||
@override
|
||
State<FlightControlPage> createState() => _FlightControlPageState();
|
||
}
|
||
|
||
class _FlightControlPageState extends State<FlightControlPage> {
|
||
Timer? _recTimer;
|
||
int _recSeconds = 0;
|
||
// Gimbal pitch slider: fraction 0 (top, +30°) … 1 (bottom, −90°).
|
||
double _gimbalFrac = 0.24;
|
||
bool _grid = false;
|
||
|
||
DjiService get _dji => widget.dji;
|
||
FlightModel get _m => widget.model;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
SystemChrome.setPreferredOrientations(<DeviceOrientation>[
|
||
DeviceOrientation.landscapeLeft,
|
||
DeviceOrientation.landscapeRight,
|
||
]);
|
||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_recTimer?.cancel();
|
||
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
|
||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||
super.dispose();
|
||
}
|
||
|
||
String _fmt(int s) => '${(s ~/ 60).toString().padLeft(2, '0')}:${(s % 60).toString().padLeft(2, '0')}';
|
||
|
||
void _snack(String msg) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context)
|
||
..clearSnackBars()
|
||
..showSnackBar(SnackBar(content: Text(msg)));
|
||
}
|
||
|
||
Future<void> _run(String label, Future<void> Function() action) async {
|
||
try {
|
||
await action();
|
||
} catch (e) {
|
||
_snack('$label failed: ${_reason(e)}');
|
||
}
|
||
}
|
||
|
||
String _reason(Object e) => e is PlatformException ? (e.message ?? e.code) : e.toString();
|
||
|
||
// ── Commands ───────────────────────────────────────────────────────────────
|
||
Future<void> _toggleShutter() async {
|
||
if (_m.captureMode == CaptureMode.video) {
|
||
if (_m.isRecording) {
|
||
_stopRecTimer();
|
||
await _run('Stop recording', _dji.stopRecordVideo);
|
||
} else {
|
||
_startRecTimer();
|
||
await _run('Start recording', _dji.startRecordVideo);
|
||
}
|
||
} else {
|
||
await _run('Shoot photo', _dji.startShootPhoto);
|
||
}
|
||
}
|
||
|
||
void _startRecTimer() {
|
||
_recSeconds = 0;
|
||
_recTimer?.cancel();
|
||
_recTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (mounted) setState(() => _recSeconds++);
|
||
});
|
||
}
|
||
|
||
void _stopRecTimer() {
|
||
_recTimer?.cancel();
|
||
_recTimer = null;
|
||
}
|
||
|
||
Future<void> _setCaptureMode(CaptureMode mode) async {
|
||
setState(() => _m.captureMode = mode);
|
||
final String wire = mode == CaptureMode.video ? 'video' : 'photo';
|
||
await _run('Set camera mode', () => _dji.setCameraMode(wire));
|
||
}
|
||
|
||
Future<void> _rth() async {
|
||
final bool ok = await _confirm('Return to Home', 'The aircraft will fly back to its recorded home point and land.');
|
||
if (ok) await _run('Return to Home', _dji.startGoHome);
|
||
}
|
||
|
||
Future<void> _toggleTakeoff() async {
|
||
if (_m.isFlying == true) {
|
||
final bool ok = await _confirm('Land now', 'The aircraft will descend and land at its current position.');
|
||
if (ok) await _run('Land', _dji.land);
|
||
} else {
|
||
final bool ok = await _confirm('Take off', 'The aircraft will take off and hover at ~1.2 m.');
|
||
if (ok) await _run('Take off', _dji.takeOff);
|
||
}
|
||
}
|
||
|
||
Future<bool> _confirm(String title, String body) async {
|
||
final bool? r = await showDialog<bool>(
|
||
context: context,
|
||
builder: (BuildContext ctx) => AlertDialog(
|
||
backgroundColor: const Color(0xFF10203F),
|
||
title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)),
|
||
content: Text(body, style: const TextStyle(color: Color(0xFF8FA0BE), fontFamily: PV.fontSans)),
|
||
actions: <Widget>[
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(title)),
|
||
],
|
||
),
|
||
);
|
||
return r ?? false;
|
||
}
|
||
|
||
/// Fallback pitch range (+30° … −90°) until the profile reports the gimbal's.
|
||
static const AxisRange _defaultPitch = AxisRange(-90, 30);
|
||
|
||
void _onGimbalDrag(double frac) {
|
||
setState(() => _gimbalFrac = frac.clamp(0.0, 1.0));
|
||
final AxisRange r = _m.profile.gimbal?.pitch ?? _defaultPitch;
|
||
final double pitch = r.max - _gimbalFrac * (r.max - r.min); // max (top) … min (bottom)
|
||
_run('Gimbal', () => _dji.rotateGimbalPitch(pitch));
|
||
}
|
||
|
||
void _openCaptureModes() {
|
||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||
builder: (_) => CaptureModesPage(model: _m, dji: _dji),
|
||
));
|
||
}
|
||
|
||
void _openCameraSettings() {
|
||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||
builder: (_) => CameraSettingsPage(model: _m, dji: _dji),
|
||
));
|
||
}
|
||
|
||
void _openSettingsMenu() {
|
||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||
builder: (_) => SettingsMenuPage(model: _m, dji: _dji),
|
||
));
|
||
}
|
||
|
||
void _openMap() {
|
||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||
builder: (_) => MapPage(model: _m, dji: _dji),
|
||
));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFF0A1120),
|
||
body: AnimatedBuilder(
|
||
animation: _m,
|
||
builder: (BuildContext context, _) {
|
||
return ProductProfileScope(
|
||
profile: _m.profile,
|
||
child: Stack(
|
||
children: <Widget>[
|
||
Positioned.fill(
|
||
child: _m.connected
|
||
? const DjiVideoView()
|
||
: const CustomPaint(painter: _FeedPainter()),
|
||
),
|
||
if (_grid) const Positioned.fill(child: IgnorePointer(child: CustomPaint(painter: _GridPainter()))),
|
||
const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))),
|
||
Positioned(top: 12, left: 14, right: 14, child: _topBar()),
|
||
Positioned(left: 14, top: 58, child: _leftRail()),
|
||
Positioned(left: 70, top: 58, bottom: 96, child: Requires(HudFeatures.gimbalPitch, child: _gimbalSlider())),
|
||
Positioned(right: 16, top: 0, bottom: 0, child: Center(child: Requires(HudFeatures.cameraControls, child: _cameraControls()))),
|
||
Positioned(left: 14, bottom: 12, child: _minimap()),
|
||
Positioned(bottom: 14, left: 0, right: 0, child: Center(child: _telemetry())),
|
||
Positioned(right: 92, bottom: 20, child: _rthButton()),
|
||
Positioned(right: 92, bottom: 72, child: _takeoffButton()),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Top bar ──────────────────────────────────────────────────────────────
|
||
Widget _topBar() {
|
||
final String mode = (_m.flightMode != null && _m.flightMode!.isNotEmpty) ? _m.flightMode! : 'N';
|
||
return Row(children: <Widget>[
|
||
GestureDetector(
|
||
onTap: () => Navigator.of(context).maybePop(),
|
||
child: _pill(child: const PVIcon('chevronLeft', size: 16, color: Glass.ink)),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
height: 26,
|
||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||
decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)),
|
||
alignment: Alignment.center,
|
||
child: Text(mode, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white)),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
const PVIcon('satellite', size: 14, color: Glass.sat),
|
||
const SizedBox(width: 4),
|
||
_mono(_m.satellites?.toString() ?? '0'),
|
||
])),
|
||
const SizedBox(width: 8),
|
||
Requires(HudFeatures.visionIndicator, child: Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: _pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)),
|
||
)),
|
||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
const PVIcon('radio', size: 14, color: Glass.ink),
|
||
const SizedBox(width: 4),
|
||
Requires(HudFeatures.wifiSignal, whenDisconnected: false, fallback: _mono('HD'), child: _mono('Wi-Fi')),
|
||
])),
|
||
const Spacer(),
|
||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
_mono('REC'),
|
||
const SizedBox(width: 6),
|
||
Container(width: 7, height: 7, decoration: BoxDecoration(color: _m.isRecording ? Glass.rec : const Color(0x66D64545), shape: BoxShape.circle)),
|
||
const SizedBox(width: 6),
|
||
_mono(_fmt(_m.isRecording ? _recSeconds : 0)),
|
||
])),
|
||
const SizedBox(width: 8),
|
||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
const PVIcon('battery', size: 16, color: Glass.sat),
|
||
const SizedBox(width: 4),
|
||
_mono(_m.batteryPercent == null ? '—' : '${_m.batteryPercent}%'),
|
||
])),
|
||
const SizedBox(width: 8),
|
||
GestureDetector(onTap: _openSettingsMenu, child: _pill(child: const PVIcon('more', size: 16, color: Glass.ink))),
|
||
]);
|
||
}
|
||
|
||
Widget _leftRail() {
|
||
return Column(children: <Widget>[
|
||
_sideBtn('gimbal', active: true, onTap: _openCameraSettings),
|
||
const SizedBox(height: 10),
|
||
_sideBtn('sun', onTap: _openCameraSettings),
|
||
const SizedBox(height: 10),
|
||
_sideBtn('aperture', onTap: _openCaptureModes),
|
||
const SizedBox(height: 10),
|
||
_sideBtn('grid', active: _grid, onTap: () => setState(() => _grid = !_grid)),
|
||
]);
|
||
}
|
||
|
||
// ── Reusable glass pieces ────────────────────────────────────────────────
|
||
Widget _pill({required Widget child}) {
|
||
return Container(
|
||
height: 26,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)),
|
||
alignment: Alignment.center,
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink));
|
||
|
||
Widget _sideBtn(String icon, {bool active = false, VoidCallback? onTap}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: 42, height: 42,
|
||
decoration: BoxDecoration(
|
||
color: active ? Glass.accent : Glass.pill,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: Glass.hairline),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: PVIcon(icon, size: 20, stroke: 1.8, color: Glass.ink),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _gimbalSlider() {
|
||
return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
|
||
final double h = c.maxHeight;
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onVerticalDragUpdate: (DragUpdateDetails d) => _onGimbalDrag(d.localPosition.dy / h),
|
||
onTapDown: (TapDownDetails d) => _onGimbalDrag(d.localPosition.dy / h),
|
||
child: SizedBox(
|
||
width: 16,
|
||
child: Stack(children: <Widget>[
|
||
Align(
|
||
alignment: Alignment.topCenter,
|
||
child: Container(width: 6, height: h, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))),
|
||
),
|
||
Positioned(
|
||
top: (_gimbalFrac * h - 8).clamp(0.0, h - 16),
|
||
left: 0,
|
||
child: const _Thumb(),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
Widget _cameraControls() {
|
||
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
GestureDetector(
|
||
onTap: _openCameraSettings,
|
||
child: Container(
|
||
width: 46, height: 46,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: const Color(0x99FFFFFF), width: 2),
|
||
gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[Color(0xFF2A4E86), Color(0xFF12201A)]),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
GestureDetector(
|
||
onTap: _toggleShutter,
|
||
child: Container(
|
||
width: 62, height: 62,
|
||
decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4)),
|
||
child: Center(
|
||
child: _m.captureMode == CaptureMode.video && _m.isRecording
|
||
? Container(width: 24, height: 24, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5)))
|
||
: Container(width: 46, height: 46, decoration: BoxDecoration(color: _m.captureMode == CaptureMode.video ? Glass.rec : Colors.white, shape: BoxShape.circle)),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
Container(
|
||
padding: const EdgeInsets.all(3),
|
||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
|
||
child: Column(children: <Widget>[
|
||
_modeBtn('image', CaptureMode.photo),
|
||
_modeBtn('video', CaptureMode.video),
|
||
_modeBtn('film', CaptureMode.pano),
|
||
]),
|
||
),
|
||
]);
|
||
}
|
||
|
||
Widget _modeBtn(String icon, CaptureMode mode) {
|
||
final bool active = _m.captureMode == mode;
|
||
return GestureDetector(
|
||
onTap: () => _setCaptureMode(mode),
|
||
child: Container(
|
||
width: 40, height: 30,
|
||
margin: const EdgeInsets.symmetric(vertical: 1),
|
||
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)),
|
||
alignment: Alignment.center,
|
||
child: PVIcon(icon, size: 17, stroke: 1.8, color: Glass.ink),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _minimap() {
|
||
final String rth = _m.homeDistance == null ? 'RTH —' : 'RTH ${_m.homeDistance!.toStringAsFixed(0)}m';
|
||
return GestureDetector(
|
||
onTap: _openMap,
|
||
child: Container(
|
||
width: 148, height: 78,
|
||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline)),
|
||
child: Stack(children: <Widget>[
|
||
const Positioned.fill(child: CustomPaint(painter: _MinimapPainter())),
|
||
Positioned(top: 6, left: 8, child: Row(children: <Widget>[
|
||
const PVIcon('home', size: 12, color: Glass.ink),
|
||
const SizedBox(width: 5),
|
||
Text(rth, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)),
|
||
])),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _telemetry() {
|
||
String f(double? v, {int d = 1}) => v == null ? '—' : v.toStringAsFixed(d);
|
||
final List<(String, String, String)> fields = <(String, String, String)>[
|
||
('H', f(_m.altitude), 'm'),
|
||
('D', f(_m.homeDistance, d: 0), 'm'),
|
||
('H.S', f(_m.horizontalSpeed), 'm/s'),
|
||
('V.S', f(_m.verticalSpeed), 'm/s'),
|
||
];
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(12)),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
for (int i = 0; i < fields.length; i++) ...<Widget>[
|
||
if (i > 0) const SizedBox(width: 20),
|
||
Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))),
|
||
const SizedBox(height: 2),
|
||
Text.rich(TextSpan(children: <TextSpan>[
|
||
TextSpan(text: fields[i].$2, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)),
|
||
TextSpan(text: ' ${fields[i].$3}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA))),
|
||
])),
|
||
]),
|
||
],
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _rthButton() {
|
||
return GestureDetector(
|
||
onTap: _rth,
|
||
child: Container(
|
||
width: 44, height: 44,
|
||
decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
|
||
alignment: Alignment.center,
|
||
child: const PVIcon('rth', size: 20, color: Glass.ink),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _takeoffButton() {
|
||
final bool flying = _m.isFlying == true;
|
||
return GestureDetector(
|
||
onTap: _toggleTakeoff,
|
||
child: Container(
|
||
width: 44, height: 44,
|
||
decoration: BoxDecoration(color: flying ? const Color(0xE6D64545) : Glass.accent, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))),
|
||
alignment: Alignment.center,
|
||
child: PVIcon(flying ? 'home' : 'takeoff', size: 20, color: Colors.white),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Thumb extends StatelessWidget {
|
||
const _Thumb();
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: 16, height: 16,
|
||
decoration: const BoxDecoration(
|
||
color: Glass.ink, shape: BoxShape.circle,
|
||
boxShadow: <BoxShadow>[BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Rule-of-thirds grid overlay toggled from the left rail.
|
||
class _GridPainter extends CustomPainter {
|
||
const _GridPainter();
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final Paint p = Paint()
|
||
..color = const Color(0x33FFFFFF)
|
||
..strokeWidth = 1;
|
||
for (int i = 1; i < 3; i++) {
|
||
final double x = size.width * i / 3, y = size.height * i / 3;
|
||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), p);
|
||
canvas.drawLine(Offset(0, y), Offset(size.width, y), p);
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _GridPainter oldDelegate) => false;
|
||
}
|
||
|
||
/// Painted placeholder camera feed: graded sky→ground, perspective grid, haze,
|
||
/// distant buildings, and a yellow tracked-subject bracket.
|
||
class _FeedPainter extends CustomPainter {
|
||
const _FeedPainter();
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final double w = size.width, h = size.height;
|
||
final double horizon = h * 0.52;
|
||
|
||
final Rect full = Offset.zero & size;
|
||
final Paint sky = Paint()
|
||
..shader = const LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: <Color>[Color(0xFF2A4E86), Color(0xFF3E6199), Color(0xFF1C2A1E), Color(0xFF0E1710)],
|
||
stops: <double>[0.0, 0.51, 0.54, 1.0],
|
||
).createShader(full);
|
||
canvas.drawRect(full, sky);
|
||
|
||
final Paint haze = Paint()
|
||
..shader = LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: <Color>[const Color(0x808FB4D8), const Color(0x008FB4D8)],
|
||
).createShader(Rect.fromLTWH(0, horizon - 30, w, 60));
|
||
canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze);
|
||
|
||
final Paint bld = Paint()..color = const Color(0xE612201A);
|
||
void building(double x, double y, double bw, double bh) => canvas.drawRect(Rect.fromLTWH(x * w, horizon + y, bw, bh), bld);
|
||
building(0.10, -40, 46, 40);
|
||
building(0.17, -52, 30, 52);
|
||
building(0.74, -46, 54, 46);
|
||
building(0.83, -34, 34, 34);
|
||
|
||
final Paint grid = Paint()
|
||
..color = const Color(0x297FE0B0)
|
||
..strokeWidth = 1;
|
||
for (int i = 1; i <= 5; i++) {
|
||
final double t = i / 5.0;
|
||
final double y = horizon + (h - horizon) * t * t;
|
||
canvas.drawLine(Offset(0, y), Offset(w, y), grid);
|
||
}
|
||
final double vx = w / 2;
|
||
for (int k = -6; k <= 6; k += 2) {
|
||
final double bx = vx + k * (w * 0.16);
|
||
canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid);
|
||
}
|
||
|
||
final Paint subj = Paint()
|
||
..color = Glass.subject
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 2;
|
||
const double bxw = 118, bxh = 82;
|
||
final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh);
|
||
const double c = 14;
|
||
canvas.drawPath(Path()..moveTo(box.left + c, box.top)..lineTo(box.left, box.top)..lineTo(box.left, box.top + c), subj);
|
||
canvas.drawPath(Path()..moveTo(box.right - c, box.top)..lineTo(box.right, box.top)..lineTo(box.right, box.top + c), subj);
|
||
canvas.drawPath(Path()..moveTo(box.left + c, box.bottom)..lineTo(box.left, box.bottom)..lineTo(box.left, box.bottom - c), subj);
|
||
canvas.drawPath(Path()..moveTo(box.right - c, box.bottom)..lineTo(box.right, box.bottom)..lineTo(box.right, box.bottom - c), subj);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _FeedPainter oldDelegate) => false;
|
||
}
|
||
|
||
class _MinimapPainter extends CustomPainter {
|
||
const _MinimapPainter();
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final Path route = Path()
|
||
..moveTo(20, 60)
|
||
..cubicTo(50, 40, 70, 30, 120, 24);
|
||
final Paint line = Paint()
|
||
..color = const Color(0xFF5B93F5)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 2;
|
||
canvas.drawPath(route, line);
|
||
canvas.drawCircle(const Offset(20, 60), 4, Paint()..color = Glass.sat);
|
||
canvas.drawCircle(const Offset(120, 24), 4, Paint()..color = const Color(0xFF5B93F5));
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _MinimapPainter oldDelegate) => false;
|
||
}
|