Rebuild the Flutter/DJI-MSDK-V4 Fly App to the v2 UI kit and wire the full SDK surface behind it. Native (Kotlin): split DjiSdkBridge into a method/event router delegating to per-subsystem SubBridge helpers sharing a BridgeCtx — FlightController (takeoff/land/RTH + rich telemetry), Camera (mode/record/photo/exposure), Gimbal, Mission (Waypoint + ActiveTrack; QuickShots via ActiveTrack QUICK_SHOT), Media (MediaManager list/thumbnail/download), and optional DJI account login. Manifest gains scoped media permissions. Flutter: ten screens under lib/ui/ (Flight HUD, capture modes, camera settings, settings menu, map+waypoints, home, album, academy, profile, routes/flight logs), driven by an expanded FlightModel. New PVIcon renders the kit's SVG paths via flutter_svg; map uses flutter_map + latlong2. Pin transitive androidx.core/browser down to SDK-35-compatible versions so the newer plugins don't force AGP 8.9.1 onto the DJI toolchain. Verified with `flutter build apk --debug` (compiles Dart + all Kotlin); runtime behaviour is untested here — it needs a physical DJI-connected device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
562 lines
21 KiB
Dart
562 lines
21 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../dji_service.dart';
|
||
import '../flight_model.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;
|
||
}
|
||
|
||
void _onGimbalDrag(double frac) {
|
||
setState(() => _gimbalFrac = frac.clamp(0.0, 1.0));
|
||
final double pitch = 30 - _gimbalFrac * 120; // +30 (top) … −90 (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 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: _gimbalSlider()),
|
||
Positioned(right: 16, top: 0, bottom: 0, child: Center(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),
|
||
_pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)),
|
||
const SizedBox(width: 8),
|
||
_pill(child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
const PVIcon('radio', size: 14, color: Glass.ink),
|
||
const SizedBox(width: 4),
|
||
_mono('HD'),
|
||
])),
|
||
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;
|
||
}
|