Terepbejárás geometria hang és képi dokumentáció létrehozása. Gradle verzió frissítése
This commit is contained in:
@@ -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<int> insertNotePhoto(NoteItemPhoto photo) async {
|
||||
final db = await database;
|
||||
return db.insert('note_item_photos', photo.toMap());
|
||||
}
|
||||
|
||||
Future<void> updateNotePhoto(NoteItemPhoto photo) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
'note_item_photos',
|
||||
photo.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [photo.id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteNotePhoto(int id) async {
|
||||
final db = await database;
|
||||
await db.delete('note_item_photos', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<List<NoteItemPhoto>> 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<void> deleteAllNotePhotos(int noteItemId) async {
|
||||
final db = await database;
|
||||
await db.delete('note_item_photos',
|
||||
where: 'note_item_id = ?', whereArgs: [noteItemId]);
|
||||
}
|
||||
|
||||
// -------------- NoteItemAudio
|
||||
|
||||
Future<int> insertNoteAudio(NoteItemAudio audio) async {
|
||||
final db = await database;
|
||||
return db.insert('note_item_audios', audio.toMap());
|
||||
}
|
||||
|
||||
Future<void> updateNoteAudio(NoteItemAudio audio) async {
|
||||
final db = await database;
|
||||
await db.update('note_item_audios', audio.toMap(),
|
||||
where: 'id = ?', whereArgs: [audio.id]);
|
||||
}
|
||||
|
||||
Future<void> deleteNoteAudio(int id) async {
|
||||
final db = await database;
|
||||
await db.delete('note_item_audios', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<List<NoteItemAudio>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int>(); // 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<void> onInit() async {
|
||||
super.onInit();
|
||||
await _initAudioDir();
|
||||
_initPlayerListeners();
|
||||
}
|
||||
|
||||
Future<void> _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<bool> 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<NoteItemAudio?> 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<void> 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<void> 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<void> stopPlayback() async {
|
||||
await _player.stop();
|
||||
playState.value = AudioPlayState.idle;
|
||||
playingAudioId.value = null;
|
||||
playPositionMs.value = 0;
|
||||
}
|
||||
|
||||
// ── Törlés ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> 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<NoteItemAudio> updateCaption(
|
||||
NoteItemAudio audio, String caption) async {
|
||||
final updated = audio.copyWith(caption: caption);
|
||||
await AppDatabase.instance.updateNoteAudio(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<List<NoteItemAudio>> loadAudios(int noteItemId) =>
|
||||
AppDatabase.instance.listNoteAudios(noteItemId);
|
||||
|
||||
// ── Segéd ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<Position?> _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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> onInit() async {
|
||||
super.onInit();
|
||||
await _initPhotoDir();
|
||||
}
|
||||
|
||||
Future<void> _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<NoteItemPhoto?> takePhoto(int noteItemId) async {
|
||||
return _pickAndSave(
|
||||
noteItemId: noteItemId,
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fotó választása galériából ───────────────────────────────────
|
||||
|
||||
Future<NoteItemPhoto?> pickFromGallery(int noteItemId) async {
|
||||
return _pickAndSave(
|
||||
noteItemId: noteItemId,
|
||||
source: ImageSource.gallery,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Közös mentési logika ─────────────────────────────────────────
|
||||
|
||||
Future<NoteItemPhoto?> _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<void> 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<NoteItemPhoto> updateCaption(
|
||||
NoteItemPhoto photo, String caption) async {
|
||||
final updated = photo.copyWith(caption: caption);
|
||||
await AppDatabase.instance.updateNotePhoto(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── GPS pozíció ──────────────────────────────────────────────────
|
||||
|
||||
Future<Position?> _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<List<NoteItemPhoto>> loadPhotos(int noteItemId) =>
|
||||
AppDatabase.instance.listNotePhotos(noteItemId);
|
||||
}
|
||||
Reference in New Issue
Block a user