diff --git a/lib/main.dart b/lib/main.dart index e7a5cb1..c5ee17c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/layer_import_service.dart'; import 'package:terepi_seged/services/layer_sync_service.dart'; +import 'package:terepi_seged/services/measured_data_service.dart'; import 'package:terepi_seged/services/note_audio_service.dart'; import 'package:terepi_seged/services/note_photo_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; @@ -117,6 +118,7 @@ Future main() async { Get.put(VehicleIdentityService()); Get.put(FieldPropertyService()); Get.put(ParcelGeometryService()); + Get.put(MeasuredDataService()); runApp(const MyApp()); } diff --git a/lib/models/measured_record.dart b/lib/models/measured_record.dart new file mode 100644 index 0000000..edbc589 --- /dev/null +++ b/lib/models/measured_record.dart @@ -0,0 +1,79 @@ +/// Egy mérési felvétel (lövés) a PC-szoftver által feltöltött +/// `terepi_seged_measure_records` táblából. +/// +/// SZÁNDÉKOSAN nincs helyi tükör — ez a PC-szoftverből folyamatosan, +/// élőben érkező adat, mindig friss, sosem gyorsítótárazott (lásd +/// MeasureDataService.watchRecords). +class MeasuredRecord { + final int id; + final String projectId; + final double? sourceLineNumber; + final double? sourcePointNumber; + final DateTime measuredAt; + final double? eovX; + final double? eovY; + final int? gpsTimeInMicroseconds; + final int? minReceiverPointNumber; + final int? minReceiverPointFduNumber; + final int? maxReceiverPointNumber; + final int? maxReceiverPointFduNumber; + final String? imagePath; + final String? imageUrl; + final int? ffid; + final int? recordType; + final int? recordSerialNumberOfSourcePoint; + + const MeasuredRecord({ + required this.id, + required this.projectId, + this.sourceLineNumber, + this.sourcePointNumber, + required this.measuredAt, + this.eovX, + this.eovY, + this.gpsTimeInMicroseconds, + this.minReceiverPointNumber, + this.minReceiverPointFduNumber, + this.maxReceiverPointNumber, + this.maxReceiverPointFduNumber, + this.imagePath, + this.imageUrl, + this.ffid, + this.recordType, + this.recordSerialNumberOfSourcePoint, + }); + + factory MeasuredRecord.fromMap(Map m) => MeasuredRecord( + id: m['id'] as int, + projectId: m['project_id'] as String, + sourceLineNumber: (m['source_line_number'] as num?)?.toDouble(), + sourcePointNumber: (m['source_point_number'] as num?)?.toDouble(), + measuredAt: DateTime.parse(m['measured_at'] as String), + eovX: (m['eov_x'] as num?)?.toDouble(), + eovY: (m['eov_y'] as num?)?.toDouble(), + gpsTimeInMicroseconds: (m['gps_time_in_microseconds'] as num?)?.toInt(), + minReceiverPointNumber: + (m['min_receiver_point_number'] as num?)?.toInt(), + minReceiverPointFduNumber: + (m['min_receiver_point_fdu_number'] as num?)?.toInt(), + maxReceiverPointNumber: + (m['max_receiver_point_number'] as num?)?.toInt(), + maxReceiverPointFduNumber: + (m['max_receiver_point_fdu_number'] as num?)?.toInt(), + imagePath: m['image_path'] as String?, + imageUrl: m['image_url'] as String?, + ffid: (m['ffid'] as num?)?.toInt(), + recordType: (m['record_type'] as num?)?.toInt(), + recordSerialNumberOfSourcePoint: + (m['record_serial_number_of_source_point'] as num?)?.toInt(), + ); + + bool get hasImage => + (imageUrl != null && imageUrl!.isNotEmpty) || + (imagePath != null && imagePath!.isNotEmpty); + + String? get geophoneRange => + (minReceiverPointNumber != null && maxReceiverPointNumber != null) + ? '$minReceiverPointNumber–$maxReceiverPointNumber' + : null; +} diff --git a/lib/pages/measured_data/presentation/controllers/measured_data_controller.dart b/lib/pages/measured_data/presentation/controllers/measured_data_controller.dart index bbb7ae3..bdd5cd0 100644 --- a/lib/pages/measured_data/presentation/controllers/measured_data_controller.dart +++ b/lib/pages/measured_data/presentation/controllers/measured_data_controller.dart @@ -1,56 +1,113 @@ +import 'dart:async'; import 'dart:typed_data'; -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:terepi_seged/models/seismic_records.dart'; +import 'package:terepi_seged/models/measured_record.dart'; import 'package:terepi_seged/pages/measured_data/presentation/views/seismogram_viewer_dialog.dart'; +import 'package:terepi_seged/services/measured_data_service.dart'; +import 'package:terepi_seged/services/project_service.dart'; class MeasuredDataController extends GetxController { - final CollectionReference _seismicRecords = - FirebaseFirestore.instance.collection('seismicRecords'); + static MeasuredDataController get to => Get.find(); - // late Query> records = _seismicRecords.orderBy('field'); - late Stream seismicRecordSnapshot; + final records = [].obs; + final isLoading = true.obs; + final searchText = ''.obs; + final lineFilter = Rxn(); - @override - void onReady() async { - super.onReady(); - } + StreamSubscription>? _sub; @override void onInit() { super.onInit(); + _subscribe(); + ever(ProjectService.to.activeProject, (_) => _subscribe()); + } - seismicRecordSnapshot = _seismicRecords - // .orderBy('sourcePoint', descending: true) - .where('gpsDateTime', isGreaterThan: DateTime(2024, 2, 25, 0, 0, 0)) - .orderBy('gpsDateTime', descending: true) - .snapshots(); + void _subscribe() { + _sub?.cancel(); + lineFilter.value = null; + final projectId = ProjectService.to.activeProject.value?.uuid; + if (projectId == null) { + records.clear(); + isLoading.value = false; + return; + } + isLoading.value = true; + _sub = MeasuredDataService.to.watchRecords(projectId).listen( + (data) { + records.value = data; + isLoading.value = false; + }, + onError: (e) { + isLoading.value = false; + Get.snackbar('Hiba', 'Nem sikerült betölteni a mérési adatokat: $e', + snackPosition: SnackPosition.BOTTOM); + }, + ); + } + + // ── Keresés/szűrés — a már memóriában lévő, élő listán, nem újabb + // lekérdezéssel (a teljes projekt-adat úgyis a stream-ben van). ── + + List get filteredRecords { + var list = records.toList(); + if (lineFilter.value != null) { + list = list.where((r) => r.sourceLineNumber == lineFilter.value).toList(); + } + final q = searchText.value.trim(); + if (q.isNotEmpty) { + list = list.where((r) { + return (r.ffid?.toString().contains(q) ?? false) || + (r.sourcePointNumber?.toString().contains(q) ?? false) || + (r.sourceLineNumber?.toString().contains(q) ?? false); + }).toList(); + } + return list; + } + + List get availableLines { + final set = + records.map((r) => r.sourceLineNumber).whereType().toSet(); + final list = set.toList()..sort(); + return list; + } + + void onSearchChanged(String v) => searchText.value = v; + + void setLineFilter(double? v) => lineFilter.value = v; + + // ── Kép megtekintése ───────────────────────────────────────────── + + Future showSeismicImage(MeasuredRecord record) async { + if (!record.hasImage) { + Get.snackbar('Nincs kép', 'Ehhez a felvételhez nincs rögzített kép.', + snackPosition: SnackPosition.BOTTOM); + return; + } + Get.dialog(const Center(child: CircularProgressIndicator()), + barrierDismissible: false); + try { + final Uint8List? bytes = await MeasuredDataService.to.loadImage(record); + Get.back(); // töltő-dialógus bezárása + if (bytes == null) { + Get.snackbar('Hiba', 'A kép nem tölthető be.', + snackPosition: SnackPosition.BOTTOM); + return; + } + Get.to(() => SeismogramViewerDialog(record: record, image: bytes), + transition: Transition.downToUp); + } catch (e) { + Get.back(); + Get.snackbar('Hiba', 'Kép letöltése sikertelen: $e', + snackPosition: SnackPosition.BOTTOM); + } } @override - void onClose() async { + void onClose() { + _sub?.cancel(); super.onClose(); - - // seismicRecordSnapshot. - } - - void showSeismicImage(int ffid) async { - late Uint8List image; - Reference reference = - FirebaseStorage.instance.ref().child('bp-4/${ffid}.jpg'); - - await reference - .getData() - .then((value) => {image = value!}) - .catchError((error) => {}); - - Get.to( - () => SeismogramViewerDialog( - imageUrl: '', - image: image, - ), - transition: Transition.downToUp); } } diff --git a/lib/pages/measured_data/presentation/views/measured_data_view.dart b/lib/pages/measured_data/presentation/views/measured_data_view.dart index 5c3ce30..bf0b009 100644 --- a/lib/pages/measured_data/presentation/views/measured_data_view.dart +++ b/lib/pages/measured_data/presentation/views/measured_data_view.dart @@ -1,83 +1,123 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/src/widgets/framework.dart'; import 'package:get/get.dart'; +import 'package:terepi_seged/models/measured_record.dart'; import 'package:terepi_seged/pages/measured_data/presentation/controllers/measured_data_controller.dart'; -import 'package:terepi_seged/pages/measured_data/presentation/views/seismogram_viewer_dialog.dart'; +import 'package:terepi_seged/services/project_service.dart'; class MeasuredDataView extends GetView { - const MeasuredDataView({Key? key}) : super(key: key); + const MeasuredDataView({super.key}); @override Widget build(BuildContext context) { return Scaffold( - resizeToAvoidBottomInset: false, - extendBody: true, - appBar: AppBar( - title: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Mérési adatok'), - Text( - "Budapest", - style: TextStyle(fontSize: 12.0), - ) - ], + appBar: AppBar( + title: Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Mérési adatok'), + Text( + ProjectService.to.activeProject.value?.name ?? '', + style: const TextStyle(fontSize: 12.0), + ), + ], + )), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + flex: 2, + child: TextField( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'FFID / forráspont / vonal…', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: controller.onSearchChanged, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Obx(() => DropdownButtonFormField( + value: controller.lineFilter.value, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Vonal', isDense: true), + items: [ + const DropdownMenuItem( + value: null, child: Text('Mind')), + ...controller.availableLines.map((l) => + DropdownMenuItem( + value: l, child: Text(_fmtNum(l)))), + ], + onChanged: controller.setLineFilter, + )), + ), + ], + ), ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 20.0), - child: GestureDetector( - onTap: () => Get.to(() => {}, transition: Transition.downToUp), - child: const Icon( - Icons.more_vert, - size: 26.0, - ), - ), - ) - ], - ), - body: Column(children: [ - Text("Mérési adatok"), - StreamBuilder( - stream: controller.seismicRecordSnapshot, - builder: - (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.hasError) { - return const Text('Hiba ...'); + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); } - if (snapshot.connectionState == ConnectionState.waiting) { - return const Text('Adatbetöltés ...'); + final list = controller.filteredRecords; + if (list.isEmpty) { + return const Center(child: Text('Nincs mérési adat.')); } - return Expanded( - child: ListView( - // shrinkWrap: true, - children: snapshot.data!.docs - .map((DocumentSnapshot document) { - Map data = - document.data()! as Map; - return ListTile( - title: Text(data['sourcePoint'].toString()), - subtitle: Text( - '${data['sourceLine'].toString()} - ${(data['gpsDateTime'] as Timestamp).toDate()}'), - trailing: Wrap(spacing: -16, children: [ - IconButton( - onPressed: () { - controller.showSeismicImage(data['ffid']); - }, - icon: const Icon(Icons.image_outlined)), - IconButton( - onPressed: () {}, - icon: - const Icon(Icons.arrow_forward_ios_sharp)) - ])); - }) - .toList() - .cast(), - ), + return ListView.builder( + itemCount: list.length, + itemBuilder: (context, i) => _RecordTile(record: list[i]), ); - }, - ) - ])); + }), + ), + ], + ), + ); + } +} + +String _fmtNum(double v) => + v == v.roundToDouble() ? v.toInt().toString() : v.toString(); + +String _fmtDate(DateTime dt) { + final local = dt.toLocal(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${local.year}.${two(local.month)}.${two(local.day)} ' + '${two(local.hour)}:${two(local.minute)}:${two(local.second)}'; +} + +class _RecordTile extends StatelessWidget { + final MeasuredRecord record; + const _RecordTile({required this.record}); + + @override + Widget build(BuildContext context) { + final hasImage = record.hasImage; + return ListTile( + title: Text( + 'FFID ${record.ffid ?? "—"} · VP ' + '${record.sourcePointNumber != null ? _fmtNum(record.sourcePointNumber!) : "—"}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + 'Vonal ${record.sourceLineNumber != null ? _fmtNum(record.sourceLineNumber!) : "—"}' + '${record.geophoneRange != null ? " · Geofon ${record.geophoneRange}" : ""}' + '\n${_fmtDate(record.measuredAt)}', + ), + isThreeLine: true, + trailing: Icon( + hasImage ? Icons.image_outlined : Icons.image_not_supported_outlined, + color: hasImage ? null : Colors.grey.shade400, + ), + onTap: hasImage + ? () => MeasuredDataController.to.showSeismicImage(record) + : null, + ); } } diff --git a/lib/pages/measured_data/presentation/views/seismogram_viewer_dialog.dart b/lib/pages/measured_data/presentation/views/seismogram_viewer_dialog.dart index 6ffa0aa..9114a31 100644 --- a/lib/pages/measured_data/presentation/views/seismogram_viewer_dialog.dart +++ b/lib/pages/measured_data/presentation/views/seismogram_viewer_dialog.dart @@ -2,17 +2,19 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:terepi_seged/pages/measured_data/presentation/controllers/measured_data_controller.dart'; import 'package:widget_zoom/widget_zoom.dart'; +import '../../../../models/measured_record.dart'; + class SeismogramViewerDialog extends StatelessWidget { - final controller = Get.find(); - final String imageUrl; + final MeasuredRecord record; final Uint8List image; - // final double sourcePoint; - SeismogramViewerDialog( - {required this.imageUrl, required this.image, super.key}); + + const SeismogramViewerDialog({ + required this.record, + required this.image, + super.key, + }); @override Widget build(BuildContext context) { @@ -24,43 +26,47 @@ class SeismogramViewerDialog extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - onPressed: () { - Get.back(); - }, - icon: const Icon(Icons.close)), + IconButton(onPressed: Get.back, icon: const Icon(Icons.close)), TextButton( - style: ButtonStyle( - overlayColor: - MaterialStateProperty.all(Colors.transparent)), - onPressed: () { - Get.back(); - }, - child: const Text( - 'Bezár', - style: TextStyle(color: Colors.blue, fontSize: 14.0), - )) + style: ButtonStyle( + overlayColor: + MaterialStateProperty.all(Colors.transparent)), + onPressed: Get.back, + child: const Text( + 'Bezár', + style: TextStyle(color: Colors.blue, fontSize: 14.0), + ), + ), ], ), ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 15.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), child: Text( - 'Felvétel megtekintése ()', - style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), + // Az eredeti verzióban ez a cím mindig üresen jelent meg + // ("Felvétel megtekintése ()") — itt tényleges adattal. + 'Felvétel megtekintése — FFID ${record.ffid ?? "—"}', + style: + const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), ), ), + if (record.geophoneRange != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15.0), + child: Text('Geofon-tartomány: ${record.geophoneRange}', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600)), + ), Padding( padding: const EdgeInsets.only(top: 10.0), child: Center( child: WidgetZoom( zoomWidget: Image(image: MemoryImage(image)), - heroAnimationTag: 'tag', + heroAnimationTag: 'seismogram_${record.id}', maxScaleFullscreen: 8, maxScaleEmbeddedView: 8, ), ), - ) + ), ], ), ); diff --git a/lib/services/measured_data_service.dart b/lib/services/measured_data_service.dart new file mode 100644 index 0000000..53e8e10 --- /dev/null +++ b/lib/services/measured_data_service.dart @@ -0,0 +1,46 @@ +import 'dart:typed_data'; + +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../models/measured_record.dart'; + +/// Mérési felvételek (lövések) élő lekérdezése a PC-szoftver által +/// feltöltött táblából. SZÁNDÉKOSAN nincs helyi tükör/gyorsítótár — ez +/// folyamatosan érkező, élő adat, mindig a jelenlegi állapotot mutatja. +class MeasuredDataService extends GetxService { + static MeasuredDataService get to => Get.find(); + + SupabaseClient get _client => Supabase.instance.client; + + /// TODO: erősítsd meg a valós bucket-nevet, ha eltér ettől. + static const _bucket = 'terepi_seged-measure-images'; + + /// Élő, folyamatosan frissülő stream — a Supabase Realtime-on keresztül, + /// pontosan úgy, ahogy korábban a Firestore .snapshots() működött. Új + /// felvétel a PC-szoftverből AZONNAL megjelenik, frissítés nélkül. + Stream> watchRecords(String projectId) { + return _client + .from('terepi_seged_measure_records') + .stream(primaryKey: ['id']) + .eq('project_id', projectId) + .order('measured_at', ascending: false) + .map((rows) => rows.map((r) => MeasuredRecord.fromMap(r)).toList()); + } + + /// A felvételhez tartozó kép betöltése — elsőbbséget adva az image_url-nek + /// (ha a PC-szoftver már kész, letölthető URL-t is küld), egyébként a + /// Storage-ból, image_path alapján. + Future loadImage(MeasuredRecord record) async { + if (record.imageUrl != null && record.imageUrl!.isNotEmpty) { + final resp = await http.get(Uri.parse(record.imageUrl!)); + if (resp.statusCode == 200) return resp.bodyBytes; + throw Exception('Kép letöltése sikertelen (${resp.statusCode})'); + } + if (record.imagePath != null && record.imagePath!.isNotEmpty) { + return _client.storage.from(_bucket).download(record.imagePath!); + } + return null; + } +} diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index f368a94..bd0a731 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -158,7 +158,7 @@ class AppDrawer extends StatelessWidget { title: const Text('Mérés'), onTap: () { Get.back(); - // Get.to(() => const NtripSettingsView()); + Get.toNamed(Routes.MEASUREDDATA); }, ), ListTile(