Kitűzés: pontok importja, szervízek, kitüző panel
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:terepi_seged/models/stakeout_point.dart';
|
||||
import 'package:terepi_seged/services/app_database.dart';
|
||||
import 'package:terepi_seged/services/coord_converter_service.dart';
|
||||
import 'package:terepi_seged/services/project_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_import_service.dart';
|
||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||
|
||||
/// Kitűzési pontok importja (CSV / GeoJSON) — felismerés + előnézet.
|
||||
///
|
||||
/// Folyamat: fájlválasztás → automatikus elemzés (elválasztó, tizedesjel,
|
||||
/// fejléc, oszlopszerepek, koordináta-rendszer) → ELŐNÉZET: minta-táblázat
|
||||
/// oszloponkénti szerep-választóval + mini-térkép vizuális ellenőrzéshez →
|
||||
/// import az aktív projektbe. Soha nem importálunk vakon.
|
||||
class StakeoutImportView extends StatefulWidget {
|
||||
const StakeoutImportView({super.key});
|
||||
|
||||
@override
|
||||
State<StakeoutImportView> createState() => _StakeoutImportViewState();
|
||||
}
|
||||
|
||||
class _StakeoutImportViewState extends State<StakeoutImportView> {
|
||||
CsvPreview? _preview;
|
||||
List<ColumnRole> _roles = [];
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_busy = true;
|
||||
});
|
||||
try {
|
||||
// FileType.any: a Google Drive (és más felhő-providerek) a tárolt
|
||||
// MIME-típus alapján szűrnek — a .geojson-nak nincs regisztrált
|
||||
// MIME-je, a Drive a csv/txt fájlokat is gyakran más MIME-mal
|
||||
// tartja nyilván, ezért custom szűrővel szürkék maradnának.
|
||||
// A kiterjesztést a kiválasztás UTÁN mi ellenőrizzük.
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.any);
|
||||
final picked = result?.files.single;
|
||||
final path = picked?.path;
|
||||
if (path == null) return;
|
||||
|
||||
final name = picked!.name.toLowerCase();
|
||||
const allowed = ['.csv', '.txt', '.geojson', '.json'];
|
||||
if (!allowed.any(name.endsWith)) {
|
||||
setState(() => _error = 'Nem támogatott fájltípus: ${picked.name} — '
|
||||
'CSV, TXT vagy GeoJSON fájlt válassz.');
|
||||
return;
|
||||
}
|
||||
|
||||
final file = File(path);
|
||||
final isGeojson = name.endsWith('.json') || name.endsWith('.geojson');
|
||||
final preview = isGeojson
|
||||
? await StakeoutImportService.analyzeGeojson(file)
|
||||
: await StakeoutImportService.analyzeCsv(file);
|
||||
|
||||
setState(() {
|
||||
_preview = preview;
|
||||
_roles = List.of(preview.guessedRoles);
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Az aktuális szerep-kiosztással felépített pontok (a mini-térképhez
|
||||
/// és az importhoz ugyanaz a kód fut — amit látsz, azt kapod).
|
||||
({List<StakeoutPoint> points, int skipped})? _build() {
|
||||
final preview = _preview;
|
||||
final projectId = ProjectService.to.activeProjectId;
|
||||
if (preview == null || projectId == null) return null;
|
||||
if (!Get.isRegistered<CoordConverterService>()) return null;
|
||||
try {
|
||||
return StakeoutImportService.buildPoints(
|
||||
preview: preview,
|
||||
roles: _roles,
|
||||
projectId: projectId,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _import() async {
|
||||
final built = _build();
|
||||
if (built == null || built.points.isEmpty) {
|
||||
Get.snackbar(
|
||||
'Import',
|
||||
'Nincs importálható pont — ellenőrizd az '
|
||||
'oszlop-megfeleltetést.',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final inserted =
|
||||
await AppDatabase.instance.insertStakeoutPoints(built.points);
|
||||
final dup = built.points.length - inserted;
|
||||
if (Get.isRegistered<StakeoutService>()) {
|
||||
await StakeoutService.to.load();
|
||||
}
|
||||
Get.back();
|
||||
Get.snackbar(
|
||||
'Import kész',
|
||||
'$inserted pont importálva'
|
||||
'${dup > 0 ? ' · $dup már létező kihagyva' : ''}'
|
||||
'${built.skipped > 0 ? ' · ${built.skipped} hibás sor' : ''}',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
);
|
||||
} finally {
|
||||
setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final preview = _preview;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Kitűzési pontok importja')),
|
||||
body: _busy && preview == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: preview == null
|
||||
? _EmptyState(onPick: _pickFile, error: _error)
|
||||
: _buildPreview(context, preview),
|
||||
bottomNavigationBar: preview == null
|
||||
? null
|
||||
: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _busy ? null : _pickFile,
|
||||
child: const Text('Másik fájl'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _busy ? null : _import,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.download_done),
|
||||
label: const Text('Import az aktív projektbe'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview(BuildContext context, CsvPreview preview) {
|
||||
final built = _build();
|
||||
final crsLabel =
|
||||
_roles.contains(ColumnRole.eovY) && _roles.contains(ColumnRole.eovX)
|
||||
? 'EOV'
|
||||
: _roles.contains(ColumnRole.lat) && _roles.contains(ColumnRole.lon)
|
||||
? 'WGS84'
|
||||
: 'nincs koordináta kijelölve!';
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Összegző chipek ─────────────────────────────────────────
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
Chip(
|
||||
avatar: const Icon(Icons.description, size: 16),
|
||||
label:
|
||||
Text(preview.fileName, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
Chip(
|
||||
label: Text('${preview.rowCount} sor',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
Chip(
|
||||
avatar: Icon(
|
||||
crsLabel.startsWith('nincs')
|
||||
? Icons.warning_amber
|
||||
: Icons.public,
|
||||
size: 16,
|
||||
color: crsLabel.startsWith('nincs') ? Colors.orange : null,
|
||||
),
|
||||
label: Text('Rendszer: $crsLabel',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
if (built != null)
|
||||
Chip(
|
||||
label: Text(
|
||||
'${built.points.length} érvényes pont'
|
||||
'${built.skipped > 0 ? ' · ${built.skipped} hibás sor' : ''}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Megfeleltetési táblázat ────────────────────────────────
|
||||
Text('Oszlop-megfeleltetés',
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ellenőrizd a felismert szerepeket — az oszlopok fölött '
|
||||
'módosíthatók.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
headingRowHeight: 96,
|
||||
columnSpacing: 16,
|
||||
columns: [
|
||||
for (var c = 0; c < preview.headers.length; c++)
|
||||
DataColumn(
|
||||
label: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(preview.headers[c],
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: Colors.grey)),
|
||||
DropdownButton<ColumnRole>(
|
||||
value: _roles[c],
|
||||
isDense: true,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: _roles[c] == ColumnRole.ignore
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
color: _roles[c] == ColumnRole.ignore
|
||||
? Colors.grey
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
items: [
|
||||
for (final r in ColumnRole.values)
|
||||
DropdownMenuItem(value: r, child: Text(r.label)),
|
||||
],
|
||||
onChanged: (r) {
|
||||
if (r == null) return;
|
||||
setState(() {
|
||||
// Egy szerep csak egy oszlopé lehet.
|
||||
if (r != ColumnRole.ignore) {
|
||||
for (var i = 0; i < _roles.length; i++) {
|
||||
if (_roles[i] == r) {
|
||||
_roles[i] = ColumnRole.ignore;
|
||||
}
|
||||
}
|
||||
}
|
||||
_roles[c] = r;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
rows: [
|
||||
for (final row in preview.sampleRows)
|
||||
DataRow(cells: [
|
||||
for (var c = 0; c < preview.headers.length; c++)
|
||||
DataCell(Text(
|
||||
c < row.length ? row[c] : '',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Mini-térkép: vizuális ellenőrzés ───────────────────────
|
||||
if (built != null && built.points.isNotEmpty) ...[
|
||||
Text('Előnézet a térképen',
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ha a pontok ott vannak, ahol lenniük kell, a megfeleltetés jó.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: _PreviewMap(points: built.points),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewMap extends StatelessWidget {
|
||||
final List<StakeoutPoint> points;
|
||||
const _PreviewMap({required this.points});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Max. 500 markert rajzolunk — előnézetnek bőven elég.
|
||||
final shown = points.length > 500
|
||||
? [
|
||||
for (var i = 0; i < points.length; i += points.length ~/ 500)
|
||||
points[i]
|
||||
]
|
||||
: points;
|
||||
|
||||
final lats = shown.map((p) => p.planLat);
|
||||
final lons = shown.map((p) => p.planLon);
|
||||
final center = LatLng(
|
||||
(lats.reduce((a, b) => a + b)) / shown.length,
|
||||
(lons.reduce((a, b) => a + b)) / shown.length,
|
||||
);
|
||||
|
||||
return FlutterMap(
|
||||
options: MapOptions(initialCenter: center, initialZoom: 13),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'hu.appdev.terepi_seged',
|
||||
),
|
||||
MarkerLayer(markers: [
|
||||
for (final p in shown)
|
||||
Marker(
|
||||
point: LatLng(p.planLat, p.planLon),
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepOrange,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final VoidCallback onPick;
|
||||
final String? error;
|
||||
const _EmptyState({required this.onPick, this.error});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.upload_file, size: 56, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Válassz CSV vagy GeoJSON fájlt.\n'
|
||||
'A program felismeri az elválasztót, a tizedesjelet és az '
|
||||
'oszlopok szerepét (EOV / WGS84), import előtt pedig '
|
||||
'ellenőrizheted az eredményt.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('Hiba: $error',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.red)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onPick,
|
||||
icon: const Icon(Icons.folder_open),
|
||||
label: const Text('Fájl kiválasztása'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user