diff --git a/lib/enums/map_survey_mode.dart b/lib/enums/map_survey_mode.dart index f5b946a..5553309 100644 --- a/lib/enums/map_survey_mode.dart +++ b/lib/enums/map_survey_mode.dart @@ -1 +1 @@ -enum MapSurveyMode { browse, measure, stakeout, fieldWalk } +enum MapSurveyMode { browse, measure, stakeout, fieldWalk, track } diff --git a/lib/main.dart b/lib/main.dart index c813278..7e9c420 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; import 'package:terepi_seged/routes/app_pages.dart'; import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/coord_converter_service.dart'; @@ -26,6 +27,7 @@ Future main() async { Get.put(GnssDeviceService()); Get.put(GnssService()); Get.put(NtripService()); + Get.put(TrackingController(), permanent: true); runApp(const MyApp()); } diff --git a/lib/models/track.dart b/lib/models/track.dart index 2636a98..7ca48e5 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -58,6 +58,7 @@ enum TrackStatus { recording, paused, finished } class Track { final int? id; + final int? projectId; final String name; final DateTime startTime; final DateTime? endTime; @@ -70,6 +71,7 @@ class Track { const Track({ this.id, + this.projectId, required this.name, required this.startTime, this.endTime, @@ -81,6 +83,7 @@ class Track { Track copyWith({ int? id, + int? projectId, String? name, DateTime? startTime, DateTime? endTime, @@ -91,6 +94,7 @@ class Track { }) => Track( id: id ?? this.id, + projectId: projectId ?? this.projectId, name: name ?? this.name, startTime: startTime ?? this.startTime, endTime: endTime ?? this.endTime, @@ -120,25 +124,30 @@ class Track { Map toMap() => { if (id != null) 'id': id, + if (projectId != null) 'project_id': projectId, 'name': name, 'start_time': startTime.toIso8601String(), 'end_time': endTime?.toIso8601String(), 'status': status.name, 'source': source, - 'distance_meters': distanceMeters, + 'distance_m': distanceMeters, 'point_count': pointCount, }; factory Track.fromMap(Map m) => Track( id: m['id'] as int?, + projectId: m['project_id'] as int?, name: m['name'] as String, startTime: DateTime.parse(m['start_time'] as String), endTime: m['end_time'] != null ? DateTime.parse(m['end_time'] as String) : null, - status: TrackStatus.values.byName(m['status'] as String), + status: TrackStatus.values.firstWhere( + (s) => s.name == (m['status'] as String?), + orElse: () => TrackStatus.finished, + ), source: m['source'] as String? ?? 'Telefon GPS', - distanceMeters: (m['distance_meters'] as num?)?.toDouble() ?? 0, + distanceMeters: (m['distance_m'] as num?)?.toDouble() ?? 0, pointCount: m['point_count'] as int? ?? 0, ); } diff --git a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart index 6978291..1d875f7 100644 --- a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart +++ b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart @@ -29,6 +29,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:terepi_seged/pages/map_survey/presentations/views/measured_points_table_dialog.dart'; import 'package:terepi_seged/pages/ntrip_settings/presentation/controllers/ntrip_settings_controller.dart'; import 'package:terepi_seged/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.dart'; +import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; import 'package:terepi_seged/services/coord_converter_service.dart'; import 'package:terepi_seged/services/gnss/gnss_connection.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; @@ -285,6 +286,18 @@ class MapSurveyController extends GetxController { } void setMode(MapSurveyMode newMode) { + if (mode.value == MapSurveyMode.track && + newMode != MapSurveyMode.track && + TrackingController.to.isRecording.value) { + Get.snackbar( + 'Rögzítés folytatódik', + 'A track rögzítés a háttérben aktív marad.', + icon: const Icon(Icons.fiber_manual_record, color: Colors.red), + duration: const Duration(seconds: 3), + snackPosition: SnackPosition.TOP, + ); + } + mode.value = newMode; // Itt lehet módhoz kötött állapotokat állítani: @@ -294,31 +307,21 @@ class MapSurveyController extends GetxController { // - track indítás/leállítás figyelmeztetés stb. } - String get currentModeLabel { - switch (mode.value) { - case MapSurveyMode.browse: - return 'Térkép'; - case MapSurveyMode.measure: - return 'Bemérés'; - case MapSurveyMode.stakeout: - return 'Kitűzés'; - case MapSurveyMode.fieldWalk: - return 'Bejárás'; - } - } + String get currentModeLabel => switch (mode.value) { + MapSurveyMode.browse => 'Térkép', + MapSurveyMode.measure => 'Bemérés', + MapSurveyMode.stakeout => 'Kitűzés', + MapSurveyMode.fieldWalk => 'Bejárás', + MapSurveyMode.track => 'Útvonal' + }; - IconData get currentModeIcon { - switch (mode.value) { - case MapSurveyMode.browse: - return Icons.map; - case MapSurveyMode.measure: - return Icons.add_location_alt; - case MapSurveyMode.stakeout: - return Icons.gps_fixed; - case MapSurveyMode.fieldWalk: - return Icons.hiking; - } - } + IconData get currentModeIcon => switch (mode.value) { + MapSurveyMode.browse => Icons.map, + MapSurveyMode.measure => Icons.add_location_alt, + MapSurveyMode.stakeout => Icons.gps_fixed, + MapSurveyMode.fieldWalk => Icons.hiking, + MapSurveyMode.track => Icons.route + }; void openNtripsettings() { if (!Get.isRegistered()) { diff --git a/lib/pages/map_survey/presentations/views/map_survey_view.dart b/lib/pages/map_survey/presentations/views/map_survey_view.dart index 2155a12..ffee9f1 100644 --- a/lib/pages/map_survey/presentations/views/map_survey_view.dart +++ b/lib/pages/map_survey/presentations/views/map_survey_view.dart @@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart'; import 'package:terepi_seged/enums/map_survey_mode.dart'; import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart'; import 'package:terepi_seged/pages/map_survey/presentations/views/settings_dialog.dart'; +import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; import 'package:terepi_seged/utils/rive_utils.dart'; import 'package:terepi_seged/widgets/coordinate_panel.dart'; import 'package:terepi_seged/widgets/map_bottom_panel.dart'; @@ -29,7 +30,18 @@ class MapSurveyView extends GetView { onCenterOnGps: controller.isMapMoveToCenter, layers: [ Obx(() => - MarkerLayer(markers: controller.currentLocationMarker.toList())) + MarkerLayer(markers: controller.currentLocationMarker.toList())), + + // Track polyline + Obx(() { + final isTracking = TrackingController.to.isRecording.value; + final inTrackMode = controller.mode.value == MapSurveyMode.track; + if (!isTracking && !inTrackMode) { + return const SizedBox.shrink(); + } else { + return _buildTrackLayer(); + } + }) ], ), Positioned( @@ -78,6 +90,23 @@ class MapSurveyView extends GetView { // child: MapBottomPanel(controller: controller)) ]); } + + Widget _buildTrackLayer() { + // FutureBuilder helyett a controller livePoints-ból + return Obx(() { + final ctrl = TrackingController.to; + if (ctrl.livePoints.isEmpty) return const SizedBox.shrink(); + return PolylineLayer(polylines: [ + Polyline( + points: ctrl.livePoints + .map((p) => LatLng(p.latitude, p.longitude)) + .toList(), + color: Colors.red.withOpacity(0.85), + strokeWidth: 3.0, + ), + ]); + }); + } } class _ModeSelector extends GetView { diff --git a/lib/pages/tracking/presentation/controllers/tracking_controller.dart b/lib/pages/tracking/presentation/controllers/tracking_controller.dart index 7eabedf..71d2222 100644 --- a/lib/pages/tracking/presentation/controllers/tracking_controller.dart +++ b/lib/pages/tracking/presentation/controllers/tracking_controller.dart @@ -37,6 +37,8 @@ class _TrackingTaskHandler extends TaskHandler { // ─── TrackingController ────────────────────────────────────────────────────── class TrackingController extends GetxController { + static TrackingController get to => Get.find(); + // ── Állapot ──────────────────────────────────────────────────────────────── final isRecording = false.obs; final isPaused = false.obs; @@ -60,6 +62,14 @@ class TrackingController extends GetxController { // ── Mentett track-ek listája ──────────────────────────────────────────────── final savedTracks = [].obs; + // Melyik track-ek látszanak overlay-ként a térképen + final overlayTrackIds = [].obs; + + // Betöltött koordináták cache + final Map> _trackCoords = {}; + + int get livePointCount => livePoints.length; + // ── Belső állapot ────────────────────────────────────────────────────────── LocationSource? _source; StreamSubscription? _positionSub; @@ -188,6 +198,8 @@ class TrackingController extends GetxController { final finished = track!.copyWith( status: TrackStatus.finished, endTime: DateTime.now(), + distanceMeters: _accumulatedDistance, + pointCount: livePoints.length, ); await _db.updateTrack(finished); currentTrack.value = finished; @@ -209,6 +221,8 @@ class TrackingController extends GetxController { Future deleteTrack(int id) async { await _db.deleteTrack(id); + overlayTrackIds.remove(id); + _trackCoords.remove(id); await loadSavedTracks(); } @@ -310,4 +324,26 @@ class TrackingController extends GetxController { } super.onClose(); } + + // Overlay ki/be kapcsolása — ha bekapcsol, betölti a koordinátákat + void toggleTrackOverlay(int trackId) { + if (overlayTrackIds.contains(trackId)) { + overlayTrackIds.remove(trackId); + overlayTrackIds.refresh(); + } else { + overlayTrackIds.add(trackId); + overlayTrackIds.refresh(); + _loadTrackCoords(trackId); + } + } + + /// Koordináták visszaadása — üres lista ha még nincs betöltve + List getCoordsFor(int trackId) => _trackCoords[trackId] ?? []; + + Future _loadTrackCoords(int trackId) async { + if (_trackCoords.containsKey(trackId)) return; + final pts = await _db.getLatLons(trackId); + _trackCoords[trackId] = pts.map((p) => LatLng(p.lat, p.lon)).toList(); + overlayTrackIds.refresh(); // térkép frissítés + } } diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index 5920a4c..29df5c2 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -24,7 +24,12 @@ class AppDatabase { } final directory = await getExternalStorageDirectory(); - final path = p.join(directory!.path, 'database', 'terepi_seged.db'); + final dbDir = Directory(p.join(directory!.path, 'database')); + + if (!await dbDir.exists()) { + await dbDir.create(recursive: true); + } + final path = p.join(dbDir.path, 'terepi_seged.db'); return openDatabase(path, version: 1, onCreate: _onCreate, onUpgrade: _onUpgrade); @@ -74,7 +79,7 @@ class AppDatabase { await db.execute(''' CREATE TABLE IF NOT EXISTS tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, - project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT, @@ -248,15 +253,20 @@ class AppDatabase { Future addPoint(TrackPoint point, double newDistance) async { final db = await database; - await db.transaction((txn) async { - await txn.insert('track_points', point.toMap()); - await txn.rawUpdate(''' + try { + await db.transaction((txn) async { + await txn.insert('track_points', point.toMap()); + await txn.rawUpdate(''' UPDATE tracks - SET distance_meters = ?, + SET distance_m = ?, point_count = point_count + 1 WHERE id = ? ''', [newDistance, point.trackId]); - }); + }); + } catch (e) { + print( + 'addPoint hiba: $e - trackId=${point.trackId} dist=$newDistance'); + } } Future> getPoints(int trackId) async { diff --git a/lib/services/track_database.dart b/lib/services/track_database.dart index 6849f3a..aa55cd4 100644 --- a/lib/services/track_database.dart +++ b/lib/services/track_database.dart @@ -1,5 +1,4 @@ -import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart' as p; +import 'package:terepi_seged/services/app_database.dart'; import '../models/track.dart'; /// SQLite adatbázis-réteg a nyomvonalakhoz. @@ -8,125 +7,16 @@ class TrackDatabase { TrackDatabase._(); static final instance = TrackDatabase._(); - static Database? _db; - - Future get database async { - _db ??= await _open(); - return _db!; - } - - Future _open() async { - final dbPath = p.join(await getDatabasesPath(), 'tracks.db'); - return openDatabase( - dbPath, - version: 1, - onCreate: _onCreate, - ); - } - - Future _onCreate(Database db, int version) async { - await db.execute(''' - CREATE TABLE tracks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - start_time TEXT NOT NULL, - end_time TEXT, - status TEXT NOT NULL DEFAULT 'recording', - source TEXT NOT NULL DEFAULT 'Telefon GPS', - distance_meters REAL NOT NULL DEFAULT 0, - point_count INTEGER NOT NULL DEFAULT 0 - ) - '''); - - await db.execute(''' - CREATE TABLE track_points ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - altitude REAL, - accuracy REAL, - speed REAL, - heading REAL, - timestamp TEXT NOT NULL - ) - '''); - - await db.execute( - 'CREATE INDEX idx_tp_track ON track_points(track_id, timestamp)'); - } - - // ─── Tracks CRUD ─────────────────────────────────────────────────────────── - - Future insertTrack(Track track) async { - final db = await database; - return db.insert('tracks', track.toMap()); - } - - Future updateTrack(Track track) async { - final db = await database; - await db.update('tracks', track.toMap(), - where: 'id = ?', whereArgs: [track.id]); - } - - Future deleteTrack(int id) async { - final db = await database; - await db.delete('tracks', where: 'id = ?', whereArgs: [id]); - } - - Future> listTracks() async { - final db = await database; - final rows = await db.query('tracks', orderBy: 'start_time DESC'); - return rows.map(Track.fromMap).toList(); - } - - Future getTrack(int id) async { - final db = await database; - final rows = - await db.query('tracks', where: 'id = ?', whereArgs: [id], limit: 1); - return rows.isEmpty ? null : Track.fromMap(rows.first); - } - - // ─── TrackPoints ─────────────────────────────────────────────────────────── - - /// Egyetlen pont hozzáadása + track statisztikák atomi frissítése. - Future addPoint(TrackPoint point, double newDistance) async { - final db = await database; - await db.transaction((txn) async { - await txn.insert('track_points', point.toMap()); - await txn.rawUpdate(''' - UPDATE tracks - SET distance_meters = ?, - point_count = point_count + 1 - WHERE id = ? - ''', [newDistance, point.trackId]); - }); - } - - Future> getPoints(int trackId) async { - final db = await database; - final rows = await db.query( - 'track_points', - where: 'track_id = ?', - whereArgs: [trackId], - orderBy: 'timestamp ASC', - ); - return rows.map(TrackPoint.fromMap).toList(); - } - - /// Csak a koordinátákat adja vissza — a térkép polyline-hoz elég. - Future> getLatLons(int trackId) async { - final db = await database; - final rows = await db.query( - 'track_points', - columns: ['latitude', 'longitude'], - where: 'track_id = ?', - whereArgs: [trackId], - orderBy: 'timestamp ASC', - ); - return rows - .map((r) => - (lat: r['latitude'] as double, lon: r['longitude'] as double)) - .toList(); - } + // Minden hívás az AppDatabase-re delegál + Future insertTrack(Track t) => AppDatabase.instance.insertTrack(t); + Future updateTrack(Track t) => AppDatabase.instance.updateTrack(t); + Future deleteTrack(int id) => AppDatabase.instance.deleteTrack(id); + Future> listTracks() => AppDatabase.instance.listTracks(); + Future getTrack(int id) => AppDatabase.instance.getTrack(id); + Future addPoint(TrackPoint p, double d) => + AppDatabase.instance.addPoint(p, d); + Future> getPoints(int id) => + AppDatabase.instance.getPoints(id); + Future> getLatLons(int trackId) => + AppDatabase.instance.getLatLons(trackId); } diff --git a/lib/widgets/map_info_card_column.dart b/lib/widgets/map_info_card_column.dart index 6d9aa92..6b6e970 100644 --- a/lib/widgets/map_info_card_column.dart +++ b/lib/widgets/map_info_card_column.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:terepi_seged/enums/map_survey_mode.dart'; import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart'; import 'package:terepi_seged/widgets/gnss_quality_card.dart'; +import 'package:terepi_seged/widgets/tracking/track_info_card.dart'; import 'package:terepi_seged/widgets/wgs84_coordinate_card.dart'; import 'eov_coordinate_card.dart'; @@ -32,6 +34,10 @@ class MapInfoCardColumn extends StatelessWidget { cards.add(GnssQualityCard(controller: controller)); } + if (controller.mode.value == MapSurveyMode.track) { + cards.add(TrackInfoCard()); + } + if (cards.isEmpty) { return const SizedBox.shrink(); } diff --git a/lib/widgets/map_mode_menu_anchor.dart b/lib/widgets/map_mode_menu_anchor.dart index d06bda7..82baf29 100644 --- a/lib/widgets/map_mode_menu_anchor.dart +++ b/lib/widgets/map_mode_menu_anchor.dart @@ -45,11 +45,11 @@ class MapModeMenuAnchor extends StatelessWidget { child: const Text('Bejárás')), MenuItemButton( leadingIcon: const Icon(Icons.route), - trailingIcon: controller.mode.value == MapSurveyMode.browse + trailingIcon: controller.mode.value == MapSurveyMode.track ? const Icon(Icons.check) : null, - onPressed: () => controller.setMode(MapSurveyMode.browse), - child: const Text('Track')), + onPressed: () => controller.setMode(MapSurveyMode.track), + child: const Text('Útvonal')), ], builder: (context, menuController, child) { return InkWell( diff --git a/lib/widgets/shell_map_appbar.dart b/lib/widgets/shell_map_appbar.dart index 974d14c..a35fdd0 100644 --- a/lib/widgets/shell_map_appbar.dart +++ b/lib/widgets/shell_map_appbar.dart @@ -6,11 +6,14 @@ import 'package:get/get_state_manager/get_state_manager.dart'; import 'package:get/state_manager.dart'; import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart'; import 'package:terepi_seged/pages/shell/presentations/controllers/shell_controller.dart'; +import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/widgets/gnss_status_chip.dart'; import 'package:terepi_seged/widgets/map_mode_menu_anchor.dart'; +import 'tracking/tracking_sheet.dart'; + class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget { final MapSurveyController controller; @@ -105,6 +108,19 @@ class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget { ]); }), actions: [ + Obx(() { + final isRec = TrackingController.to.isRecording.value; + return Badge( + isLabelVisible: isRec, + label: null, // csak piros pont, szám nélkül + backgroundColor: Colors.red, + child: IconButton( + icon: const Icon(Icons.route_outlined), + tooltip: 'Nyomvonal', + onPressed: () => _openTrackingSheet(context), + ), + ); + }), PopupMenuButton( tooltip: 'További funkciók', icon: const Icon(Icons.more_vert), @@ -128,4 +144,13 @@ class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget { if (e < 1.0) return Colors.orange; return Colors.red; } + + void _openTrackingSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const TrackingSheet(), + ); + } } diff --git a/lib/widgets/tracking/handle.dart b/lib/widgets/tracking/handle.dart new file mode 100644 index 0000000..602a351 --- /dev/null +++ b/lib/widgets/tracking/handle.dart @@ -0,0 +1,21 @@ +// Sheet húzható csík +import 'package:flutter/material.dart'; + +class Handle extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/tracking/live_stat_panel.dart b/lib/widgets/tracking/live_stat_panel.dart new file mode 100644 index 0000000..249abf6 --- /dev/null +++ b/lib/widgets/tracking/live_stat_panel.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; + +import 'stat_cell.dart'; + +class LiveStatsPanel extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Obx(() { + final ctrl = TrackingController.to; + final isRec = ctrl.isRecording.value; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: isRec + ? Column(children: [ + // Statisztika sor + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + StatCell( + icon: Icons.timer_outlined, + label: 'Idő', + value: ctrl.elapsedFormatted.value, + large: true), + StatCell( + icon: Icons.route, + label: 'Távolság', + value: _fmtDist(ctrl.sessionDistance.value)), + StatCell( + icon: Icons.location_on_outlined, + label: 'Pontok', + value: '${ctrl.livePointCount}'), + ], + ), + const SizedBox(height: 10), + // Vezérlők + Row(children: [ + Expanded( + child: OutlinedButton.icon( + icon: Icon( + ctrl.isPaused.value ? Icons.play_arrow : Icons.pause), + label: Text(ctrl.isPaused.value ? 'Folytatás' : 'Szünet'), + onPressed: ctrl.isPaused.value + ? ctrl.resumeRecording + : ctrl.pauseRecording, + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + icon: const Icon(Icons.stop), + label: const Text('Befejezés'), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade700), + onPressed: ctrl.stopRecording, + ), + ), + ]), + ]) + : FilledButton.icon( + icon: const Icon(Icons.fiber_manual_record), + label: const Text('Rögzítés indítása'), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade700), + onPressed: () => _showStartDialog(context), + ), + ); + }); + } + + void _showStartDialog(BuildContext context) { + final nameCtrl = TextEditingController( + text: 'Track ${DateTime.now().toString().substring(5, 16)}', + ); + Get.dialog(AlertDialog( + title: const Text('Rögzítés indítása'), + content: TextField( + controller: nameCtrl, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Track neve', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('Mégse')), + FilledButton( + onPressed: () { + Get.back(); + TrackingController.to.startRecording(); + }, + child: const Text('Indítás'), + ), + ], + )); + } + + String _fmtDist(double m) => m < 1000 + ? '${m.toStringAsFixed(0)} m' + : '${(m / 1000).toStringAsFixed(2)} km'; +} diff --git a/lib/widgets/tracking/section_header.dart b/lib/widgets/tracking/section_header.dart new file mode 100644 index 0000000..36744fe --- /dev/null +++ b/lib/widgets/tracking/section_header.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +// Szekció fejléc (pl. "Korábbi útvonalak") +class SectionHeader extends StatelessWidget { + final String title; + final Widget? trailing; + + const SectionHeader({ + required this.title, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), + child: Row(children: [ + Text( + title.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey.shade500, + letterSpacing: 0.6, + ), + ), + const Spacer(), + if (trailing != null) trailing!, + ]), + ); + } +} diff --git a/lib/widgets/tracking/stat_cell.dart b/lib/widgets/tracking/stat_cell.dart new file mode 100644 index 0000000..e1e5cb6 --- /dev/null +++ b/lib/widgets/tracking/stat_cell.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class StatCell extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final bool large; + + const StatCell({ + super.key, + required this.icon, + required this.label, + required this.value, + this.large = false, + }); + + @override + Widget build(BuildContext context) { + return Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 16, color: Colors.grey), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: large ? 20 : 15, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + label, + style: const TextStyle( + fontSize: 10, + color: Colors.grey, + ), + ), + ]); + } +} diff --git a/lib/widgets/tracking/track_info_card.dart b/lib/widgets/tracking/track_info_card.dart new file mode 100644 index 0000000..3bfb4c4 --- /dev/null +++ b/lib/widgets/tracking/track_info_card.dart @@ -0,0 +1,356 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; + +import 'tracking_sheet.dart'; + +/// Kompakt tracking státusz kártya a térkép bal felső sarkában. +/// +/// Rögzítés közben: idő, távolság, pontok + szünet/stop gombok +/// Rögzítésen kívül: egyetlen start gomb +/// +/// Mindig látható — tap → TrackingSheet (teljes lista + vezérlők) +class TrackInfoCard extends StatelessWidget { + const TrackInfoCard({super.key}); + + @override + Widget build(BuildContext context) { + return Obx(() { + final ctrl = TrackingController.to; + final isRec = ctrl.isRecording.value; + + return GestureDetector( + onTap: () => _openSheet(context), + child: Container( + constraints: const BoxConstraints(minWidth: 140), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.72), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isRec + ? Colors.red.withOpacity(0.5) + : Colors.white.withOpacity(0.12), + ), + ), + child: isRec ? _RecordingContent(ctrl: ctrl) : _IdleContent(), + ), + ); + }); + } + + void _openSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const TrackingSheet(), + ); + } +} + +// ─── Rögzítés közben ────────────────────────────────────────────────────────── + +class _RecordingContent extends StatelessWidget { + final TrackingController ctrl; + const _RecordingContent({required this.ctrl}); + + @override + Widget build(BuildContext context) { + return Obx(() => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Fejléc sor — piros pont + "REC" vagy "SZÜNET" + Row(mainAxisSize: MainAxisSize.min, children: [ + _StatusDot(paused: ctrl.isPaused.value), + const SizedBox(width: 5), + Text( + ctrl.isPaused.value ? 'SZÜNET' : 'REC', + style: TextStyle( + color: ctrl.isPaused.value ? Colors.orange : Colors.red, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + const Spacer(), + // Kártya bezárása (csak megkisebbíti, nem állítja le) + GestureDetector( + onTap: () {}, // placeholder — a kártya mindig látható + child: Icon(Icons.unfold_less, + size: 14, color: Colors.white.withOpacity(0.3)), + ), + ]), + + const SizedBox(height: 4), + + // Statisztika sor + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _MiniStat( + icon: Icons.timer_outlined, + value: ctrl.elapsedFormatted.value, + ), + const SizedBox(width: 10), + _MiniStat( + icon: Icons.route, + value: _fmtDist(ctrl.sessionDistance.value), + ), + const SizedBox(width: 10), + _MiniStat( + icon: Icons.location_on_outlined, + value: '${ctrl.livePoints.length}', + ), + ], + ), + + const SizedBox(height: 6), + + // Vezérlő gombok + Row(children: [ + // Szünet / Folytatás + _CardButton( + icon: ctrl.isPaused.value ? Icons.play_arrow : Icons.pause, + color: ctrl.isPaused.value ? Colors.greenAccent : Colors.orange, + onTap: ctrl.isPaused.value + ? ctrl.resumeRecording + : ctrl.pauseRecording, + tooltip: ctrl.isPaused.value ? 'Folytatás' : 'Szünet', + ), + + const SizedBox(width: 6), + + // Stop + _CardButton( + icon: Icons.stop, + color: Colors.red, + onTap: () => _confirmStop(context), + tooltip: 'Befejezés', + ), + + const Spacer(), + + // Nyíl → sheet megnyitás + Icon(Icons.keyboard_arrow_up, + size: 14, color: Colors.white.withOpacity(0.35)), + ]), + ], + )); + } + + void _confirmStop(BuildContext context) { + Get.dialog(AlertDialog( + title: const Text('Rögzítés befejezése'), + content: Obx(() => Text( + '${ctrl.elapsedFormatted.value} · ' + '${_fmtDist(ctrl.sessionDistance.value)}\n' + '${ctrl.livePoints.length} pont', + )), + actions: [ + TextButton(onPressed: Get.back, child: const Text('Mégse')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), + onPressed: () { + Get.back(); + ctrl.stopRecording(); + }, + child: const Text('Befejezés'), + ), + ], + )); + } + + String _fmtDist(double m) => m < 1000 + ? '${m.toStringAsFixed(0)} m' + : '${(m / 1000).toStringAsFixed(2)} km'; +} + +// ─── Idle állapot ───────────────────────────────────────────────────────────── + +class _IdleContent extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.route_outlined, + size: 14, color: Colors.white.withOpacity(0.5)), + const SizedBox(width: 6), + Text( + 'Track', + style: TextStyle( + color: Colors.white.withOpacity(0.55), + fontSize: 12, + ), + ), + const SizedBox(width: 8), + // Indítás gomb + GestureDetector( + onTap: () => _startRecording(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.2), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.red.withOpacity(0.4)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.fiber_manual_record, size: 10, color: Colors.red), + SizedBox(width: 4), + Text('Start', + style: TextStyle( + color: Colors.red, + fontSize: 11, + fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ], + ); + } + + void _startRecording(BuildContext context) { + final nameCtrl = TextEditingController( + text: 'Track ${DateTime.now().toString().substring(5, 16)}', + ); + Get.dialog(AlertDialog( + title: const Text('Rögzítés indítása'), + content: TextField( + controller: nameCtrl, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Track neve', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('Mégse')), + FilledButton( + onPressed: () { + Get.back(); + TrackingController.to.startRecording(); + // .startRecording(name: nameCtrl.text.trim()); + }, + child: const Text('Indítás'), + ), + ], + )); + } +} + +// ─── Segéd widgetek ─────────────────────────────────────────────────────────── + +class _StatusDot extends StatefulWidget { + final bool paused; + const _StatusDot({required this.paused}); + + @override + State<_StatusDot> createState() => _StatusDotState(); +} + +class _StatusDotState extends State<_StatusDot> + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _anim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + )..repeat(reverse: true); + _anim = Tween(begin: 0.3, end: 1.0) + .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut)); + } + + @override + void didUpdateWidget(_StatusDot old) { + super.didUpdateWidget(old); + widget.paused ? _ctrl.stop() : _ctrl.repeat(reverse: true); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = widget.paused ? Colors.orange : Colors.red; + return AnimatedBuilder( + animation: _anim, + builder: (_, __) => Opacity( + opacity: widget.paused ? 0.6 : _anim.value, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + ), + ); + } +} + +class _MiniStat extends StatelessWidget { + final IconData icon; + final String value; + const _MiniStat({required this.icon, required this.value}); + + @override + Widget build(BuildContext context) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 11, color: Colors.white54), + const SizedBox(width: 3), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ]); + } +} + +class _CardButton extends StatelessWidget { + final IconData icon; + final Color color; + final VoidCallback onTap; + final String tooltip; + + const _CardButton({ + required this.icon, + required this.color, + required this.onTap, + required this.tooltip, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 28, + height: 24, + decoration: BoxDecoration( + color: color.withOpacity(0.18), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.45)), + ), + child: Icon(icon, size: 14, color: color), + ), + ), + ); + } +} diff --git a/lib/widgets/tracking/track_list_item.dart b/lib/widgets/tracking/track_list_item.dart new file mode 100644 index 0000000..7e411fb --- /dev/null +++ b/lib/widgets/tracking/track_list_item.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/models/track.dart'; +import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; + +class TrackListItem extends StatelessWidget { + final Track track; + + const TrackListItem({ + super.key, + required this.track, + }); + + @override + Widget build(BuildContext context) { + return Obx(() { + final ctrl = TrackingController.to; + final isOverlay = ctrl.overlayTrackIds.contains(track.id); + + return ListTile( + leading: CircleAvatar( + backgroundColor: isOverlay + ? Colors.blue.withOpacity(0.2) + : Theme.of(context).colorScheme.surfaceVariant, + child: Icon(Icons.route, + color: isOverlay ? Colors.blue : Colors.grey, size: 20), + ), + title: Text(track.name, style: const TextStyle(fontSize: 13)), + subtitle: Text( + '${_fmtDist(track.distanceMeters)} · ' + '${track.durationFormatted} · ' + '${track.pointCount} pt', + style: const TextStyle(fontSize: 11), + ), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + // Overlay kapcsoló + IconButton( + icon: Icon( + isOverlay ? Icons.layers : Icons.layers_outlined, + color: isOverlay ? Colors.blue : null, + size: 20, + ), + tooltip: isOverlay ? 'Elrejt' : 'Mutat a térképen', + onPressed: () { + ctrl.toggleTrackOverlay(track.id!); + // Ha bekapcsolta → zárjuk be a sheet-et + // hogy lássa a térképet + if (!isOverlay) Navigator.pop(context); + }, + ), + + // Export + IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () => ctrl.exportTrack(track), + ), + ]), + ); + }); + } + + String _fmtDist(double m) => m < 1000 + ? '${m.toStringAsFixed(0)} m' + : '${(m / 1000).toStringAsFixed(2)} km'; +} diff --git a/lib/widgets/tracking/tracking_sheet.dart b/lib/widgets/tracking/tracking_sheet.dart new file mode 100644 index 0000000..91f8439 --- /dev/null +++ b/lib/widgets/tracking/tracking_sheet.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; + +import 'handle.dart'; +import 'live_stat_panel.dart'; +import 'section_header.dart'; +import 'track_list_item.dart'; + +class TrackingSheet extends StatelessWidget { + const TrackingSheet(); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.35, // kezdetben kis méret + minChildSize: 0.2, + maxChildSize: 0.85, // felfelé húzva nagy lista + snap: true, + snapSizes: const [0.2, 0.35, 0.85], + builder: (_, scrollCtrl) => Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: CustomScrollView( + controller: scrollCtrl, + slivers: [ + // Handle + SliverToBoxAdapter(child: Handle()), + + // Élő statisztika panel + SliverToBoxAdapter( + child: LiveStatsPanel(), + ), + + // Mentett track-ek fejléc + SliverToBoxAdapter( + child: SectionHeader( + title: 'Korábbi útvonalak', + trailing: TextButton( + onPressed: TrackingController.to.loadSavedTracks, + child: const Text('Frissítés'), + ), + ), + ), + + // Track lista + Obx(() => SliverList.separated( + itemCount: TrackingController.to.savedTracks.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => TrackListItem( + track: TrackingController.to.savedTracks[i], + ), + )), + + const SliverPadding(padding: EdgeInsets.only(bottom: 20)), + ], + ), + ), + ); + } +}