Kitűzés: pontok importja, szervízek, kitüző panel

This commit is contained in:
2026-07-06 11:47:41 +02:00
parent c01fdcf012
commit 6706b2b1ba
12 changed files with 2482 additions and 4 deletions
@@ -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'),
),
],
),
),
);
}
}