对接连续任务列表接口和连续任务下发,便捷操作中暂停任务的对接,底部导航tab项的设置根据用户角色显示和设置
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../regions/domain/entities/region_draft.dart';
|
||||
import '../../../regions/domain/usecases/region_usecases.dart';
|
||||
import '../../domain/entities/consecutive_task_request.dart';
|
||||
import '../../domain/usecases/create_consecutive_task_usecase.dart';
|
||||
|
||||
enum AreaTaskSubmitStatus { idle, submitting, success, failure }
|
||||
|
||||
class AreaTaskCreateState {
|
||||
AreaTaskCreateState({
|
||||
this.siteId,
|
||||
this.loading = false,
|
||||
this.loaded = false,
|
||||
List<RegionDraft> regions = const [],
|
||||
List<RegionRouteOption> routes = const [],
|
||||
Set<int> selectedRegions = const {},
|
||||
List<int> orderedRouteIds = const [],
|
||||
this.loadError,
|
||||
this.submitStatus = AreaTaskSubmitStatus.idle,
|
||||
this.submitError,
|
||||
}) : regions = List.unmodifiable(regions),
|
||||
routes = List.unmodifiable(routes),
|
||||
selectedRegions = Set.unmodifiable(selectedRegions),
|
||||
orderedRouteIds = List.unmodifiable(orderedRouteIds);
|
||||
|
||||
final int? siteId;
|
||||
final bool loading;
|
||||
final bool loaded;
|
||||
final List<RegionDraft> regions;
|
||||
final List<RegionRouteOption> routes;
|
||||
final Set<int> selectedRegions;
|
||||
final List<int> orderedRouteIds;
|
||||
final String? loadError;
|
||||
final AreaTaskSubmitStatus submitStatus;
|
||||
final String? submitError;
|
||||
bool get isSubmitting => submitStatus == AreaTaskSubmitStatus.submitting;
|
||||
|
||||
Set<int> get availableRouteIds => {
|
||||
for (final region in regions)
|
||||
if (selectedRegions.contains(region.id)) ...{
|
||||
...region.boundRouteIds,
|
||||
if (region.defaultRouteId != null) region.defaultRouteId!,
|
||||
},
|
||||
};
|
||||
|
||||
List<RegionRouteOption> get availableRoutes =>
|
||||
routes.where((route) => availableRouteIds.contains(route.id)).toList();
|
||||
|
||||
AreaTaskCreateState copyWith({
|
||||
bool? loading,
|
||||
bool? loaded,
|
||||
List<RegionDraft>? regions,
|
||||
List<RegionRouteOption>? routes,
|
||||
Set<int>? selectedRegions,
|
||||
List<int>? orderedRouteIds,
|
||||
String? loadError,
|
||||
AreaTaskSubmitStatus? submitStatus,
|
||||
String? submitError,
|
||||
}) => AreaTaskCreateState(
|
||||
siteId: siteId,
|
||||
loading: loading ?? this.loading,
|
||||
loaded: loaded ?? this.loaded,
|
||||
regions: regions ?? this.regions,
|
||||
routes: routes ?? this.routes,
|
||||
selectedRegions: selectedRegions ?? this.selectedRegions,
|
||||
orderedRouteIds: orderedRouteIds ?? this.orderedRouteIds,
|
||||
loadError: loadError,
|
||||
submitStatus: submitStatus ?? this.submitStatus,
|
||||
submitError: submitError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 分区、路线选择和顺序由本 Cubit 管理;页面不访问 HTTP 或数据源。
|
||||
class AreaTaskCreateCubit extends Cubit<AreaTaskCreateState> {
|
||||
AreaTaskCreateCubit(this._loadRegions, this._loadRoutes, this._create)
|
||||
: super(AreaTaskCreateState());
|
||||
final LoadRegionsUseCase _loadRegions;
|
||||
final LoadRegionRoutesUseCase _loadRoutes;
|
||||
final CreateConsecutiveTaskUseCase _create;
|
||||
int _generation = 0;
|
||||
|
||||
Future<void> load(int siteId) async {
|
||||
if (state.isSubmitting || state.loading) return;
|
||||
final generation = ++_generation;
|
||||
emit(AreaTaskCreateState(siteId: siteId, loading: true));
|
||||
try {
|
||||
final regionsFuture = _loadRegions(siteId);
|
||||
final routesFuture = _loadRoutes(siteId);
|
||||
final regionResult = await regionsFuture;
|
||||
final routeResult = await routesFuture;
|
||||
if (isClosed || generation != _generation) return;
|
||||
String? error;
|
||||
final regions = regionResult.fold((failure) {
|
||||
error = failure.message;
|
||||
return <RegionDraft>[];
|
||||
}, (value) => value);
|
||||
final routes = routeResult.fold((failure) {
|
||||
error ??= failure.message;
|
||||
return <RegionRouteOption>[];
|
||||
}, (value) => value);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
loaded: error == null,
|
||||
regions: regions
|
||||
.where(
|
||||
(region) =>
|
||||
region.siteId == siteId &&
|
||||
region.enabled &&
|
||||
region.id != null,
|
||||
)
|
||||
.toList(),
|
||||
routes: routes,
|
||||
loadError: error,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isClosed || generation != _generation) return;
|
||||
emit(state.copyWith(loading: false, loadError: error.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void toggleRegion(int id) {
|
||||
if (!state.loaded || state.isSubmitting) return;
|
||||
final region = state.regions.where((region) => region.id == id).firstOrNull;
|
||||
if (region == null) return;
|
||||
final selected = {...state.selectedRegions};
|
||||
final adding = selected.add(id);
|
||||
if (!adding) selected.remove(id);
|
||||
final next = state.copyWith(selectedRegions: selected);
|
||||
final available = next.availableRoutes.map((route) => route.id).toSet();
|
||||
final ordered = state.orderedRouteIds.where(available.contains).toList();
|
||||
// 新选分区默认勾选它的默认路线,其余绑定路线由用户自主选择。
|
||||
final defaultId = region.defaultRouteId;
|
||||
if (adding &&
|
||||
defaultId != null &&
|
||||
available.contains(defaultId) &&
|
||||
!ordered.contains(defaultId)) {
|
||||
ordered.add(defaultId);
|
||||
}
|
||||
emit(
|
||||
next.copyWith(
|
||||
orderedRouteIds: ordered,
|
||||
submitStatus: AreaTaskSubmitStatus.idle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void toggleRoute(int id) {
|
||||
if (state.isSubmitting ||
|
||||
!state.availableRoutes.any((route) => route.id == id))
|
||||
return;
|
||||
final ordered = [...state.orderedRouteIds];
|
||||
if (!ordered.remove(id)) ordered.add(id);
|
||||
emit(
|
||||
state.copyWith(
|
||||
orderedRouteIds: ordered,
|
||||
submitStatus: AreaTaskSubmitStatus.idle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void reorder(int oldIndex, int newIndex) {
|
||||
if (state.isSubmitting) return;
|
||||
final ordered = [...state.orderedRouteIds];
|
||||
if (oldIndex < 0 ||
|
||||
oldIndex >= ordered.length ||
|
||||
newIndex < 0 ||
|
||||
newIndex > ordered.length)
|
||||
return;
|
||||
if (newIndex > oldIndex) --newIndex;
|
||||
ordered.insert(newIndex, ordered.removeAt(oldIndex));
|
||||
emit(
|
||||
state.copyWith(
|
||||
orderedRouteIds: ordered,
|
||||
submitStatus: AreaTaskSubmitStatus.idle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> submit({
|
||||
required String deviceId,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
if (state.isSubmitting ||
|
||||
state.submitStatus == AreaTaskSubmitStatus.success)
|
||||
return;
|
||||
String? error;
|
||||
final request = ConsecutiveTaskRequest(
|
||||
deviceId: deviceId,
|
||||
routeIds: state.orderedRouteIds,
|
||||
orgId: orgId,
|
||||
siteId: siteId,
|
||||
);
|
||||
if (!state.loaded || state.siteId != siteId) {
|
||||
error = '请重新加载当前场站的分区和路线';
|
||||
} else if (state.selectedRegions.isEmpty) {
|
||||
error = '请至少选择一个任务区域';
|
||||
} else if (state.orderedRouteIds.any(
|
||||
(id) =>
|
||||
!state.availableRouteIds.contains(id) ||
|
||||
!state.routes.any((route) => route.id == id),
|
||||
)) {
|
||||
error = '路线与所选区域不匹配,请重新选择';
|
||||
}
|
||||
error ??= request.validationError;
|
||||
if (error != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
submitStatus: AreaTaskSubmitStatus.failure,
|
||||
submitError: error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(submitStatus: AreaTaskSubmitStatus.submitting));
|
||||
try {
|
||||
final result = await _create(request);
|
||||
if (isClosed) return;
|
||||
result.fold(
|
||||
(failure) => emit(
|
||||
state.copyWith(
|
||||
submitStatus: AreaTaskSubmitStatus.failure,
|
||||
submitError: failure.message,
|
||||
),
|
||||
),
|
||||
(_) => emit(state.copyWith(submitStatus: AreaTaskSubmitStatus.success)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isClosed)
|
||||
emit(
|
||||
state.copyWith(
|
||||
submitStatus: AreaTaskSubmitStatus.failure,
|
||||
submitError: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void clearFailure() {
|
||||
if (state.submitStatus == AreaTaskSubmitStatus.failure) {
|
||||
emit(state.copyWith(submitStatus: AreaTaskSubmitStatus.idle));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/localization/app_localizations.dart';
|
||||
import '../../../v2/device_list/presentation/widgets/autonomous/work_ui.dart';
|
||||
import '../cubit/area_task_create_cubit.dart';
|
||||
|
||||
class AreaDispatchSection extends StatelessWidget {
|
||||
const AreaDispatchSection({super.key, required this.onReload});
|
||||
final VoidCallback onReload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<AreaTaskCreateCubit>();
|
||||
final state = context.watch<AreaTaskCreateCubit>().state;
|
||||
String text(String key) =>
|
||||
AppLocalizations.of(context).translate('work_v2.plan_create.$key');
|
||||
Widget heading(String label, IconData icon) => Row(
|
||||
children: [
|
||||
Icon(icon, color: kWtBlue, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: kWtNavy,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
Widget hint(String label) => Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: kWtGray, height: 1.5),
|
||||
);
|
||||
|
||||
if (state.loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
}
|
||||
if (!state.loaded) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
hint(state.loadError ?? text('area_load_hint')),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: onReload,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(text('area_reload')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final routes = state.availableRoutes;
|
||||
final names = {for (final route in state.routes) route.id: route.name};
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: heading(
|
||||
text('area_select_title'),
|
||||
Icons.crop_free_rounded,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: text('area_reload'),
|
||||
onPressed: state.isSubmitting ? null : onReload,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 19, color: kWtGray),
|
||||
),
|
||||
],
|
||||
),
|
||||
hint(text('area_select_hint')),
|
||||
const SizedBox(height: 8),
|
||||
if (state.regions.isEmpty)
|
||||
hint(text('area_empty'))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final region in state.regions)
|
||||
FilterChip(
|
||||
label: Text(
|
||||
region.name.isEmpty ? '#${region.id}' : region.name,
|
||||
),
|
||||
selected: state.selectedRegions.contains(region.id),
|
||||
onSelected: state.isSubmitting
|
||||
? null
|
||||
: (_) => cubit.toggleRegion(region.id!),
|
||||
selectedColor: kWtSoftBlue,
|
||||
checkmarkColor: kWtBlue,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
heading(text('area_routes_title'), Icons.alt_route_rounded),
|
||||
const SizedBox(height: 6),
|
||||
hint(text('area_routes_hint')),
|
||||
const SizedBox(height: 8),
|
||||
if (state.selectedRegions.isEmpty)
|
||||
hint(text('area_choose_first'))
|
||||
else if (routes.isEmpty)
|
||||
hint(text('area_routes_empty'))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final route in routes)
|
||||
FilterChip(
|
||||
label: Text(route.name.isEmpty ? '#${route.id}' : route.name),
|
||||
selected: state.orderedRouteIds.contains(route.id),
|
||||
onSelected: state.isSubmitting
|
||||
? null
|
||||
: (_) => cubit.toggleRoute(route.id),
|
||||
selectedColor: kWtSoftBlue,
|
||||
checkmarkColor: kWtBlue,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
heading(text('area_order_title'), Icons.format_list_numbered_rounded),
|
||||
const SizedBox(height: 6),
|
||||
hint(text('area_order_hint')),
|
||||
const SizedBox(height: 8),
|
||||
if (state.orderedRouteIds.isEmpty)
|
||||
hint(text('area_order_empty'))
|
||||
else
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: state.orderedRouteIds.length,
|
||||
onReorder: cubit.reorder,
|
||||
itemBuilder: (context, index) {
|
||||
final id = state.orderedRouteIds[index];
|
||||
return Container(
|
||||
key: ValueKey('area_task_route_$id'),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: kWtSegBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: kWtBorder),
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.only(left: 10, right: 2),
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: kWtBlue,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
names[id] ?? '#$id',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: kWtNavy,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: text('area_remove_route'),
|
||||
onPressed: state.isSubmitting
|
||||
? null
|
||||
: () => cubit.toggleRoute(id),
|
||||
icon: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 17,
|
||||
color: kWtGray,
|
||||
),
|
||||
),
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
enabled: !state.isSubmitting,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.drag_handle_rounded,
|
||||
color: kWtGray,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user