对接连续任务列表接口和连续任务下发,便捷操作中暂停任务的对接,底部导航tab项的设置根据用户角色显示和设置

This commit is contained in:
2026-11-11 16:51:21 +08:00
parent 47f518f94c
commit 985bcf4b55
54 changed files with 5017 additions and 0 deletions

View File

@@ -0,0 +1,441 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/site_task_plans/domain/entities/site_task_plan.dart';
import 'package:maibu_satabot_v2/features/site_task_plans/presentation/cubit/site_task_plans_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/autonomous/work_ui.dart';
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/widgets/device_confirm_dialog.dart';
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
import 'work_phone_ui.dart';
enum _WorkTab { plans, pool, consecutive }
class AllWorkSheet extends StatefulWidget {
const AllWorkSheet({
super.key,
required this.taskPoolBuilder,
required this.refreshTaskPool,
});
final Widget Function(BuildContext, ScrollController) taskPoolBuilder;
final Future<void> Function() refreshTaskPool;
@override
State<AllWorkSheet> createState() => _AllWorkSheetState();
}
class _AllWorkSheetState extends State<AllWorkSheet> {
final SiteTaskPlansCubit _plans = sl<SiteTaskPlansCubit>();
final List<StreamSubscription<dynamic>> _subscriptions = [];
_WorkTab _tab = _WorkTab.plans;
late (String?, int?) _scope;
bool _confirmingDeletion = false;
(String?, int?) get _currentScope => (
sl<AppUserCubit>().state.user?.userId,
sl<SiteCubit>().state.selectedSite?.id,
);
@override
void initState() {
super.initState();
_scope = _currentScope;
_subscriptions.add(sl<SiteCubit>().stream.listen((_) => _scopeChanged()));
_subscriptions.add(
sl<AppUserCubit>().stream.listen((_) => _scopeChanged()),
);
unawaited(_refreshPlans());
}
void _scopeChanged() {
final scope = _currentScope;
if (!mounted || scope == _scope) return;
setState(() => _scope = scope);
_plans.clear();
unawaited(_refreshPlans());
}
Future<void> _refreshPlans() =>
_plans.load(_scope.$1?.trim().isNotEmpty == true ? _scope.$2 : null);
@override
void dispose() {
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
unawaited(_plans.close());
super.dispose();
}
String _tr(String key) =>
AppLocalizations.of(context).translate('work_v2.all_work.$key');
String _planName(SiteTaskPlan plan) =>
plan.name.isEmpty ? '${_tr('plans')} #${plan.id}' : plan.name;
Future<void> _confirmDelete(SiteTaskPlan plan, int siteId) async {
if (_confirmingDeletion || _plans.state.deletingId != null) return;
final scope = _scope;
_confirmingDeletion = true;
try {
final confirmed = await showDeviceConfirmDialog(
context,
icon: Icons.delete_outline_rounded,
title: _tr('delete_title'),
message: _tr('delete_message').replaceFirst('%s', _planName(plan)),
confirmText: _tr('delete'),
danger: true,
);
if (!confirmed || !mounted || scope != _currentScope) return;
final result = await _plans.delete(siteId: siteId, planId: plan.id);
if (!mounted || scope != _currentScope || result == null) return;
result.fold(
(failure) => CapsuleToast.show(failure.message, showCheck: false),
(_) => CapsuleToast.show(_tr('delete_success')),
);
} finally {
_confirmingDeletion = false;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
minChildSize: 0.4,
maxChildSize: 0.94,
builder: (context, controller) =>
BlocBuilder<SiteTaskPlansCubit, SiteTaskPlansState>(
bloc: _plans,
builder: (context, state) {
final refresh = switch (_tab) {
_WorkTab.plans =>
state.loading || state.deletingId != null
? null
: _refreshPlans,
_WorkTab.pool => widget.refreshTaskPool,
_WorkTab.consecutive => null,
};
return SafeArea(
top: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
_tr('title'),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: kWtNavy,
),
),
),
if (_tab != _WorkTab.consecutive)
IconButton(
tooltip: _tr('refresh'),
onPressed: refresh,
icon: const Icon(
Icons.refresh_rounded,
color: kWtBlue,
),
),
IconButton(
tooltip: l10n.translate('common.close'),
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: WorkFilterRow(
items: [
for (final tab in _WorkTab.values) _tr(tab.name),
],
index: _tab.index,
onChanged: (index) =>
setState(() => _tab = _WorkTab.values[index]),
),
),
const SizedBox(height: 8),
Expanded(
child: switch (_tab) {
_WorkTab.plans => _planList(state, controller),
_WorkTab.pool => widget.taskPoolBuilder(
context,
controller,
),
_WorkTab.consecutive => ListView(
key: const ValueKey('all_work_consecutive'),
controller: controller,
padding: const EdgeInsets.all(16),
children: [
_message(
Icons.playlist_play_rounded,
_tr('consecutive_pending'),
),
],
),
},
),
],
),
);
},
),
);
}
Widget _message(IconData icon, String text, {bool retry = false}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
Icon(icon, size: 32, color: kWtMuted),
const SizedBox(height: 10),
Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: kWtGray),
),
if (retry)
TextButton(onPressed: _refreshPlans, child: Text(_tr('retry'))),
],
),
);
Widget _planList(SiteTaskPlansState state, ScrollController controller) {
final siteId = state.siteId;
final plans = state.plans;
if (plans.isEmpty || siteId == null) {
return RefreshIndicator(
onRefresh: _refreshPlans,
child: ListView(
key: const ValueKey('all_work_plans_empty'),
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
if (state.loading)
const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
else if (state.error != null)
_message(Icons.error_outline_rounded, state.error!, retry: true)
else if (_scope.$1?.trim().isNotEmpty != true)
_message(
Icons.person_outline_rounded,
AppLocalizations.of(
context,
).translate('work_v2.task_pool.no_user'),
)
else if (siteId == null)
_message(
Icons.location_off_outlined,
AppLocalizations.of(
context,
).translate('work_v2.task_pool.no_site'),
)
else
_message(Icons.event_note_outlined, _tr('empty')),
],
),
);
}
return RefreshIndicator(
onRefresh: _refreshPlans,
child: ListView.builder(
key: const ValueKey('all_work_plans'),
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: plans.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_tr('count').replaceFirst('%s', '${plans.length}'),
style: const TextStyle(fontSize: 12, color: kWtGray),
),
if (state.loading) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(minHeight: 2),
],
if (state.error != null)
_message(
Icons.error_outline_rounded,
state.error!,
retry: true,
),
],
),
);
}
final plan = plans[index - 1];
return _planCard(
plan,
deleting: state.deletingId == plan.id,
onDelete: state.loading || state.deletingId != null
? null
: () => _confirmDelete(plan, siteId),
);
},
),
);
}
String _schedule(SiteTaskPlanRule? rule) {
if (rule == null) return '—';
final l10n = AppLocalizations.of(context);
final type = switch (rule.type) {
'DAY' => l10n.translate('work_v2.plan_create.type_day'),
'WEEK' => l10n.translate('work_v2.plan_create.type_week'),
'MONTH' => l10n.translate('work_v2.plan_create.type_month'),
_ => rule.type,
};
final parts = [
if (type.isNotEmpty) type,
if (rule.type == 'WEEK' && rule.weekdays.isNotEmpty)
rule.weekdays.join(', '),
if (rule.type == 'MONTH' && rule.monthDays.isNotEmpty)
_tr('month_days').replaceFirst('%s', rule.monthDays.join(', ')),
if (rule.time != null) rule.time!,
];
return parts.isEmpty ? '—' : parts.join(' · ');
}
Widget _field(String label, String value) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 84,
child: Text(
label,
style: const TextStyle(fontSize: 12, color: kWtGray),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontSize: 12, color: kWtNavy),
),
),
],
),
);
Widget _planCard(
SiteTaskPlan plan, {
required bool deleting,
VoidCallback? onDelete,
}) {
final l10n = AppLocalizations.of(context);
final device = [
if (plan.deviceAlias != null) plan.deviceAlias!,
if (plan.deviceId.isNotEmpty) plan.deviceId,
].join(' · ');
final status = plan.statusLabel.isNotEmpty
? plan.statusLabel
: plan.status.isNotEmpty
? plan.status
: l10n.translate('work_v2.task_pool.unknown_status');
return Container(
key: ValueKey('site_task_plan_${plan.id}'),
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.fromLTRB(14, 14, 14, 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: kWtBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
_planName(plan),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: kWtNavy,
),
),
),
const SizedBox(width: 8),
Flexible(
child: DecoratedBox(
decoration: BoxDecoration(
color: kWtSoftBlue,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 9,
vertical: 4,
),
child: Text(
status,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: kWtBlue,
),
),
),
),
),
],
),
const SizedBox(height: 4),
_field(_tr('device'), device.isEmpty ? '—' : device),
if (plan.routeId != null) _field(_tr('route'), '#${plan.routeId}'),
_field(_tr('schedule'), _schedule(plan.rule)),
_field(_tr('starts_at'), plan.startsAt ?? '—'),
_field(_tr('ends_at'), plan.endsAt ?? '—'),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onDelete,
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFD54941),
),
icon: deleting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_outline_rounded, size: 18),
label: Text(_tr(deleting ? 'deleting' : 'delete')),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:latlong2/latlong.dart';
import '../../../../../core/localization/app_localizations.dart';
import '../../../../autonomous_work/domain/entities/return_plan_models.dart';
import '../../../device_list/presentation/widgets/autonomous/work_ui.dart';
import '../cubit/return_plan_library_cubit.dart';
import 'return_plan_map_preview.dart';
class ReturnPlanLibrarySection extends StatelessWidget {
const ReturnPlanLibrarySection({
super.key,
required this.cubit,
required this.onReload,
required this.onEdit,
});
final ReturnPlanLibraryCubit cubit;
final Future<void> Function() onReload;
final Future<void> Function() onEdit;
String _tr(BuildContext context, String key) =>
AppLocalizations.of(context).translate('work_v2.route_lib.$key');
String _position(LatLng point) =>
'${point.latitude.toStringAsFixed(7)}, ${point.longitude.toStringAsFixed(7)}';
Widget _card(List<Widget> children) => Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: kWtBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
Widget _heading(String text, IconData icon) => Row(
children: [
Icon(icon, size: 19, color: kWtBlue),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: kWtNavy,
),
),
),
],
);
Widget _text(String text) => Text(
text,
style: const TextStyle(fontSize: 12, color: kWtGray, height: 1.5),
);
Widget _metric(String value, String label) => Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: kWtNavy,
),
),
const SizedBox(height: 4),
_text(label),
],
),
);
void _openMap(BuildContext context, ReturnPlanDraft plan) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => SafeArea(
top: false,
child: SizedBox(
height: MediaQuery.sizeOf(sheetContext).height * 0.8,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Expanded(
child: _heading(
_tr(context, 'return_preview'),
Icons.map_outlined,
),
),
IconButton(
tooltip: AppLocalizations.of(
context,
).translate('common.close'),
onPressed: () => Navigator.of(sheetContext).pop(),
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 8),
Expanded(
child: ReturnPlanMapPreview(plan: plan, interactive: true),
),
const SizedBox(height: 8),
_text(_tr(context, 'return_map_note')),
],
),
),
),
),
);
}
@override
Widget build(
BuildContext context,
) => BlocBuilder<ReturnPlanLibraryCubit, ReturnPlanLibraryState>(
bloc: cubit,
builder: (context, state) {
final plan = state.plan;
if (state.loading && !state.loaded) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
return RefreshIndicator(
onRefresh: onReload,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 12),
children: [
if (state.loading) ...[
const LinearProgressIndicator(minHeight: 2),
const SizedBox(height: 10),
],
if (state.error != null) ...[
_card([
_text(state.error!),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: onReload,
icon: const Icon(Icons.refresh_rounded),
label: Text(_tr(context, 'reload')),
),
),
]),
const SizedBox(height: 12),
],
if (state.loaded && plan == null)
_card([
const Icon(Icons.home_work_outlined, size: 44, color: kWtMuted),
const SizedBox(height: 12),
Text(
_tr(context, 'return_empty'),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: kWtNavy,
),
),
const SizedBox(height: 8),
_text(_tr(context, 'return_empty_hint')),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.add_rounded),
label: Text(_tr(context, 'return_create')),
style: FilledButton.styleFrom(
backgroundColor: kWtBlue,
minimumSize: const Size.fromHeight(46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
]),
if (state.loaded && plan != null) ...[
_card([
_heading(
_tr(context, 'return_plan_title'),
Icons.home_work_outlined,
),
if (plan.id != null) ...[
const SizedBox(height: 4),
_text('${_tr(context, 'return_plan_id')} #${plan.id}'),
],
const SizedBox(height: 14),
Row(
children: [
_metric(
'${plan.restPoints.length}',
_tr(context, 'return_points'),
),
_metric(
'${plan.obstacles.length}',
_tr(context, 'return_obstacles'),
),
_metric(
'${plan.restPoints.map((point) => point.bindDeviceId?.trim()).whereType<String>().where((id) => id.isNotEmpty).toSet().length}',
_tr(context, 'return_bound_devices'),
),
],
),
const SizedBox(height: 12),
_text(_tr(context, 'return_plan_hint')),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined, size: 18),
label: Text(_tr(context, 'return_edit')),
style: FilledButton.styleFrom(
backgroundColor: kWtBlue,
minimumSize: const Size.fromHeight(46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
]),
const SizedBox(height: 12),
if (!plan.isEmpty) ...[
_card([
Row(
children: [
Expanded(
child: _heading(
_tr(context, 'return_preview'),
Icons.map_outlined,
),
),
IconButton(
tooltip: _tr(context, 'return_expand'),
onPressed: () => _openMap(context, plan),
icon: const Icon(
Icons.open_in_full_rounded,
size: 18,
color: kWtBlue,
),
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 190,
child: ReturnPlanMapPreview(plan: plan),
),
const SizedBox(height: 10),
_text(_tr(context, 'return_map_note')),
]),
const SizedBox(height: 16),
],
_heading(_tr(context, 'return_points'), Icons.pin_drop_outlined),
const SizedBox(height: 10),
if (plan.restPoints.isEmpty)
_text(_tr(context, 'return_no_points')),
for (final (index, point) in plan.restPoints.indexed) ...[
_card([
Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFFE8F8EC),
child: Text(
'${index + 1}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: Color(0xFF22A06B),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
point.alias.trim().isNotEmpty
? point.alias
: _tr(
context,
'return_point_index',
).replaceAll('%s', '${index + 1}'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: kWtNavy,
),
),
),
],
),
const SizedBox(height: 10),
_text(
'${_tr(context, 'return_device')}:${point.bindDeviceId?.trim().isNotEmpty == true ? point.bindDeviceId : _tr(context, 'return_unbound')}',
),
const SizedBox(height: 5),
SelectableText(
'${_tr(context, 'return_coordinates')}:${_position(point.position)}',
style: const TextStyle(fontSize: 11.5, color: kWtGray),
),
]),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
_heading(_tr(context, 'return_obstacles'), Icons.block_outlined),
const SizedBox(height: 10),
if (plan.obstacles.isEmpty)
_text(_tr(context, 'return_no_obstacles')),
for (final (index, obstacle) in plan.obstacles.indexed) ...[
_card([
_heading(
obstacle.alias.trim().isNotEmpty
? obstacle.alias
: _tr(
context,
'return_obstacle_index',
).replaceAll('%s', '${index + 1}'),
Icons.block_outlined,
),
const SizedBox(height: 10),
_text(
'${_tr(context, 'return_shape')}:${obstacle.shapeType == 'rect'
? _tr(context, 'return_shape_rect')
: obstacle.shapeType == 'circle'
? _tr(context, 'return_shape_circle')
: obstacle.shapeType}',
),
const SizedBox(height: 5),
_text(
'${_tr(context, 'return_size')}:${obstacle.sizeX > 0 && obstacle.sizeY > 0 ? '${obstacle.sizeX} × ${obstacle.sizeY} m' : '—'}',
),
const SizedBox(height: 5),
_text(
'${_tr(context, 'return_source')}:${obstacle.source == 'platform' ? _tr(context, 'return_platform') : obstacle.source}',
),
const SizedBox(height: 5),
SelectableText(
'${_tr(context, 'return_coordinates')}:${_position(obstacle.center)}',
style: const TextStyle(fontSize: 11.5, color: kWtGray),
),
]),
const SizedBox(height: 10),
],
],
],
),
);
},
);
}

View File

@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../../../autonomous_work/domain/entities/return_plan_models.dart';
import '../../../../home/presentation/widgets/map/app_base_map_layer.dart';
import '../../../../home/presentation/widgets/map/geoserver_suzhou_layer.dart';
/// 只展示接口返回的返航点和障碍;这些点不是行驶轨迹,不连接成路线。
class ReturnPlanMapPreview extends StatefulWidget {
const ReturnPlanMapPreview({
super.key,
required this.plan,
this.interactive = false,
});
final ReturnPlanDraft plan;
final bool interactive;
@override
State<ReturnPlanMapPreview> createState() => _ReturnPlanMapPreviewState();
}
class _ReturnPlanMapPreviewState extends State<ReturnPlanMapPreview> {
static const _green = Color(0xFF22A06B);
static const _red = Color(0xFFF0483E);
static const _distance = Distance();
final MapController _map = MapController();
bool _ready = false;
late List<Widget> _stationLayers = StationLayerRepo.instance.buildLayers();
bool _valid(LatLng point) =>
point.latitude.isFinite &&
point.longitude.isFinite &&
point.latitude >= -90 &&
point.latitude <= 90 &&
point.longitude >= -180 &&
point.longitude <= 180;
List<LatLng> _corners(ReturnObstaclePoint obstacle) {
if (!_valid(obstacle.center) ||
!obstacle.sizeX.isFinite ||
!obstacle.sizeY.isFinite ||
obstacle.sizeX <= 0 ||
obstacle.sizeY <= 0 ||
obstacle.shapeType != 'rect')
return const [];
final north = _distance
.offset(obstacle.center, obstacle.sizeY / 2, 0)
.latitude;
final south = _distance
.offset(obstacle.center, obstacle.sizeY / 2, 180)
.latitude;
final east = _distance
.offset(obstacle.center, obstacle.sizeX / 2, 90)
.longitude;
final west = _distance
.offset(obstacle.center, obstacle.sizeX / 2, 270)
.longitude;
return [
LatLng(north, west),
LatLng(north, east),
LatLng(south, east),
LatLng(south, west),
];
}
List<LatLng> get _points => [
for (final point in widget.plan.restPoints)
if (_valid(point.position)) point.position,
for (final obstacle in widget.plan.obstacles)
if (_valid(obstacle.center)) ...[obstacle.center, ..._corners(obstacle)],
];
@override
void initState() {
super.initState();
StationLayerRepo.instance.addListener(_layersChanged);
StationLayerRepo.instance.refresh();
}
void _layersChanged() {
if (mounted)
setState(() => _stationLayers = StationLayerRepo.instance.buildLayers());
}
@override
void didUpdateWidget(covariant ReturnPlanMapPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.plan, widget.plan)) {
WidgetsBinding.instance.addPostFrameCallback((_) => _fit());
}
}
void _fit() {
if (!mounted || !_ready) return;
final points = _points;
if (points.isEmpty) return;
if (points.length == 1 || points.toSet().length == 1) {
_map.move(points.first, 20);
} else {
_map.fitCamera(
CameraFit.bounds(
bounds: LatLngBounds.fromPoints(points),
padding: const EdgeInsets.all(36),
maxZoom: 21,
),
);
}
}
@override
void dispose() {
StationLayerRepo.instance.removeListener(_layersChanged);
_map.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final points = _points;
if (points.isEmpty) return const SizedBox.shrink();
return ClipRRect(
borderRadius: BorderRadius.circular(14),
child: FlutterMap(
mapController: _map,
options: MapOptions(
initialCenter: points.first,
initialZoom: 19,
minZoom: 3,
maxZoom: 22,
onMapReady: () {
_ready = true;
_fit();
},
interactionOptions: InteractionOptions(
flags: widget.interactive
? InteractiveFlag.all
: InteractiveFlag.none,
),
),
children: [
const AppBaseMapLayer(maxZoom: 22),
..._stationLayers,
PolygonLayer(
polygons: [
for (final obstacle in widget.plan.obstacles)
if (_corners(obstacle).isNotEmpty)
Polygon(
points: _corners(obstacle),
color: _red.withValues(alpha: 0.22),
borderColor: _red,
borderStrokeWidth: 2,
),
],
),
MarkerLayer(
markers: [
for (final (index, point) in widget.plan.restPoints.indexed)
if (_valid(point.position))
Marker(
point: point.position,
width: 32,
height: 32,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: _green,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
),
for (final obstacle in widget.plan.obstacles)
if (_valid(obstacle.center))
Marker(
point: obstacle.center,
width: 26,
height: 26,
child: const Icon(
Icons.block_rounded,
size: 24,
color: _red,
),
),
],
),
],
),
);
}
}