对接连续任务列表接口和连续任务下发,便捷操作中暂停任务的对接,底部导航tab项的设置根据用户角色显示和设置
This commit is contained in:
441
lib/features/v2/work/presentation/widgets/all_work_sheet.dart
Normal file
441
lib/features/v2/work/presentation/widgets/all_work_sheet.dart
Normal 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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user