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