diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..bf1df27 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,36 @@ +#!/bin/bash +# Git Pre-Commit Hook: Melo App Qualitäts-Gate +# Läuft automatisch vor jedem git commit + +export FLUTTER="/home/dustin/development/flutter/bin/flutter" +export DART="/home/dustin/development/flutter/bin/dart" +export PATH="$HOME/development/flutter/bin:$PATH" + +echo "🔍 Melo App Qualitäts-Gate gestartet..." + +# 1. Dart Analyse +echo -n " Prüfe: flutter analyze ... " +cd "$(git rev-parse --show-toplevel)" || exit 1 +ANALYZE_OUTPUT=$($FLUTTER analyze 2>&1) +if echo "$ANALYZE_OUTPUT" | grep -q "No issues found"; then + echo "✅" +else + echo "❌ FEHLER!" + echo "$ANALYZE_OUTPUT" | tail -20 + exit 1 +fi + +# 2. Tests +echo -n " Prüfe: flutter test ... " +TEST_OUTPUT=$($FLUTTER test 2>&1) +if echo "$TEST_OUTPUT" | grep -q "All tests passed"; then + echo "✅" +elif echo "$TEST_OUTPUT" | grep -q "No tests found"; then + echo "⚠️ Keine Tests vorhanden (überspringe)" +else + echo "❌ Tests fehlgeschlagen!" + echo "$TEST_OUTPUT" | tail -20 + exit 1 +fi + +echo "✅ Alle Prüfungen bestanden! Commit wird freigegeben." diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b60684..444557a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### Added +- 🌐 Navidrome-Integration (Musikserver-Verbindung): Settings-Tab bietet Login/Logout für Subsonic-API-kompatible Server +- Navidrome-Service mit sichere Credential-Speicherung (Secure Storage + SharedPreferences) +- Dependencies für Navidrome hinzugefügt: http, crypto, shared_preferences, flutter_secure_storage + +## [1.0.0-alpha] + ### Added - Playlisten-Tab: Favoriten-Kurzeinstieg oben, Liste aller Playlisten mit Songanzahl, Erstellen-Dialog (FAB), Löschen mit Bestätigung @@ -44,6 +51,15 @@ Künstler-/Alben-Browsing und automatischem Fortsetzen der Wiedergabeposition.** - "Zuletzt hinzugefügt" aus der alten Startseite ist jetzt Teil der Bibliothek (horizontale Leiste oben) - MiniPlayer wird auf dem Player-Tab ausgeblendet (dort läuft schon die volle Ansicht) +### Fixed +- 🐛 Der APK-Build funktioniert jetzt überhaupt. Drei Dinge waren nötig: + `compileSdk = 37` und `kotlin.jvm.target.validation.mode=warning` existierten + bisher nur lokal auf dem Server und fehlten im Repo — ohne sie bricht der Build + ab, weil `permission_handler` SDK 37 verlangt. Zusätzlich hebt ein Gradle- + Workaround jetzt Plugin-Module mit zu altem `compileSdk` auf 36 an: + `on_audio_query_android` kompiliert gegen SDK 33, seine AndroidX-Abhängigkeiten + verlangen aber mindestens 34 (16 Metadaten-Konflikte). + ### Removed - Alte Startseite (`home_screen.dart`) entfernt — Funktionalität in die Bibliothek verschoben diff --git a/CLAUDE.md b/CLAUDE.md index 6b8cb88..49972c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,32 @@ -# Arbeitsweise (Melo-Projekt) +# CLAUDE.md — Melo Projekt (Server-Klon) + +## 🎯 Projekt +- **Melo**: Musik-App (Flutter, Android) — Nachfolger des alten "Melo"-Projekts +- Design: Schwarz (#0B0B10) / Rot (#c0392b) — Dark Theme, 5 Tabs +- Features (frühere Version): Player, Playlisten, Favoriten, Recaps, Cloud-Sync (JWT), Offline-Tombstones, Echtzeit-Sync (SSE) +- Git: http://git.baka-net.de/dustin/Melo.git (Repo heißt "Melo"!) +- Der Code hier ist ein BUILD-KLON für den Server (Builds + Tests) + +## ⚙️ Build-Umgebung (Server!) +- Android SDK: ANDROID_HOME=/home/dustin/Android +- Flutter: /home/dustin/development/flutter/bin/flutter +- Build: flutter build apk --target-platform android-arm64 +- Bekannte Fixes: compileSdk=37 gesetzt (in android/app/build.gradle.kts), kotlin.jvm.target.validation.mode=warning (gradle.properties) +- ACHTUNG: permission_handler + on_audio_query verlangen SDK 37 — kein echtes android-37 SDK (Kopie von 36!) + +## 🛡️ Sicherheitsregeln +- NIE Passwörter/API-Keys in Code oder Commits +- Keine .env committen +- Bei Unsicherheit: Fragen statt raten + +## 📋 CHANGELOG-PFLICHT +- Nach JEDER Aufgabe → CHANGELOG.md aktualisieren (neueste zuerst, einfach erklärt!) +- Format: ✅ Was gemacht · 🔧 Details · 🐛 Fixes +- Changelog IMMER mit dem Code committen! + +## 🤖 Arbeitsweise - Vor jedem Commit: @code-reviewer die Änderungen reviewen lassen - Bei neuen Features: @planner für den Plan, @tdd-guide für Tests - Bei Sicherheitsfragen: @security-reviewer - Ponytail-Prinzip: minimale, robuste Lösungen bevorzugen +- Nach Abschluss: commit + push nach git.baka-net.de diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..bf8d421 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index acc0ab2..3f80dbb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,9 @@ plugins { android { namespace = "de.baka.melo" - compileSdk = flutter.compileSdkVersion + // permission_handler_android verlangt SDK 37; android-37 ist lokal eine Kopie + // von 36 (ApiLevel=36) → jvmTarget-Validierung muss auf "warning" stehen. + compileSdk = 37 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index d8016e9..ddf7638 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -22,6 +22,13 @@ subprojects { if (androidExt != null && androidExt.namespace == null) { androidExt.namespace = project.group.toString() } + // on_audio_query_android kompiliert gegen SDK 33, seine AndroidX- + // Abhängigkeiten (fragment, window, ...) verlangen aber mindestens 34. + // Zu niedrige Library-Module auf 36 anheben (echtes SDK, nicht die 37-Kopie). + val libCompileSdk = androidExt?.compileSdkVersion?.removePrefix("android-")?.toIntOrNull() + if (libCompileSdk != null && libCompileSdk < 34) { + androidExt.compileSdkVersion(36) + } } } subprojects { diff --git a/android/gradle.properties b/android/gradle.properties index e96108c..b63a313 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -4,3 +4,4 @@ android.useAndroidX=true android.newDsl=false # This builtInKotlin flag was added by the Flutter template android.builtInKotlin=false +kotlin.jvm.target.validation.mode=warning diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart new file mode 100644 index 0000000..fcdd717 --- /dev/null +++ b/lib/services/navidrome_service.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class SubsonicSong { + final String id; + final String titel; + final String kuenstler; + final String album; + final int dauerSekunden; + final String? coverId; + + const SubsonicSong({ + required this.id, + required this.titel, + this.kuenstler = '', + this.album = '', + this.dauerSekunden = 0, + this.coverId, + }); + + factory SubsonicSong.fromJson(Map j) => SubsonicSong( + id: j['id'] as String, + titel: j['title'] as String? ?? 'Unbekannt', + kuenstler: j['artist'] as String? ?? '', + album: j['album'] as String? ?? '', + dauerSekunden: (j['duration'] as int?) ?? 0, + coverId: j['coverArt'] as String?, + ); +} + +class SubsonicAlbum { + final String id; + final String name; + final String? coverId; + final int songCount; + + const SubsonicAlbum({ + required this.id, + required this.name, + this.coverId, + this.songCount = 0, + }); + + factory SubsonicAlbum.fromJson(Map j) => SubsonicAlbum( + id: j['id'] as String, + name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt', + coverId: j['coverArt'] as String?, + songCount: (j['songCount'] as int?) ?? 0, + ); +} + +class NavidromeService { + final FlutterSecureStorage _secure = const FlutterSecureStorage(); + + String _serverUrl = ''; + String _user = ''; + String _password = ''; + String _salt = ''; + String? _token; + + static const _version = '1.16.1'; + static const _client = 'Melo'; + + void setCredentials(String url, String user, String password) { + _serverUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + _user = user; + _password = password; + final rng = Random.secure(); + _salt = base64Encode(List.generate(16, (_) => rng.nextInt(256))); + _token = md5.convert(utf8.encode(_password + _salt)).toString(); + } + + Future ladeGespeicherteZugangsdaten() async { + try { + final prefs = await SharedPreferences.getInstance(); + final url = prefs.getString('navidrome_url'); + final user = prefs.getString('navidrome_user'); + final pass = await _secure.read(key: 'navidrome_pass'); + if (url != null && user != null && pass != null && url.isNotEmpty) { + setCredentials(url, user, pass); + } + } catch (e) { + debugPrint('Fehler beim Laden der Navidrome-Zugangsdaten: $e'); + } + } + + Future speichereZugangsdaten(String url, String user, String password) async { + setCredentials(url, user, password); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('navidrome_url', url); + await prefs.setString('navidrome_user', user); + await _secure.write(key: 'navidrome_pass', value: password); + } catch (e) { + debugPrint('Fehler beim Speichern der Navidrome-Zugangsdaten: $e'); + } + } + + Future loescheZugangsdaten() async { + _serverUrl = ''; + _user = ''; + _password = ''; + _salt = ''; + _token = null; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('navidrome_url'); + await prefs.remove('navidrome_user'); + await _secure.delete(key: 'navidrome_pass'); + } catch (e) { + debugPrint('Fehler beim Löschen der Navidrome-Zugangsdaten: $e'); + } + } + + bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty; + + Uri _uri(String endpoint, [Map? extra]) { + final params = { + 'u': _user, + 't': _token!, + 's': _salt, + 'v': _version, + 'c': _client, + 'f': 'json', + }; + if (extra != null) params.addAll(extra); + return Uri.parse('$_serverUrl/rest/$endpoint').replace(queryParameters: params); + } + + Future ping() async { + try { + final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10)); + return r.statusCode == 200; + } catch (e) { + debugPrint('Navidrome Ping Fehler: $e'); + return false; + } + } + + Future> getAlben({int anzahl = 50}) async { + try { + final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15)); + if (r.statusCode != 200) return []; + final data = jsonDecode(r.body); + final list = data['subsonic-response']?['albumList']?['album'] as List? ?? []; + return list.map((j) => SubsonicAlbum.fromJson(j)).toList(); + } catch (e) { + debugPrint('Navidrome getAlben Fehler: $e'); + return []; + } + } + + Future> getSongs(String albumId) async { + try { + final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15)); + if (r.statusCode != 200) return []; + final data = jsonDecode(r.body); + final songs = data['subsonic-response']?['album']?['song'] as List? ?? []; + return songs.map((j) => SubsonicSong.fromJson(j)).toList(); + } catch (e) { + debugPrint('Navidrome getSongs Fehler: $e'); + return []; + } + } + + Uri streamUrl(String songId) { + return _uri('stream.view', {'id': songId, 'format': 'raw'}); + } + + Uri? coverUrl(String coverId) { + return _uri('getCoverArt.view', {'id': coverId}); + } +} diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 0544317..a9937b2 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -3,15 +3,26 @@ import 'package:provider/provider.dart'; import '../library/database.dart'; import '../library/permissions.dart'; +import '../services/navidrome_service.dart'; import 'library_stats.dart'; -/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Über Melo. -/// -/// Kein Cloud-/Account-System vorhanden (Phase 2) — daher keine -/// Platzhalter-Einträge für Profil/Sync/Geräte, die aktuell ins Leere laufen. -class SettingsScreen extends StatelessWidget { +/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo. +class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late final NavidromeService _navidrome = NavidromeService(); + + @override + void initState() { + super.initState(); + _navidrome.ladeGespeicherteZugangsdaten(); + } + @override Widget build(BuildContext context) { final db = context.read(); @@ -40,6 +51,24 @@ class SettingsScreen extends StatelessWidget { }, ), const Divider(height: 1), + const _SectionLabel('Musikserver'), + ListTile( + leading: const Icon(Icons.cloud_circle), + title: const Text('🌐 Navidrome'), + subtitle: _navidrome.istVerbunden + ? const Text('✅ Verbunden') + : const Text('Nicht verbunden'), + trailing: _navidrome.istVerbunden + ? IconButton( + icon: const Icon(Icons.logout), + onPressed: () => _trennNavidrome(), + ) + : IconButton( + icon: const Icon(Icons.login), + onPressed: () => _zeigeNavidromeDialog(), + ), + ), + const Divider(height: 1), const _SectionLabel('Berechtigungen'), ListTile( leading: const Icon(Icons.mic_none), @@ -60,6 +89,113 @@ class SettingsScreen extends StatelessWidget { ), ); } + + void _zeigeNavidromeDialog() { + final urlCtrl = TextEditingController(); + final userCtrl = TextEditingController(); + final passCtrl = TextEditingController(); + bool verbindet = false; + String? fehler; + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + backgroundColor: Colors.grey.shade900, + title: const Text('🌐 Navidrome Verbinden', + style: TextStyle(color: Colors.white, fontSize: 16)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: urlCtrl, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Server-URL', + hintText: 'https://musik.example.com', + labelStyle: TextStyle(color: Colors.grey), + hintStyle: TextStyle(color: Colors.grey), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + TextField( + controller: userCtrl, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Benutzer', + labelStyle: TextStyle(color: Colors.grey), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + TextField( + controller: passCtrl, + style: const TextStyle(color: Colors.white), + obscureText: true, + decoration: const InputDecoration( + labelText: 'Passwort', + labelStyle: TextStyle(color: Colors.grey), + border: OutlineInputBorder(), + ), + ), + if (fehler != null) ...[ + const SizedBox(height: 8), + Text(fehler!, + style: const TextStyle(color: Colors.red, fontSize: 12)), + ], + if (verbindet) + const Padding( + padding: EdgeInsets.only(top: 12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.redAccent), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Abbrechen')), + TextButton( + onPressed: verbindet + ? null + : () async { + setDialogState(() => verbindet = true); + _navidrome.setCredentials( + urlCtrl.text, userCtrl.text, passCtrl.text); + final ok = await _navidrome.ping(); + setDialogState(() => verbindet = false); + if (ok && ctx.mounted) { + await _navidrome.speichereZugangsdaten( + urlCtrl.text, userCtrl.text, passCtrl.text); + if (ctx.mounted) Navigator.pop(ctx); + if (mounted) setState(() {}); + } else if (ctx.mounted) { + setDialogState(() => + fehler = '❌ Verbindung fehlgeschlagen\nPrüfe URL + Zugangsdaten'); + } + }, + child: const Text('Verbinden', + style: TextStyle(color: Colors.redAccent)), + ), + ], + ), + ), + ).then((_) { + urlCtrl.dispose(); + userCtrl.dispose(); + passCtrl.dispose(); + }); + } + + Future _trennNavidrome() async { + await _navidrome.loescheZugangsdaten(); + if (mounted) setState(() {}); + } } /// Kleine graue Überschrift über einer Einstellungs-Gruppe. diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9ad45ba..0df1c6f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,9 @@ import audio_service import audio_session import device_info_plus import file_picker_darwin +import flutter_secure_storage_darwin import just_audio +import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -17,6 +19,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 6ac59ae..41ba85b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,7 +218,7 @@ packages: source: hosted version: "0.3.5+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -382,6 +382,54 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6" + url: "https://pub.dev" + source: hosted + version: "11.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -417,7 +465,7 @@ packages: source: hosted version: "2.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -856,6 +904,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4aaa7b4..dbef790 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,10 @@ dependencies: permission_handler: ^13.0.1 device_info_plus: ^13.2.0 on_audio_query: ^2.9.0 + http: ^1.1.0 + crypto: ^3.0.3 + shared_preferences: ^2.2.2 + flutter_secure_storage: ^11.0.0 dev_dependencies: flutter_test: