Tracking funkció fejlesztése

This commit is contained in:
torok.istvan 2026-06-11 01:20:55 +02:00
parent 2364b2311c
commit 01e6105240
18 changed files with 858 additions and 162 deletions

View File

@ -1 +1 @@
enum MapSurveyMode { browse, measure, stakeout, fieldWalk } enum MapSurveyMode { browse, measure, stakeout, fieldWalk, track }

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.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/routes/app_pages.dart';
import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/coord_converter_service.dart'; import 'package:terepi_seged/services/coord_converter_service.dart';
@ -26,6 +27,7 @@ Future<void> main() async {
Get.put(GnssDeviceService()); Get.put(GnssDeviceService());
Get.put(GnssService()); Get.put(GnssService());
Get.put(NtripService()); Get.put(NtripService());
Get.put(TrackingController(), permanent: true);
runApp(const MyApp()); runApp(const MyApp());
} }

View File

@ -58,6 +58,7 @@ enum TrackStatus { recording, paused, finished }
class Track { class Track {
final int? id; final int? id;
final int? projectId;
final String name; final String name;
final DateTime startTime; final DateTime startTime;
final DateTime? endTime; final DateTime? endTime;
@ -70,6 +71,7 @@ class Track {
const Track({ const Track({
this.id, this.id,
this.projectId,
required this.name, required this.name,
required this.startTime, required this.startTime,
this.endTime, this.endTime,
@ -81,6 +83,7 @@ class Track {
Track copyWith({ Track copyWith({
int? id, int? id,
int? projectId,
String? name, String? name,
DateTime? startTime, DateTime? startTime,
DateTime? endTime, DateTime? endTime,
@ -91,6 +94,7 @@ class Track {
}) => }) =>
Track( Track(
id: id ?? this.id, id: id ?? this.id,
projectId: projectId ?? this.projectId,
name: name ?? this.name, name: name ?? this.name,
startTime: startTime ?? this.startTime, startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime, endTime: endTime ?? this.endTime,
@ -120,25 +124,30 @@ class Track {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
if (id != null) 'id': id, if (id != null) 'id': id,
if (projectId != null) 'project_id': projectId,
'name': name, 'name': name,
'start_time': startTime.toIso8601String(), 'start_time': startTime.toIso8601String(),
'end_time': endTime?.toIso8601String(), 'end_time': endTime?.toIso8601String(),
'status': status.name, 'status': status.name,
'source': source, 'source': source,
'distance_meters': distanceMeters, 'distance_m': distanceMeters,
'point_count': pointCount, 'point_count': pointCount,
}; };
factory Track.fromMap(Map<String, dynamic> m) => Track( factory Track.fromMap(Map<String, dynamic> m) => Track(
id: m['id'] as int?, id: m['id'] as int?,
projectId: m['project_id'] as int?,
name: m['name'] as String, name: m['name'] as String,
startTime: DateTime.parse(m['start_time'] as String), startTime: DateTime.parse(m['start_time'] as String),
endTime: m['end_time'] != null endTime: m['end_time'] != null
? DateTime.parse(m['end_time'] as String) ? DateTime.parse(m['end_time'] as String)
: null, : 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', 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, pointCount: m['point_count'] as int? ?? 0,
); );
} }

View File

@ -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/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/controllers/ntrip_settings_controller.dart';
import 'package:terepi_seged/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.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/coord_converter_service.dart';
import 'package:terepi_seged/services/gnss/gnss_connection.dart'; import 'package:terepi_seged/services/gnss/gnss_connection.dart';
import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
@ -285,6 +286,18 @@ class MapSurveyController extends GetxController {
} }
void setMode(MapSurveyMode newMode) { 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; mode.value = newMode;
// Itt lehet módhoz kötött állapotokat állítani: // 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. // - track indítás/leállítás figyelmeztetés stb.
} }
String get currentModeLabel { String get currentModeLabel => switch (mode.value) {
switch (mode.value) { MapSurveyMode.browse => 'Térkép',
case MapSurveyMode.browse: MapSurveyMode.measure => 'Bemérés',
return 'Térkép'; MapSurveyMode.stakeout => 'Kitűzés',
case MapSurveyMode.measure: MapSurveyMode.fieldWalk => 'Bejárás',
return 'Bemérés'; MapSurveyMode.track => 'Útvonal'
case MapSurveyMode.stakeout: };
return 'Kitűzés';
case MapSurveyMode.fieldWalk:
return 'Bejárás';
}
}
IconData get currentModeIcon { IconData get currentModeIcon => switch (mode.value) {
switch (mode.value) { MapSurveyMode.browse => Icons.map,
case MapSurveyMode.browse: MapSurveyMode.measure => Icons.add_location_alt,
return Icons.map; MapSurveyMode.stakeout => Icons.gps_fixed,
case MapSurveyMode.measure: MapSurveyMode.fieldWalk => Icons.hiking,
return Icons.add_location_alt; MapSurveyMode.track => Icons.route
case MapSurveyMode.stakeout: };
return Icons.gps_fixed;
case MapSurveyMode.fieldWalk:
return Icons.hiking;
}
}
void openNtripsettings() { void openNtripsettings() {
if (!Get.isRegistered<NtripSettingsController>()) { if (!Get.isRegistered<NtripSettingsController>()) {

View File

@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
import 'package:terepi_seged/enums/map_survey_mode.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/controllers/map_survey_controller.dart';
import 'package:terepi_seged/pages/map_survey/presentations/views/settings_dialog.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/utils/rive_utils.dart';
import 'package:terepi_seged/widgets/coordinate_panel.dart'; import 'package:terepi_seged/widgets/coordinate_panel.dart';
import 'package:terepi_seged/widgets/map_bottom_panel.dart'; import 'package:terepi_seged/widgets/map_bottom_panel.dart';
@ -29,7 +30,18 @@ class MapSurveyView extends GetView<MapSurveyController> {
onCenterOnGps: controller.isMapMoveToCenter, onCenterOnGps: controller.isMapMoveToCenter,
layers: [ layers: [
Obx(() => 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( Positioned(
@ -78,6 +90,23 @@ class MapSurveyView extends GetView<MapSurveyController> {
// child: MapBottomPanel(controller: controller)) // 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<MapSurveyController> { class _ModeSelector extends GetView<MapSurveyController> {

View File

@ -37,6 +37,8 @@ class _TrackingTaskHandler extends TaskHandler {
// TrackingController // TrackingController
class TrackingController extends GetxController { class TrackingController extends GetxController {
static TrackingController get to => Get.find();
// Állapot // Állapot
final isRecording = false.obs; final isRecording = false.obs;
final isPaused = false.obs; final isPaused = false.obs;
@ -60,6 +62,14 @@ class TrackingController extends GetxController {
// Mentett track-ek listája // Mentett track-ek listája
final savedTracks = <Track>[].obs; final savedTracks = <Track>[].obs;
// Melyik track-ek látszanak overlay-ként a térképen
final overlayTrackIds = <int>[].obs;
// Betöltött koordináták cache
final Map<int, List<LatLng>> _trackCoords = {};
int get livePointCount => livePoints.length;
// Belső állapot // Belső állapot
LocationSource? _source; LocationSource? _source;
StreamSubscription<SourcePosition>? _positionSub; StreamSubscription<SourcePosition>? _positionSub;
@ -188,6 +198,8 @@ class TrackingController extends GetxController {
final finished = track!.copyWith( final finished = track!.copyWith(
status: TrackStatus.finished, status: TrackStatus.finished,
endTime: DateTime.now(), endTime: DateTime.now(),
distanceMeters: _accumulatedDistance,
pointCount: livePoints.length,
); );
await _db.updateTrack(finished); await _db.updateTrack(finished);
currentTrack.value = finished; currentTrack.value = finished;
@ -209,6 +221,8 @@ class TrackingController extends GetxController {
Future<void> deleteTrack(int id) async { Future<void> deleteTrack(int id) async {
await _db.deleteTrack(id); await _db.deleteTrack(id);
overlayTrackIds.remove(id);
_trackCoords.remove(id);
await loadSavedTracks(); await loadSavedTracks();
} }
@ -310,4 +324,26 @@ class TrackingController extends GetxController {
} }
super.onClose(); 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<LatLng> getCoordsFor(int trackId) => _trackCoords[trackId] ?? [];
Future<void> _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
}
} }

View File

@ -24,7 +24,12 @@ class AppDatabase {
} }
final directory = await getExternalStorageDirectory(); 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, return openDatabase(path,
version: 1, onCreate: _onCreate, onUpgrade: _onUpgrade); version: 1, onCreate: _onCreate, onUpgrade: _onUpgrade);
@ -74,7 +79,7 @@ class AppDatabase {
await db.execute(''' await db.execute('''
CREATE TABLE IF NOT EXISTS tracks ( CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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, name TEXT NOT NULL,
start_time TEXT NOT NULL, start_time TEXT NOT NULL,
end_time TEXT, end_time TEXT,
@ -248,15 +253,20 @@ class AppDatabase {
Future<void> addPoint(TrackPoint point, double newDistance) async { Future<void> addPoint(TrackPoint point, double newDistance) async {
final db = await database; final db = await database;
await db.transaction((txn) async { try {
await txn.insert('track_points', point.toMap()); await db.transaction((txn) async {
await txn.rawUpdate(''' await txn.insert('track_points', point.toMap());
await txn.rawUpdate('''
UPDATE tracks UPDATE tracks
SET distance_meters = ?, SET distance_m = ?,
point_count = point_count + 1 point_count = point_count + 1
WHERE id = ? WHERE id = ?
''', [newDistance, point.trackId]); ''', [newDistance, point.trackId]);
}); });
} catch (e) {
print(
'addPoint hiba: $e - trackId=${point.trackId} dist=$newDistance');
}
} }
Future<List<TrackPoint>> getPoints(int trackId) async { Future<List<TrackPoint>> getPoints(int trackId) async {

View File

@ -1,5 +1,4 @@
import 'package:sqflite/sqflite.dart'; import 'package:terepi_seged/services/app_database.dart';
import 'package:path/path.dart' as p;
import '../models/track.dart'; import '../models/track.dart';
/// SQLite adatbázis-réteg a nyomvonalakhoz. /// SQLite adatbázis-réteg a nyomvonalakhoz.
@ -8,125 +7,16 @@ class TrackDatabase {
TrackDatabase._(); TrackDatabase._();
static final instance = TrackDatabase._(); static final instance = TrackDatabase._();
static Database? _db; // Minden hívás az AppDatabase-re delegál
Future<int> insertTrack(Track t) => AppDatabase.instance.insertTrack(t);
Future<Database> get database async { Future<void> updateTrack(Track t) => AppDatabase.instance.updateTrack(t);
_db ??= await _open(); Future<void> deleteTrack(int id) => AppDatabase.instance.deleteTrack(id);
return _db!; Future<List<Track>> listTracks() => AppDatabase.instance.listTracks();
} Future<Track?> getTrack(int id) => AppDatabase.instance.getTrack(id);
Future<void> addPoint(TrackPoint p, double d) =>
Future<Database> _open() async { AppDatabase.instance.addPoint(p, d);
final dbPath = p.join(await getDatabasesPath(), 'tracks.db'); Future<List<TrackPoint>> getPoints(int id) =>
return openDatabase( AppDatabase.instance.getPoints(id);
dbPath, Future<List<({double lat, double lon})>> getLatLons(int trackId) =>
version: 1, AppDatabase.instance.getLatLons(trackId);
onCreate: _onCreate,
);
}
Future<void> _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<int> insertTrack(Track track) async {
final db = await database;
return db.insert('tracks', track.toMap());
}
Future<void> updateTrack(Track track) async {
final db = await database;
await db.update('tracks', track.toMap(),
where: 'id = ?', whereArgs: [track.id]);
}
Future<void> deleteTrack(int id) async {
final db = await database;
await db.delete('tracks', where: 'id = ?', whereArgs: [id]);
}
Future<List<Track>> listTracks() async {
final db = await database;
final rows = await db.query('tracks', orderBy: 'start_time DESC');
return rows.map(Track.fromMap).toList();
}
Future<Track?> 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<void> 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<List<TrackPoint>> 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<List<({double lat, double lon})>> 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();
}
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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/pages/map_survey/presentations/controllers/map_survey_controller.dart';
import 'package:terepi_seged/widgets/gnss_quality_card.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 'package:terepi_seged/widgets/wgs84_coordinate_card.dart';
import 'eov_coordinate_card.dart'; import 'eov_coordinate_card.dart';
@ -32,6 +34,10 @@ class MapInfoCardColumn extends StatelessWidget {
cards.add(GnssQualityCard(controller: controller)); cards.add(GnssQualityCard(controller: controller));
} }
if (controller.mode.value == MapSurveyMode.track) {
cards.add(TrackInfoCard());
}
if (cards.isEmpty) { if (cards.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }

View File

@ -45,11 +45,11 @@ class MapModeMenuAnchor extends StatelessWidget {
child: const Text('Bejárás')), child: const Text('Bejárás')),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(Icons.route), leadingIcon: const Icon(Icons.route),
trailingIcon: controller.mode.value == MapSurveyMode.browse trailingIcon: controller.mode.value == MapSurveyMode.track
? const Icon(Icons.check) ? const Icon(Icons.check)
: null, : null,
onPressed: () => controller.setMode(MapSurveyMode.browse), onPressed: () => controller.setMode(MapSurveyMode.track),
child: const Text('Track')), child: const Text('Útvonal')),
], ],
builder: (context, menuController, child) { builder: (context, menuController, child) {
return InkWell( return InkWell(

View File

@ -6,11 +6,14 @@ import 'package:get/get_state_manager/get_state_manager.dart';
import 'package: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/map_survey/presentations/controllers/map_survey_controller.dart';
import 'package:terepi_seged/pages/shell/presentations/controllers/shell_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/gnss/gnss_service.dart';
import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart';
import 'package:terepi_seged/widgets/gnss_status_chip.dart'; import 'package:terepi_seged/widgets/gnss_status_chip.dart';
import 'package:terepi_seged/widgets/map_mode_menu_anchor.dart'; import 'package:terepi_seged/widgets/map_mode_menu_anchor.dart';
import 'tracking/tracking_sheet.dart';
class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget { class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget {
final MapSurveyController controller; final MapSurveyController controller;
@ -105,6 +108,19 @@ class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget {
]); ]);
}), }),
actions: [ 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( PopupMenuButton(
tooltip: 'További funkciók', tooltip: 'További funkciók',
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
@ -128,4 +144,13 @@ class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget {
if (e < 1.0) return Colors.orange; if (e < 1.0) return Colors.orange;
return Colors.red; return Colors.red;
} }
void _openTrackingSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const TrackingSheet(),
);
}
} }

View File

@ -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),
),
),
),
);
}
}

View File

@ -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';
}

View File

@ -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!,
]),
);
}
}

View File

@ -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,
),
),
]);
}
}

View File

@ -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<double> _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),
),
),
);
}
}

View File

@ -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';
}

View File

@ -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()),
// É 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)),
],
),
),
);
}
}