diff --git a/android/app/build.gradle b/android/app/build.gradle
index 34abfae..85153fd 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -14,10 +14,11 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "hu.app_dev.terepi_seged"
- compileSdk 35
+ compileSdk 36
// ndkVersion "25.1.8937393"
// ndkVersion flutter.ndkVersion
- ndkVersion "27.0.12077973"
+ //ndkVersion "27.0.12077973"
+ ndkVersion "28.2.13676358"
compileOptions {
// sourceCompatibility JavaVersion.VERSION_1_8
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 817fb17..e140772 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -62,5 +62,7 @@
android:usesPermissionFlags="neverForLocation" tools:targetApi="s"/>
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
index 94adc3a..eac5ff9 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 37f853b..6514f91 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/android/settings.gradle b/android/settings.gradle
index 1b854e9..29dc757 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -18,8 +18,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
- id "com.android.application" version "8.7.1" apply false
- id "org.jetbrains.kotlin.android" version "2.2.0" apply false
+ id "com.android.application" version "8.11.1" apply false
+ id "org.jetbrains.kotlin.android" version "2.2.20" apply false
id "com.google.gms.google-services" version "4.4.0" apply false
id "com.google.firebase.crashlytics" version "2.9.9" apply false
}
diff --git a/lib/main.dart b/lib/main.dart
index e747464..7c6de84 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -9,6 +9,8 @@ import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
import 'package:terepi_seged/services/gnss/gnss_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';
import 'package:terepi_seged/services/project_service.dart';
@@ -30,6 +32,8 @@ Future main() async {
Get.put(GnssService());
Get.put(NtripService());
Get.put(TrackingController(), permanent: true);
+ Get.put(NotePhotoService(), permanent: true);
+ Get.put(NoteAudioService(), permanent: true);
runApp(const MyApp());
}
diff --git a/lib/models/note_item_audio.dart b/lib/models/note_item_audio.dart
new file mode 100644
index 0000000..f0a96a9
--- /dev/null
+++ b/lib/models/note_item_audio.dart
@@ -0,0 +1,70 @@
+import 'dart:io';
+import 'package:latlong2/latlong.dart';
+
+class NoteItemAudio {
+ final int? id;
+ final int noteItemId;
+ final String localPath; // .m4a fájl
+ final String caption; // opcionális felirat
+ final int durationSeconds; // másodpercben
+ final double? latitude;
+ final double? longitude;
+ final DateTime createdAt;
+
+ const NoteItemAudio({
+ this.id,
+ required this.noteItemId,
+ required this.localPath,
+ this.caption = '',
+ this.durationSeconds = 0,
+ this.latitude,
+ this.longitude,
+ required this.createdAt,
+ });
+
+ File get file => File(localPath);
+ bool get fileExists => file.existsSync();
+ LatLng? get location => latitude != null && longitude != null
+ ? LatLng(latitude!, longitude!)
+ : null;
+
+ /// Formázott időtartam: "0:42" vagy "1:23"
+ String get durationFormatted {
+ final m = durationSeconds ~/ 60;
+ final s = (durationSeconds % 60).toString().padLeft(2, '0');
+ return '$m:$s';
+ }
+
+ NoteItemAudio copyWith({String? caption}) => NoteItemAudio(
+ id: id,
+ noteItemId: noteItemId,
+ localPath: localPath,
+ caption: caption ?? this.caption,
+ durationSeconds: durationSeconds,
+ latitude: latitude,
+ longitude: longitude,
+ createdAt: createdAt,
+ );
+
+ Map toMap() => {
+ if (id != null) 'id': id,
+ 'note_item_id': noteItemId,
+ 'local_path': localPath,
+ 'caption': caption,
+ 'duration_seconds': durationSeconds,
+ 'latitude': latitude,
+ 'longitude': longitude,
+ 'created_at': createdAt.toIso8601String(),
+ };
+
+ factory NoteItemAudio.fromMap(Map m) => NoteItemAudio(
+ id: m['id'] as int?,
+ noteItemId: m['note_item_id'] as int,
+ localPath: m['local_path'] as String,
+ caption: m['caption'] as String? ?? '',
+ durationSeconds: m['duration_seconds'] as int? ?? 0,
+ latitude: (m['latitude'] as num?)?.toDouble(),
+ longitude: (m['longitude'] as num?)?.toDouble(),
+ createdAt: DateTime.parse(m['created_at'] as String),
+ );
+}
diff --git a/lib/models/note_item_photo.dart b/lib/models/note_item_photo.dart
new file mode 100644
index 0000000..49b28a5
--- /dev/null
+++ b/lib/models/note_item_photo.dart
@@ -0,0 +1,70 @@
+// lib/models/note_item_photo.dart
+
+import 'dart:io';
+import 'package:latlong2/latlong.dart';
+
+class NoteItemPhoto {
+ final int? id;
+ final int noteItemId;
+ final String localPath; // teljes elérési út a fájlhoz
+ final String? storagePath; // Supabase Storage (megosztáskor)
+ final String caption; // opcionális megjegyzés
+ final double? latitude; // ahol a fotó készült
+ final double? longitude;
+ final DateTime createdAt;
+
+ const NoteItemPhoto({
+ this.id,
+ required this.noteItemId,
+ required this.localPath,
+ this.storagePath,
+ this.caption = '',
+ this.latitude,
+ this.longitude,
+ required this.createdAt,
+ });
+
+ File get file => File(localPath);
+ bool get fileExists => file.existsSync();
+ bool get isShared => storagePath != null;
+ LatLng? get location => latitude != null && longitude != null
+ ? LatLng(latitude!, longitude!)
+ : null;
+
+ NoteItemPhoto copyWith({
+ String? caption,
+ String? storagePath,
+ }) =>
+ NoteItemPhoto(
+ id: id,
+ noteItemId: noteItemId,
+ localPath: localPath,
+ storagePath: storagePath ?? this.storagePath,
+ caption: caption ?? this.caption,
+ latitude: latitude,
+ longitude: longitude,
+ createdAt: createdAt,
+ );
+
+ Map toMap() => {
+ if (id != null) 'id': id,
+ 'note_item_id': noteItemId,
+ 'local_path': localPath,
+ 'storage_path': storagePath,
+ 'caption': caption,
+ 'latitude': latitude,
+ 'longitude': longitude,
+ 'created_at': createdAt.toIso8601String(),
+ };
+
+ factory NoteItemPhoto.fromMap(Map m) => NoteItemPhoto(
+ id: m['id'] as int?,
+ noteItemId: m['note_item_id'] as int,
+ localPath: m['local_path'] as String,
+ storagePath: m['storage_path'] as String?,
+ caption: m['caption'] as String? ?? '',
+ latitude: (m['latitude'] as num?)?.toDouble(),
+ longitude: (m['longitude'] as num?)?.toDouble(),
+ createdAt: DateTime.parse(m['created_at'] as String),
+ );
+}
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 25cc15f..66947ed 100644
--- a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart
+++ b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart
@@ -194,8 +194,8 @@ class MapSurveyController extends GetxController {
bool get isMapEditing => activeEditTool.value != MapEditTool.none;
final selectedNoteItemId = Rx(null);
final selectedNoteItemType = NoteType.line.obs;
- int? _editingNoteItemId;
- bool get isGeometryEditing => _editingNoteItemId != null;
+ int? editingNoteItemId;
+ bool get isGeometryEditing => editingNoteItemId != null;
final draftLengthMeters = 0.0.obs;
final draftAreaSquareMeters = 0.0.obs;
@@ -1107,7 +1107,7 @@ class MapSurveyController extends GetxController {
void cancelEditing() {
polygonEditorController.clear();
activeEditTool.value = MapEditTool.none;
- _editingNoteItemId = null;
+ editingNoteItemId = null;
selectedNoteItemId.value = null;
editorPointCount.value = 0;
draftAreaSquareMeters.value = 0.0;
@@ -1243,7 +1243,7 @@ class MapSurveyController extends GetxController {
}
Future finishDraft() async {
- if (_editingNoteItemId != null) {
+ if (editingNoteItemId != null) {
if (polygonEditorController.points.isEmpty) {
await _finishStyleUpdate();
} else {
@@ -1257,7 +1257,7 @@ class MapSurveyController extends GetxController {
}
Future _finishStyleUpdate() async {
- final id = _editingNoteItemId!;
+ final id = editingNoteItemId!;
final existing = await AppDatabase.instance.getNoteItem(id);
if (existing == null) return;
@@ -1270,18 +1270,18 @@ class MapSurveyController extends GetxController {
);
await updateNoteItem(updated);
- _editingNoteItemId = null;
+ editingNoteItemId = null;
activeEditTool.value = MapEditTool.none;
draftAreaSquareMeters.value = 0.0;
draftLengthMeters.value = 0.0;
}
Future deleteEditingItem() async {
- final id = _editingNoteItemId;
+ final id = editingNoteItemId;
if (id == null) return;
final item = await AppDatabase.instance.getNoteItem(id);
if (item != null) await deleteNoteItem(item);
- _editingNoteItemId = null;
+ editingNoteItemId = null;
selectedNoteItemId.value = null;
activeEditTool.value = MapEditTool.none;
draftAreaSquareMeters.value = 0.0;
@@ -1323,10 +1323,10 @@ class MapSurveyController extends GetxController {
// }
void saveEditedPoint({required LatLng point}) {
- if (_editingNoteItemId != null) {
+ if (editingNoteItemId != null) {
// ── PONT FRISSÍTÉSI MÓD ──────────────────────────────────────
- final id = _editingNoteItemId!;
- _editingNoteItemId = null;
+ final id = editingNoteItemId!;
+ editingNoteItemId = null;
activeEditTool.value = MapEditTool.none;
AppDatabase.instance.getNoteItem(id).then((existing) async {
@@ -1375,7 +1375,7 @@ class MapSurveyController extends GetxController {
final item = await AppDatabase.instance.getNoteItem(id);
if (item == null) return;
- _editingNoteItemId = item.id;
+ editingNoteItemId = item.id;
activeEditColor.value = item.color;
activeEditOpacity.value = item.opacity;
activeEditStrokeWidth.value = item.strokeWidth;
@@ -1464,7 +1464,7 @@ class MapSurveyController extends GetxController {
// ── Geometria szerkesztés indítása ────────────────────────────────────
Future startGeometryEdit() async {
- final id = _editingNoteItemId;
+ final id = editingNoteItemId;
if (id == null) return;
final item = await AppDatabase.instance.getNoteItem(id);
@@ -1501,14 +1501,14 @@ class MapSurveyController extends GetxController {
// ── Geometria szerkesztés megszakítása ────────────────────────────────
void cancelGeometryEdit() {
- _editingNoteItemId = null;
+ editingNoteItemId = null;
polygonEditorController.clear();
activeEditTool.value = MapEditTool.none;
editorPointCount.value = 0;
}
Future _finishGeometryUpdate() async {
- final id = _editingNoteItemId!;
+ final id = editingNoteItemId!;
if (polygonEditorController.points.length < _minPoints) {
Get.snackbar(
@@ -1538,7 +1538,7 @@ class MapSurveyController extends GetxController {
await updateNoteItem(updated);
// Reset
- _editingNoteItemId = null;
+ editingNoteItemId = null;
polygonEditorController.clear();
activeEditTool.value = MapEditTool.none;
editorPointCount.value = 0;
diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart
index b40edf0..e8e79a0 100644
--- a/lib/services/app_database.dart
+++ b/lib/services/app_database.dart
@@ -5,6 +5,8 @@ import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:terepi_seged/enums/note_type.dart';
import 'package:terepi_seged/models/note_item.dart';
+import 'package:terepi_seged/models/note_item_audio.dart';
+import 'package:terepi_seged/models/note_item_photo.dart';
import 'package:terepi_seged/models/track.dart';
import 'package:uuid/uuid.dart';
import '../models/project.dart';
@@ -132,6 +134,36 @@ class AppDatabase {
await db
.execute('CREATE INDEX idx_notes_project ON note_items(project_id)');
+ await db.execute('''
+ CREATE TABLE IF NOT EXISTS note_item_photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ note_item_id INTEGER NOT NULL REFERENCES note_items(id) ON DELETE CASCADE,
+ local_path TEXT NOT NULL,
+ storage_path TEXT,
+ caption TEXT NOT NULL DEFAULT '',
+ latitude REAL,
+ longitude REAL,
+ created_at TEXT NOT NULL
+ )
+''');
+ await db.execute(
+ 'CREATE INDEX idx_photos_note ON note_item_photos(note_item_id)');
+
+ await db.execute('''
+ CREATE TABLE IF NOT EXISTS note_item_audios (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ note_item_id INTEGER NOT NULL REFERENCES note_items(id) ON DELETE CASCADE,
+ local_path TEXT NOT NULL,
+ caption TEXT NOT NULL DEFAULT '',
+ duration_seconds INTEGER NOT NULL DEFAULT 0,
+ latitude REAL,
+ longitude REAL,
+ created_at TEXT NOT NULL
+ )
+''');
+ await db.execute(
+ 'CREATE INDEX idx_audios_note ON note_item_audios(note_item_id)');
+
await db.execute('''
CREATE TABLE IF NOT EXISTS pending_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -459,4 +491,72 @@ class AppDatabase {
whereArgs: [projectId],
);
}
+
+ // -------- NoteItemPhoto
+
+ Future insertNotePhoto(NoteItemPhoto photo) async {
+ final db = await database;
+ return db.insert('note_item_photos', photo.toMap());
+ }
+
+ Future updateNotePhoto(NoteItemPhoto photo) async {
+ final db = await database;
+ await db.update(
+ 'note_item_photos',
+ photo.toMap(),
+ where: 'id = ?',
+ whereArgs: [photo.id],
+ );
+ }
+
+ Future deleteNotePhoto(int id) async {
+ final db = await database;
+ await db.delete('note_item_photos', where: 'id = ?', whereArgs: [id]);
+ }
+
+ Future> listNotePhotos(int noteItemId) async {
+ final db = await database;
+ final rows = await db.query(
+ 'note_item_photos',
+ where: 'note_item_id = ?',
+ whereArgs: [noteItemId],
+ orderBy: 'created_at ASC',
+ );
+ return rows.map(NoteItemPhoto.fromMap).toList();
+ }
+
+ Future deleteAllNotePhotos(int noteItemId) async {
+ final db = await database;
+ await db.delete('note_item_photos',
+ where: 'note_item_id = ?', whereArgs: [noteItemId]);
+ }
+
+ // -------------- NoteItemAudio
+
+ Future insertNoteAudio(NoteItemAudio audio) async {
+ final db = await database;
+ return db.insert('note_item_audios', audio.toMap());
+ }
+
+ Future updateNoteAudio(NoteItemAudio audio) async {
+ final db = await database;
+ await db.update('note_item_audios', audio.toMap(),
+ where: 'id = ?', whereArgs: [audio.id]);
+ }
+
+ Future deleteNoteAudio(int id) async {
+ final db = await database;
+ await db.delete('note_item_audios', where: 'id = ?', whereArgs: [id]);
+ }
+
+ Future> listNoteAudios(int noteItemId) async {
+ final db = await database;
+ final rows = await db.query(
+ 'note_item_audios',
+ where: 'note_item_id = ?',
+ whereArgs: [noteItemId],
+ orderBy: 'created_at ASC',
+ );
+ return rows.map(NoteItemAudio.fromMap).toList();
+ }
}
diff --git a/lib/services/note_audio_service.dart b/lib/services/note_audio_service.dart
new file mode 100644
index 0000000..d0a5dec
--- /dev/null
+++ b/lib/services/note_audio_service.dart
@@ -0,0 +1,265 @@
+// Hangjegyzet service:
+// - Felvétel (record csomag, AAC/m4a)
+// - Lejátszás (audioplayers csomag)
+// - Fájl kezelés + SQLite mentés
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:get/get.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+import 'package:record/record.dart';
+import 'package:audioplayers/audioplayers.dart';
+
+import '../models/note_item_audio.dart';
+import '../services/app_database.dart';
+
+// ─── Felvétel állapot ─────────────────────────────────────────────────────────
+
+enum AudioRecordState { idle, recording, stopped }
+
+enum AudioPlayState { idle, playing, paused }
+
+// ─── NoteAudioService ─────────────────────────────────────────────────────────
+
+class NoteAudioService extends GetxService {
+ static NoteAudioService get to => Get.find();
+
+ // ── Reaktív állapot ────────────────────────────────────────────────────────
+ final recordState = AudioRecordState.idle.obs;
+ final recordDurationMs = 0.obs; // eltelt ms felvétel közben
+ final playingAudioId = Rxn(); // melyik clip játszik épp
+ final playState = AudioPlayState.idle.obs;
+ final playPositionMs = 0.obs;
+
+ // ── Belső ──────────────────────────────────────────────────────────────────
+ final _recorder = AudioRecorder();
+ final _player = AudioPlayer();
+ String? _audioDir;
+ String? _currentRecordPath;
+ Timer? _recordTimer;
+ int? _recordStartMs;
+
+ // ── Inicializálás ──────────────────────────────────────────────────────────
+
+ @override
+ Future onInit() async {
+ super.onInit();
+ await _initAudioDir();
+ _initPlayerListeners();
+ }
+
+ Future _initAudioDir() async {
+ final ext = await getExternalStorageDirectory();
+ final dir = Directory(p.join(ext!.path, 'audio'));
+ if (!await dir.exists()) await dir.create(recursive: true);
+ _audioDir = dir.path;
+ }
+
+ void _initPlayerListeners() {
+ // Lejátszás pozíció frissítése
+ _player.onPositionChanged.listen((pos) {
+ playPositionMs.value = pos.inMilliseconds;
+ });
+
+ // Lejátszás vége
+ _player.onPlayerComplete.listen((_) {
+ playState.value = AudioPlayState.idle;
+ playingAudioId.value = null;
+ playPositionMs.value = 0;
+ });
+ }
+
+ // ── Felvétel ──────────────────────────────────────────────────────────────
+
+ Future startRecording(int noteItemId) async {
+ // Engedély ellenőrzés
+ final hasPermission = await _recorder.hasPermission();
+ if (!hasPermission) {
+ Get.snackbar(
+ 'Engedély szükséges', 'Mikrofon hozzáférés engedélyezése szükséges.',
+ snackPosition: SnackPosition.BOTTOM);
+ return false;
+ }
+
+ // Aktív lejátszás leállítása
+ if (playState.value != AudioPlayState.idle) {
+ await stopPlayback();
+ }
+
+ final fileName = 'audio_${noteItemId}_'
+ '${DateTime.now().millisecondsSinceEpoch}.m4a';
+ _currentRecordPath = p.join(_audioDir!, fileName);
+
+ await _recorder.start(
+ const RecordConfig(
+ encoder: AudioEncoder.aacLc, // jó minőség, kis méret
+ bitRate: 64000, // 64kbps elegendő hanghoz
+ sampleRate: 22050,
+ ),
+ path: _currentRecordPath!,
+ );
+
+ _recordStartMs = DateTime.now().millisecondsSinceEpoch;
+ recordState.value = AudioRecordState.recording;
+ recordDurationMs.value = 0;
+
+ // Másodpercenkénti számláló
+ _recordTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
+ recordDurationMs.value =
+ DateTime.now().millisecondsSinceEpoch - _recordStartMs!;
+ });
+
+ return true;
+ }
+
+ /// Felvétel leállítása és SQLite mentés
+ Future stopRecording(int noteItemId) async {
+ if (recordState.value != AudioRecordState.recording) return null;
+
+ _recordTimer?.cancel();
+ _recordTimer = null;
+
+ final path = await _recorder.stop();
+ recordState.value = AudioRecordState.idle;
+
+ if (path == null) return null;
+
+ final durationSec =
+ (DateTime.now().millisecondsSinceEpoch - _recordStartMs!) ~/ 1000;
+ recordDurationMs.value = 0;
+ _recordStartMs = null;
+
+ // Nagyon rövid felvétel dobja el (véletlen érintés)
+ if (durationSec < 1) {
+ await File(path).delete().catchError((_) => File(path));
+ return null;
+ }
+
+ // GPS pozíció
+ final pos = await _currentPosition();
+
+ final audio = NoteItemAudio(
+ noteItemId: noteItemId,
+ localPath: path,
+ durationSeconds: durationSec,
+ latitude: pos?.latitude,
+ longitude: pos?.longitude,
+ createdAt: DateTime.now(),
+ );
+
+ final id = await AppDatabase.instance.insertNoteAudio(audio);
+ return NoteItemAudio(
+ id: id,
+ noteItemId: audio.noteItemId,
+ localPath: audio.localPath,
+ durationSeconds: audio.durationSeconds,
+ latitude: audio.latitude,
+ longitude: audio.longitude,
+ createdAt: audio.createdAt,
+ );
+ }
+
+ /// Felvétel megszakítása (mentés nélkül)
+ Future cancelRecording() async {
+ if (recordState.value != AudioRecordState.recording) return;
+ _recordTimer?.cancel();
+ _recordTimer = null;
+ recordDurationMs.value = 0;
+
+ final path = await _recorder.stop();
+ recordState.value = AudioRecordState.idle;
+ if (path != null) {
+ await File(path).delete().catchError((_) => File(path));
+ }
+ }
+
+ // ── Lejátszás ─────────────────────────────────────────────────────────────
+
+ Future playAudio(NoteItemAudio audio) async {
+ if (!audio.fileExists) {
+ Get.snackbar('Fájl nem található', 'A hangjegyzet fájlja törlődött.',
+ snackPosition: SnackPosition.BOTTOM);
+ return;
+ }
+
+ // Ha ugyanaz játszik → pause/resume toggle
+ if (playingAudioId.value == audio.id) {
+ if (playState.value == AudioPlayState.playing) {
+ await _player.pause();
+ playState.value = AudioPlayState.paused;
+ } else {
+ await _player.resume();
+ playState.value = AudioPlayState.playing;
+ }
+ return;
+ }
+
+ // Más vagy új clip
+ await _player.stop();
+ playingAudioId.value = audio.id;
+ playState.value = AudioPlayState.playing;
+ playPositionMs.value = 0;
+
+ await _player.play(DeviceFileSource(audio.localPath));
+ }
+
+ Future stopPlayback() async {
+ await _player.stop();
+ playState.value = AudioPlayState.idle;
+ playingAudioId.value = null;
+ playPositionMs.value = 0;
+ }
+
+ // ── Törlés ────────────────────────────────────────────────────────────────
+
+ Future deleteAudio(NoteItemAudio audio) async {
+ // Leállítás ha épp játszik
+ if (playingAudioId.value == audio.id) await stopPlayback();
+
+ final file = File(audio.localPath);
+ if (await file.exists()) await file.delete();
+ await AppDatabase.instance.deleteNoteAudio(audio.id!);
+ }
+
+ Future updateCaption(
+ NoteItemAudio audio, String caption) async {
+ final updated = audio.copyWith(caption: caption);
+ await AppDatabase.instance.updateNoteAudio(updated);
+ return updated;
+ }
+
+ Future> loadAudios(int noteItemId) =>
+ AppDatabase.instance.listNoteAudios(noteItemId);
+
+ // ── Segéd ─────────────────────────────────────────────────────────────────
+
+ Future _currentPosition() async {
+ try {
+ return await Geolocator.getCurrentPosition(
+ locationSettings: const LocationSettings(
+ accuracy: LocationAccuracy.medium, timeLimit: Duration(seconds: 3)),
+ ).timeout(const Duration(seconds: 3));
+ } catch (_) {
+ return Geolocator.getLastKnownPosition();
+ }
+ }
+
+ // Formázott időtartam: ms → "0:42"
+ String formatMs(int ms) {
+ final s = ms ~/ 1000;
+ final m = s ~/ 60;
+ return '$m:${(s % 60).toString().padLeft(2, '0')}';
+ }
+
+ @override
+ void onClose() {
+ _recorder.dispose();
+ _player.dispose();
+ _recordTimer?.cancel();
+ super.onClose();
+ }
+}
diff --git a/lib/services/note_photo_service.dart b/lib/services/note_photo_service.dart
new file mode 100644
index 0000000..d0d0b1d
--- /dev/null
+++ b/lib/services/note_photo_service.dart
@@ -0,0 +1,146 @@
+// lib/services/note_photo_service.dart
+//
+// Fotó kezelő service:
+// - Kamera / galéria hozzáférés (image_picker)
+// - Fájl mentés külső tárhelyre
+// - SQLite CRUD
+// - Opcionális Supabase Storage szinkron
+
+import 'dart:io';
+import 'package:flutter/material.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:get/get.dart';
+import 'package:image_picker/image_picker.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+
+import '../models/note_item_photo.dart';
+import '../services/app_database.dart';
+
+class NotePhotoService extends GetxService {
+ static NotePhotoService get to => Get.find();
+
+ final _picker = ImagePicker();
+ String? _photoDir;
+
+ @override
+ Future onInit() async {
+ super.onInit();
+ await _initPhotoDir();
+ }
+
+ Future _initPhotoDir() async {
+ final ext = await getExternalStorageDirectory();
+ final dir = Directory(p.join(ext!.path, 'photos'));
+ if (!await dir.exists()) await dir.create(recursive: true);
+ _photoDir = dir.path;
+ }
+
+ // ── Fotó készítése kamerával ─────────────────────────────────────
+
+ Future takePhoto(int noteItemId) async {
+ return _pickAndSave(
+ noteItemId: noteItemId,
+ source: ImageSource.camera,
+ );
+ }
+
+ // ── Fotó választása galériából ───────────────────────────────────
+
+ Future pickFromGallery(int noteItemId) async {
+ return _pickAndSave(
+ noteItemId: noteItemId,
+ source: ImageSource.gallery,
+ );
+ }
+
+ // ── Közös mentési logika ─────────────────────────────────────────
+
+ Future _pickAndSave({
+ required int noteItemId,
+ required ImageSource source,
+ }) async {
+ try {
+ final picked = await _picker.pickImage(
+ source: source,
+ imageQuality: 85, // tömörítés a tárhelyért
+ maxWidth: 2048,
+ maxHeight: 2048,
+ );
+ if (picked == null) return null; // felhasználó visszalépett
+
+ // GPS pozíció a fotókészítés pillanatában
+ final pos = await _currentPosition();
+
+ // Fájl másolása az app saját könyvtárába
+ final fileName =
+ 'photo_${noteItemId}_${DateTime.now().millisecondsSinceEpoch}.jpg';
+ final destPath = p.join(_photoDir!, fileName);
+ await File(picked.path).copy(destPath);
+
+ // SQLite mentés
+ final photo = NoteItemPhoto(
+ noteItemId: noteItemId,
+ localPath: destPath,
+ latitude: pos?.latitude,
+ longitude: pos?.longitude,
+ createdAt: DateTime.now(),
+ );
+
+ final id = await AppDatabase.instance.insertNotePhoto(photo);
+ return NoteItemPhoto(
+ id: id,
+ noteItemId: photo.noteItemId,
+ localPath: photo.localPath,
+ latitude: photo.latitude,
+ longitude: photo.longitude,
+ createdAt: photo.createdAt,
+ );
+ } catch (e) {
+ debugPrint('NotePhotoService hiba: $e');
+ Get.snackbar('Hiba', 'Fotó mentése sikertelen.',
+ snackPosition: SnackPosition.BOTTOM);
+ return null;
+ }
+ }
+
+ // ── Fotó törlése ─────────────────────────────────────────────────
+
+ Future deletePhoto(NoteItemPhoto photo) async {
+ // Fájl törlése
+ final file = File(photo.localPath);
+ if (await file.exists()) await file.delete();
+
+ // SQLite törlése
+ await AppDatabase.instance.deleteNotePhoto(photo.id!);
+ }
+
+ // ── Felirat frissítése ────────────────────────────────────────────
+
+ Future updateCaption(
+ NoteItemPhoto photo, String caption) async {
+ final updated = photo.copyWith(caption: caption);
+ await AppDatabase.instance.updateNotePhoto(updated);
+ return updated;
+ }
+
+ // ── GPS pozíció ──────────────────────────────────────────────────
+
+ Future _currentPosition() async {
+ try {
+ return await Geolocator.getCurrentPosition(
+ locationSettings: const LocationSettings(
+ accuracy: LocationAccuracy.medium,
+ timeLimit: Duration(seconds: 3),
+ ),
+ ).timeout(const Duration(seconds: 3));
+ } catch (_) {
+ return Geolocator.getLastKnownPosition();
+ }
+ }
+
+ // ── Fotók betöltése ──────────────────────────────────────────────
+
+ Future> loadPhotos(int noteItemId) =>
+ AppDatabase.instance.listNotePhotos(noteItemId);
+}
diff --git a/lib/widgets/map_edit_tools/map_feature_save_sheet.dart b/lib/widgets/map_edit_tools/map_feature_save_sheet.dart
index 855d3cf..1c7dd5f 100644
--- a/lib/widgets/map_edit_tools/map_feature_save_sheet.dart
+++ b/lib/widgets/map_edit_tools/map_feature_save_sheet.dart
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:terepi_seged/enums/map_edit_tool.dart';
import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart';
+import 'package:terepi_seged/widgets/map_edit_tools/note_audio_widget.dart';
+import 'package:terepi_seged/widgets/map_edit_tools/note_photo_gallery.dart';
import 'color_row.dart';
import 'label_field.dart';
@@ -66,6 +68,10 @@ class MapFeatureSaveSheet extends StatelessWidget {
])
: const SizedBox.shrink()),
LabelField(ctrl: ctrl),
+ const SizedBox(height: 16),
+ NotePhotoGallery(noteItemId: ctrl.editingNoteItemId),
+ const SizedBox(height: 16),
+ NoteAudioWidget(noteItemId: ctrl.editingNoteItemId),
const SizedBox(height: 24),
SaveSheetActions(ctrl: ctrl),
SizedBox(
diff --git a/lib/widgets/map_edit_tools/note_audio_widget.dart b/lib/widgets/map_edit_tools/note_audio_widget.dart
new file mode 100644
index 0000000..b051dd5
--- /dev/null
+++ b/lib/widgets/map_edit_tools/note_audio_widget.dart
@@ -0,0 +1,471 @@
+// Hangjegyzet widget a MapFeatureSaveSheet-ben:
+// - Felvétel gomb animált mikrofonnal
+// - Felvett klipek listája play/pause/delete gombokkal
+// - Haladásjelző csúszka lejátszás közben
+
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../../models/note_item_audio.dart';
+import '../../services/note_audio_service.dart';
+
+class NoteAudioWidget extends StatefulWidget {
+ final int? noteItemId;
+ const NoteAudioWidget({super.key, required this.noteItemId});
+
+ @override
+ State createState() => _NoteAudioWidgetState();
+}
+
+class _NoteAudioWidgetState extends State {
+ List _audios = [];
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ if (widget.noteItemId == null) return;
+ final list = await NoteAudioService.to.loadAudios(widget.noteItemId!);
+ if (mounted) setState(() => _audios = list);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (widget.noteItemId == null) {
+ return const _DisabledAudio();
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Fejléc sor
+ Row(children: [
+ Text('Hangjegyzetek',
+ style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
+ const SizedBox(width: 6),
+ if (_audios.isNotEmpty)
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+ decoration: BoxDecoration(
+ color: Colors.grey.shade200,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Text('${_audios.length}',
+ style: const TextStyle(
+ fontSize: 11, fontWeight: FontWeight.w600)),
+ ),
+ ]),
+
+ const SizedBox(height: 10),
+
+ // Felvétel gomb
+ _RecordButton(
+ noteItemId: widget.noteItemId!,
+ onRecorded: (audio) {
+ setState(() => _audios.add(audio));
+ },
+ ),
+
+ // Felvett klipek listája
+ if (_audios.isNotEmpty) ...[
+ const SizedBox(height: 10),
+ ..._audios.map((audio) => _AudioClipTile(
+ audio: audio,
+ onDelete: () async {
+ await NoteAudioService.to.deleteAudio(audio);
+ setState(() => _audios.removeWhere((a) => a.id == audio.id));
+ },
+ )),
+ ],
+ ],
+ );
+ }
+}
+
+// ─── Felvétel gomb ───────────────────────────────────────────────────────────
+
+class _RecordButton extends StatelessWidget {
+ final int noteItemId;
+ final ValueChanged onRecorded;
+
+ const _RecordButton({
+ required this.noteItemId,
+ required this.onRecorded,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final svc = NoteAudioService.to;
+
+ return Obx(() {
+ final isRecording = svc.recordState.value == AudioRecordState.recording;
+ final durationMs = svc.recordDurationMs.value;
+
+ return Row(children: [
+ // Mikrofon gomb
+ GestureDetector(
+ onTap: () => isRecording ? _stopRecording(svc) : _startRecording(svc),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ width: 52,
+ height: 52,
+ decoration: BoxDecoration(
+ color: isRecording
+ ? Colors.red
+ : Theme.of(context).colorScheme.primaryContainer,
+ shape: BoxShape.circle,
+ boxShadow: isRecording
+ ? [
+ BoxShadow(
+ color: Colors.red.withOpacity(0.4),
+ blurRadius: 12,
+ spreadRadius: 2,
+ )
+ ]
+ : null,
+ ),
+ child: Icon(
+ isRecording ? Icons.stop : Icons.mic,
+ color: isRecording
+ ? Colors.white
+ : Theme.of(context).colorScheme.primary,
+ size: 24,
+ ),
+ ),
+ ),
+
+ const SizedBox(width: 12),
+
+ Expanded(
+ child: isRecording
+ // Felvétel közben: időmérő + animált hullám
+ ? Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(children: [
+ _PulsingDot(),
+ const SizedBox(width: 8),
+ Text(
+ 'Felvétel: ${svc.formatMs(durationMs)}',
+ style: const TextStyle(
+ fontWeight: FontWeight.w600,
+ color: Colors.red,
+ fontSize: 14,
+ ),
+ ),
+ ]),
+ const SizedBox(height: 4),
+ Text(
+ 'Megállításhoz nyomd meg a gombot',
+ style:
+ TextStyle(fontSize: 11, color: Colors.grey.shade500),
+ ),
+ ],
+ )
+ // Alap állapot
+ : Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text('Hangjegyzet rögzítése',
+ style: TextStyle(
+ fontWeight: FontWeight.w500, fontSize: 14)),
+ Text('Nyomd meg a mikrofon gombot',
+ style: TextStyle(
+ fontSize: 11, color: Colors.grey.shade500)),
+ ],
+ ),
+ ),
+
+ // Mégse — csak felvétel közben
+ if (isRecording)
+ TextButton(
+ onPressed: () async {
+ await svc.cancelRecording();
+ },
+ child: const Text('Mégse', style: TextStyle(color: Colors.grey)),
+ ),
+ ]);
+ });
+ }
+
+ Future _startRecording(NoteAudioService svc) async {
+ await svc.startRecording(noteItemId);
+ }
+
+ Future _stopRecording(NoteAudioService svc) async {
+ final audio = await svc.stopRecording(noteItemId);
+ if (audio != null) onRecorded(audio);
+ }
+}
+
+// ─── Egy felvett klip sor ────────────────────────────────────────────────────
+
+class _AudioClipTile extends StatelessWidget {
+ final NoteItemAudio audio;
+ final VoidCallback onDelete;
+
+ const _AudioClipTile({
+ required this.audio,
+ required this.onDelete,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final svc = NoteAudioService.to;
+
+ return Obx(() {
+ final isThisPlaying = svc.playingAudioId.value == audio.id;
+ final pState = svc.playState.value;
+ final posMs = svc.playPositionMs.value;
+ final totalMs = audio.durationSeconds * 1000;
+
+ // Haladás 0.0 – 1.0
+ final progress = (isThisPlaying && totalMs > 0)
+ ? (posMs / totalMs).clamp(0.0, 1.0)
+ : 0.0;
+
+ return Container(
+ margin: const EdgeInsets.only(bottom: 8),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: isThisPlaying
+ ? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.4)
+ : Colors.grey.shade100,
+ borderRadius: BorderRadius.circular(10),
+ border: isThisPlaying
+ ? Border.all(
+ color: Theme.of(context).colorScheme.primary.withOpacity(0.4))
+ : null,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(children: [
+ // Play / Pause gomb
+ GestureDetector(
+ onTap: () => svc.playAudio(audio),
+ child: Container(
+ width: 36,
+ height: 36,
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primary,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(
+ isThisPlaying && pState == AudioPlayState.playing
+ ? Icons.pause
+ : Icons.play_arrow,
+ color: Colors.white,
+ size: 20,
+ ),
+ ),
+ ),
+
+ const SizedBox(width: 10),
+
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // Felirat vagy dátum
+ if (audio.caption.isNotEmpty)
+ Text(audio.caption,
+ style: const TextStyle(
+ fontSize: 13, fontWeight: FontWeight.w500),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis)
+ else
+ Text(
+ _formatDate(audio.createdAt),
+ style: TextStyle(
+ fontSize: 12, color: Colors.grey.shade600),
+ ),
+
+ const SizedBox(height: 4),
+
+ // Haladásjelző csúszka
+ ClipRRect(
+ borderRadius: BorderRadius.circular(2),
+ child: LinearProgressIndicator(
+ value: isThisPlaying && totalMs > 0
+ ? (posMs / totalMs).clamp(0.0, 1.0)
+ : 0.0,
+ minHeight: 3,
+ backgroundColor: Colors.grey.shade300,
+ valueColor: AlwaysStoppedAnimation(
+ Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ ),
+
+ const SizedBox(height: 2),
+
+ // Időtartam
+ Text(
+ isThisPlaying
+ ? '${svc.formatMs(posMs)} / '
+ '${audio.durationFormatted}'
+ : audio.durationFormatted,
+ style: TextStyle(
+ fontSize: 10,
+ color: Colors.grey.shade500,
+ fontFeatures: const [FontFeature.tabularFigures()]),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(width: 4),
+
+ // Menü: felirat / törlés
+ PopupMenuButton<_AudioAction>(
+ icon: Icon(Icons.more_vert,
+ size: 18, color: Colors.grey.shade500),
+ onSelected: (a) => _onAction(a, context),
+ itemBuilder: (_) => [
+ const PopupMenuItem(
+ value: _AudioAction.caption,
+ child: ListTile(
+ leading: Icon(Icons.edit_outlined),
+ title: Text('Felirat'),
+ dense: true,
+ ),
+ ),
+ const PopupMenuDivider(),
+ const PopupMenuItem(
+ value: _AudioAction.delete,
+ child: ListTile(
+ leading: Icon(Icons.delete_outline, color: Colors.red),
+ title:
+ Text('Törlés', style: TextStyle(color: Colors.red)),
+ dense: true,
+ ),
+ ),
+ ],
+ ),
+ ]),
+ ],
+ ),
+ );
+ });
+ }
+
+ void _onAction(_AudioAction action, BuildContext context) {
+ switch (action) {
+ case _AudioAction.caption:
+ _editCaption();
+ case _AudioAction.delete:
+ _confirmDelete();
+ }
+ }
+
+ Future _editCaption() async {
+ final ctrl = TextEditingController(text: audio.caption);
+ await Get.dialog(AlertDialog(
+ title: const Text('Felirat'),
+ content: TextField(
+ controller: ctrl,
+ decoration: const InputDecoration(hintText: 'Hangjegyzet leírása...'),
+ autofocus: true,
+ ),
+ actions: [
+ TextButton(onPressed: Get.back, child: const Text('Mégse')),
+ FilledButton(
+ onPressed: () async {
+ Get.back();
+ await NoteAudioService.to.updateCaption(audio, ctrl.text.trim());
+ },
+ child: const Text('Mentés'),
+ ),
+ ],
+ ));
+ }
+
+ Future _confirmDelete() async {
+ final ok = await Get.dialog(AlertDialog(
+ title: const Text('Hangjegyzet törlése'),
+ content: const Text('Ez a felvétel véglegesen törlődik.'),
+ actions: [
+ TextButton(onPressed: Get.back, child: const Text('Mégse')),
+ FilledButton(
+ style: FilledButton.styleFrom(backgroundColor: Colors.red),
+ onPressed: () => Get.back(result: true),
+ child: const Text('Törlés'),
+ ),
+ ],
+ ));
+ if (ok == true) onDelete();
+ }
+
+ String _formatDate(DateTime dt) =>
+ '${dt.year}.${dt.month.toString().padLeft(2, '0')}.'
+ '${dt.day.toString().padLeft(2, '0')} '
+ '${dt.hour.toString().padLeft(2, '0')}:'
+ '${dt.minute.toString().padLeft(2, '0')}';
+}
+
+// ─── Segéd widgetek ──────────────────────────────────────────────────────────
+
+class _DisabledAudio extends StatelessWidget {
+ const _DisabledAudio();
+ @override
+ Widget build(BuildContext context) => Container(
+ height: 50,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: Colors.grey.shade100,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Text('Mentés után adható hangjegyzet',
+ style: TextStyle(fontSize: 12, color: Colors.grey.shade500)),
+ );
+}
+
+class _PulsingDot extends StatefulWidget {
+ @override
+ State<_PulsingDot> createState() => _PulsingDotState();
+}
+
+class _PulsingDotState extends State<_PulsingDot>
+ with SingleTickerProviderStateMixin {
+ late AnimationController _ctrl;
+ late Animation _anim;
+
+ @override
+ void initState() {
+ super.initState();
+ _ctrl = AnimationController(
+ vsync: this, duration: const Duration(milliseconds: 600))
+ ..repeat(reverse: true);
+ _anim = Tween(begin: 0.3, end: 1.0).animate(_ctrl);
+ }
+
+ @override
+ void dispose() {
+ _ctrl.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => AnimatedBuilder(
+ animation: _anim,
+ builder: (_, __) => Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: Colors.red.withOpacity(_anim.value),
+ shape: BoxShape.circle,
+ ),
+ ),
+ );
+}
+
+enum _AudioAction { caption, delete }
diff --git a/lib/widgets/map_edit_tools/note_photo_gallery.dart b/lib/widgets/map_edit_tools/note_photo_gallery.dart
new file mode 100644
index 0000000..5babdca
--- /dev/null
+++ b/lib/widgets/map_edit_tools/note_photo_gallery.dart
@@ -0,0 +1,457 @@
+// lib/widgets/map_edit_tools/note_photo_gallery.dart
+//
+// Fotó galéria a MapFeatureSaveSheet-ben:
+// - Vízszintes görgetős sor
+// - + gomb: kamera / galéria választó
+// - Fotóra koppintva: teljes képernyős nézet + felirat szerkesztés
+// - Fotóra hosszan nyomva: törlés
+
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../../models/note_item_photo.dart';
+import '../../services/note_photo_service.dart';
+
+class NotePhotoGallery extends StatefulWidget {
+ /// A szerkesztett NoteItem id-ja — null ha az elem még nincs elmentve
+ final int? noteItemId;
+
+ const NotePhotoGallery({super.key, required this.noteItemId});
+
+ @override
+ State createState() => _NotePhotoGalleryState();
+}
+
+class _NotePhotoGalleryState extends State {
+ List _photos = [];
+ bool _loading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadPhotos();
+ }
+
+ Future _loadPhotos() async {
+ if (widget.noteItemId == null) return;
+ setState(() => _loading = true);
+ _photos = await NotePhotoService.to.loadPhotos(widget.noteItemId!);
+ if (mounted) setState(() => _loading = false);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // NoteItem nem mentett még — nem lehet fotót hozzáadni
+ if (widget.noteItemId == null) {
+ return const _DisabledGallery();
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(children: [
+ Text('Fotók',
+ style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
+ const SizedBox(width: 6),
+ if (_photos.isNotEmpty)
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+ decoration: BoxDecoration(
+ color: Colors.grey.shade200,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Text(
+ '${_photos.length}',
+ style:
+ const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
+ ),
+ ),
+ ]),
+ const SizedBox(height: 8),
+ if (_loading)
+ const SizedBox(
+ height: 80,
+ child: Center(child: CircularProgressIndicator()),
+ )
+ else
+ SizedBox(
+ height: 88,
+ child: ListView(
+ scrollDirection: Axis.horizontal,
+ children: [
+ // Meglévő fotók
+ ..._photos.map((photo) => _PhotoThumb(
+ photo: photo,
+ onTap: () => _openViewer(photo),
+ onDelete: () => _delete(photo),
+ )),
+
+ // + Fotó hozzáadása gomb
+ _AddPhotoButton(
+ onCamera: () => _addPhoto(fromCamera: true),
+ onGallery: () => _addPhoto(fromCamera: false),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+
+ Future _addPhoto({required bool fromCamera}) async {
+ final svc = NotePhotoService.to;
+ final photo = fromCamera
+ ? await svc.takePhoto(widget.noteItemId!)
+ : await svc.pickFromGallery(widget.noteItemId!);
+
+ if (photo != null && mounted) {
+ setState(() => _photos.add(photo));
+ }
+ }
+
+ Future _delete(NoteItemPhoto photo) async {
+ final ok = await Get.dialog(AlertDialog(
+ title: const Text('Fotó törlése'),
+ content: const Text('Ez a fotó véglegesen törlődik.'),
+ actions: [
+ TextButton(onPressed: Get.back, child: const Text('Mégse')),
+ FilledButton(
+ style: FilledButton.styleFrom(backgroundColor: Colors.red),
+ onPressed: () => Get.back(result: true),
+ child: const Text('Törlés'),
+ ),
+ ],
+ ));
+
+ if (ok == true && mounted) {
+ await NotePhotoService.to.deletePhoto(photo);
+ setState(() => _photos.removeWhere((p) => p.id == photo.id));
+ }
+ }
+
+ void _openViewer(NoteItemPhoto photo) {
+ Get.to(() => _PhotoViewerPage(
+ photos: _photos,
+ initialIndex: _photos.indexWhere((p) => p.id == photo.id),
+ onCaptionSaved: (updated) {
+ setState(() {
+ final idx = _photos.indexWhere((p) => p.id == updated.id);
+ if (idx >= 0) _photos[idx] = updated;
+ });
+ },
+ ));
+ }
+}
+
+// ─── Fotó bélyegkép ──────────────────────────────────────────────────────────
+
+class _PhotoThumb extends StatelessWidget {
+ final NoteItemPhoto photo;
+ final VoidCallback onTap;
+ final VoidCallback onDelete;
+
+ const _PhotoThumb({
+ required this.photo,
+ required this.onTap,
+ required this.onDelete,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: GestureDetector(
+ onTap: onTap,
+ onLongPress: onDelete,
+ child: Stack(children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: photo.fileExists
+ ? Image.file(
+ photo.file,
+ width: 80, height: 80,
+ fit: BoxFit.cover,
+ cacheWidth: 160, // memória optimalizálás
+ )
+ : Container(
+ width: 80,
+ height: 80,
+ color: Colors.grey.shade200,
+ child: const Icon(Icons.broken_image, color: Colors.grey),
+ ),
+ ),
+ // Felirat jelzése ha van
+ if (photo.caption.isNotEmpty)
+ Positioned(
+ bottom: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.bottomCenter,
+ end: Alignment.topCenter,
+ colors: [
+ Colors.black.withOpacity(0.7),
+ Colors.transparent,
+ ],
+ ),
+ borderRadius:
+ const BorderRadius.vertical(bottom: Radius.circular(8)),
+ ),
+ child: Text(
+ photo.caption,
+ style: const TextStyle(color: Colors.white, fontSize: 9),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ // GPS jelzése ha van
+ if (photo.location != null)
+ Positioned(
+ top: 4,
+ right: 4,
+ child: Container(
+ padding: const EdgeInsets.all(2),
+ decoration: BoxDecoration(
+ color: Colors.black.withOpacity(0.5),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: const Icon(Icons.location_on,
+ color: Colors.white, size: 10),
+ ),
+ ),
+ ]),
+ ),
+ );
+ }
+}
+
+// ─── Hozzáadás gomb ──────────────────────────────────────────────────────────
+
+class _AddPhotoButton extends StatelessWidget {
+ final VoidCallback onCamera;
+ final VoidCallback onGallery;
+
+ const _AddPhotoButton({
+ required this.onCamera,
+ required this.onGallery,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: () => _showPicker(context),
+ child: Container(
+ width: 80,
+ height: 80,
+ decoration: BoxDecoration(
+ color: Colors.grey.shade100,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: Colors.grey.shade300,
+ width: 1.5,
+ ),
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.add_a_photo_outlined,
+ size: 24, color: Colors.grey.shade500),
+ const SizedBox(height: 4),
+ Text('Fotó',
+ style: TextStyle(fontSize: 10, color: Colors.grey.shade500)),
+ ],
+ ),
+ ),
+ );
+ }
+
+ void _showPicker(BuildContext context) {
+ showModalBottomSheet(
+ context: context,
+ builder: (_) => SafeArea(
+ child: Column(mainAxisSize: MainAxisSize.min, children: [
+ ListTile(
+ leading: const Icon(Icons.camera_alt_outlined),
+ title: const Text('Kamera'),
+ onTap: () {
+ Navigator.pop(context);
+ onCamera();
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.photo_library_outlined),
+ title: const Text('Galéria'),
+ onTap: () {
+ Navigator.pop(context);
+ onGallery();
+ },
+ ),
+ ]),
+ ),
+ );
+ }
+}
+
+// ─── Letiltott galéria (elem még nincs mentve) ───────────────────────────────
+
+class _DisabledGallery extends StatelessWidget {
+ const _DisabledGallery();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ height: 50,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: Colors.grey.shade100,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Text(
+ 'Mentés után adhatók hozzá fotók',
+ style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
+ ),
+ );
+ }
+}
+
+// ─── Teljes képernyős fotónézegető ───────────────────────────────────────────
+
+class _PhotoViewerPage extends StatefulWidget {
+ final List photos;
+ final int initialIndex;
+ final ValueChanged onCaptionSaved;
+
+ const _PhotoViewerPage({
+ required this.photos,
+ required this.initialIndex,
+ required this.onCaptionSaved,
+ });
+
+ @override
+ State<_PhotoViewerPage> createState() => _PhotoViewerPageState();
+}
+
+class _PhotoViewerPageState extends State<_PhotoViewerPage> {
+ late PageController _pageCtrl;
+ late int _currentIdx;
+
+ @override
+ void initState() {
+ super.initState();
+ _currentIdx = widget.initialIndex;
+ _pageCtrl = PageController(initialPage: widget.initialIndex);
+ }
+
+ @override
+ void dispose() {
+ _pageCtrl.dispose();
+ super.dispose();
+ }
+
+ NoteItemPhoto get _current => widget.photos[_currentIdx];
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: Colors.black,
+ appBar: AppBar(
+ backgroundColor: Colors.black,
+ foregroundColor: Colors.white,
+ title: widget.photos.length > 1
+ ? Text('${_currentIdx + 1} / ${widget.photos.length}')
+ : null,
+ actions: [
+ // Felirat szerkesztés
+ IconButton(
+ icon: const Icon(Icons.edit_outlined, color: Colors.white),
+ tooltip: 'Felirat szerkesztése',
+ onPressed: _editCaption,
+ ),
+ ],
+ ),
+ body: Column(children: [
+ // Fotó
+ Expanded(
+ child: PageView.builder(
+ controller: _pageCtrl,
+ itemCount: widget.photos.length,
+ onPageChanged: (i) => setState(() => _currentIdx = i),
+ itemBuilder: (_, i) {
+ final photo = widget.photos[i];
+ return InteractiveViewer(
+ child: Center(
+ child: photo.fileExists
+ ? Image.file(photo.file, fit: BoxFit.contain)
+ : const Icon(Icons.broken_image,
+ color: Colors.white54, size: 64),
+ ),
+ );
+ },
+ ),
+ ),
+
+ // Felirat + helyadatok
+ if (_current.caption.isNotEmpty || _current.location != null)
+ Container(
+ color: Colors.black.withOpacity(0.7),
+ padding: EdgeInsets.fromLTRB(
+ 16,
+ 10,
+ 16,
+ 10 + MediaQuery.of(context).padding.bottom,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (_current.caption.isNotEmpty)
+ Text(_current.caption,
+ style:
+ const TextStyle(color: Colors.white, fontSize: 14)),
+ if (_current.location != null)
+ Text(
+ '📍 ${_current.latitude!.toStringAsFixed(6)}, '
+ '${_current.longitude!.toStringAsFixed(6)}',
+ style: const TextStyle(color: Colors.white54, fontSize: 11),
+ ),
+ ],
+ ),
+ ),
+ ]),
+ );
+ }
+
+ Future _editCaption() async {
+ final ctrl = TextEditingController(text: _current.caption);
+ final result = await Get.dialog(AlertDialog(
+ title: const Text('Felirat'),
+ content: TextField(
+ controller: ctrl,
+ decoration: const InputDecoration(
+ hintText: 'Fotó leírása...',
+ ),
+ autofocus: true,
+ maxLines: 3,
+ ),
+ actions: [
+ TextButton(onPressed: Get.back, child: const Text('Mégse')),
+ FilledButton(
+ onPressed: () => Get.back(result: ctrl.text.trim()),
+ child: const Text('Mentés'),
+ ),
+ ],
+ ));
+
+ if (result != null) {
+ final updated = await NotePhotoService.to.updateCaption(_current, result);
+ widget.onCaptionSaved(updated);
+ setState(() {});
+ }
+ }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 1ef56c7..e4a7d31 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+15
environment:
- sdk: ">=3.0.0 <3.20.0"
+ sdk: ">=3.0.0 <3.50.0"
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@@ -72,6 +72,9 @@ dependencies:
flutter_foreground_task: ^9.2.2
flutter_blue_plus: ^2.3.2
flutter_dotenv: ^6.0.1
+ image_picker: ^1.2.2
+ record: ^7.1.0
+ audioplayers: ^6.7.1
flutter:
sdk: flutter