From c01fdcf012aa69e588f78e529454f853b3869fc4 Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Mon, 6 Jul 2026 09:53:33 +0200 Subject: [PATCH] =?UTF-8?q?Google=20authentik=C3=A1ci=C3=B3=20bevezet?= =?UTF-8?q?=C3=A9se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- android/app/google-services.json | 24 ++ lib/main.dart | 2 + .../controllers/socket_test_controller.dart | 16 +- lib/services/auth_service.dart | 153 ++++++++++++ lib/widgets/app_drawer.dart | 122 ++++++---- lib/widgets/project/join_project_sheet.dart | 230 ++++++++++++++++++ pubspec.yaml | 2 +- 8 files changed, 500 insertions(+), 51 deletions(-) create mode 100644 lib/services/auth_service.dart create mode 100644 lib/widgets/project/join_project_sheet.dart diff --git a/.env b/.env index 6f5f4fc..ad7a156 100644 --- a/.env +++ b/.env @@ -2,4 +2,4 @@ SUPABASE_URL=https://supa.app-dev.hu SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJyb2xlIjogImFub24iLAogICJpc3MiOiAic3VwYWJhc2UiLAogICJpYXQiOiAxNzQwMjY1MjAwLAogICJleHAiOiAxODk4MDMxNjAwCn0.4cMVfAnBLxne1lq0fm94rgtXLBJdRx-0f-E4Jd_jFwI -GOOGLE_WEB_CLIENT_ID=123456789-abc.apps.googleusercontent.com \ No newline at end of file +GOOGLE_WEB_CLIENT_ID=34126164738-dejrjhuqrv1qehqf3pprkrs3jltrb89n.apps.googleusercontent.com \ No newline at end of file diff --git a/android/app/google-services.json b/android/app/google-services.json index d9c3c55..f871360 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -13,6 +13,30 @@ } }, "oauth_client": [ + { + "client_id": "34126164738-fkuskqkn4j9efh7c73ahm0olgkpqrsck.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "hu.app_dev.terepi_seged", + "certificate_hash": "ce49d032c9782c11d0cde121f36751324846ff77" + } + }, + { + "client_id": "34126164738-oeb8993sbc9fqdoag66fi97fae1oun14.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "hu.app_dev.terepi_seged", + "certificate_hash": "105d948b791a4b4bf3ab2489e230278bdcec4d3e" + } + }, + { + "client_id": "34126164738-qb9m4ifm5sttbjo9km681kljsnmna8ds.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "hu.app_dev.terepi_seged", + "certificate_hash": "f54d498b4e8f2d9c3b8d9f0d9a5a1d5f0b7e0297" + } + }, { "client_id": "34126164738-s3jjdhb3htkv6l18bqbgi53ebehhh4mp.apps.googleusercontent.com", "client_type": 1, diff --git a/lib/main.dart b/lib/main.dart index b8a6573..455e512 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_co import 'package:terepi_seged/routes/app_pages.dart'; import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/app_logger.dart'; +import 'package:terepi_seged/services/auth_service.dart'; import 'package:terepi_seged/services/coord_converter_service.dart'; import 'package:terepi_seged/services/device_identity_service.dart'; import 'package:terepi_seged/services/firebase_logger.dart'; @@ -59,6 +60,7 @@ Future main() async { Get.put(LayerImportService(), permanent: true); Get.put(DeviceIdentityService(), permanent: true); Get.put(TrackSyncService(), permanent: true); + Get.put(AuthService()); runApp(const MyApp()); } diff --git a/lib/pages/socket_test/presentation/controllers/socket_test_controller.dart b/lib/pages/socket_test/presentation/controllers/socket_test_controller.dart index d11a6f6..13173a9 100644 --- a/lib/pages/socket_test/presentation/controllers/socket_test_controller.dart +++ b/lib/pages/socket_test/presentation/controllers/socket_test_controller.dart @@ -12,9 +12,9 @@ class SocketTestController extends GetxController { StreamSubscription? socketStreamSubscription; RxString message = ''.obs; - final GoogleSignIn googleSignIn = - GoogleSignIn(scopes: ['https://www.googleapis.com/auth/drive.appdata']); - late GoogleSignInAccount? googleSignInAccount; + // final GoogleSignIn googleSignIn = + // GoogleSignIn(scopes: ['https://www.googleapis.com/auth/drive.appdata']); + // late GoogleSignInAccount? googleSignInAccount; @override void onInit() { @@ -31,13 +31,13 @@ class SocketTestController extends GetxController { } void googleLogIn() async { - googleSignInAccount = await googleSignIn.signIn(); + // googleSignInAccount = await googleSignIn.signIn(); - final GoogleSignInAuthentication googleSignInAuthentication = - await googleSignInAccount!.authentication; + // final GoogleSignInAuthentication googleSignInAuthentication = + // await googleSignInAccount!.authentication; - print("AccessToken: ${googleSignInAuthentication.accessToken}"); - print("IdToken: ${googleSignInAuthentication.idToken}"); + // print("AccessToken: ${googleSignInAuthentication.accessToken}"); + // print("IdToken: ${googleSignInAuthentication.idToken}"); } void connect() async { diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..4a1c4dc --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,153 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get/get.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Ember által olvasható hitelesítési hiba. +class AuthServiceException implements Exception { + final String message; + AuthServiceException(this.message); + @override + String toString() => message; +} + +/// Hitelesítési service — google_sign_in 7.x + Supabase. +/// +/// Folyamat: natív Google bejelentkezés → idToken (+ accessToken az +/// authorizationClient-ből) → Supabase signInWithIdToken → Supabase +/// session (amit a supabase_flutter magától perzisztál és frissít, ezért +/// app-újraindításnál NEM kell újra Google-bejelentkezés). +/// +/// FONTOS (google_sign_in 7.x változások a 6.x-hez képest): +/// * singleton: GoogleSignIn.instance (nincs konstruktor-példányosítás) +/// * kötelező egyszeri initialize() minden más hívás előtt +/// * signIn() helyett authenticate(); a megszakítást kivétel jelzi +/// * az authentication már csak idToken-t ad; accessToken a +/// authorizationClient-től kérhető +/// +/// A GetX-minta szerint az állapot Rx-ekben él: a UI (drawer-fejléc, +/// beállítások) Obx-szel figyeli a currentUser-t. +class AuthService extends GetxService { + static AuthService get to => Get.find(); + + SupabaseClient get _supabase => Supabase.instance.client; + + // ── Reaktív állapot ────────────────────────────────────────────── + final currentUser = Rxn(); + final isBusy = false.obs; + + bool get isSignedIn => currentUser.value != null; + + String get displayName { + final u = currentUser.value; + if (u == null) return 'Nincs bejelentkezve'; + return (u.userMetadata?['full_name'] as String?) ?? + (u.userMetadata?['name'] as String?) ?? + u.email ?? + 'Felhasználó'; + } + + String get email => currentUser.value?.email ?? ''; + + String? get avatarUrl => + currentUser.value?.userMetadata?['avatar_url'] as String?; + + bool _googleInitialized = false; + + @override + void onInit() { + super.onInit(); + + // A Supabase a sessiont maga perzisztálja — induláskor csak átvesszük. + currentUser.value = _supabase.auth.currentUser; + + // Minden auth-változás (bejelentkezés, kijelentkezés, token-frissítés) + // ide fut be; a UI a currentUser-en keresztül reagál. + _supabase.auth.onAuthStateChange.listen((data) { + currentUser.value = data.session?.user; + }); + } + + /// Egyszeri google_sign_in inicializálás (a 7.x megköveteli). + /// A serverClientId a Supabase-ben engedélyezett WEB client ID — + /// az idToken "közönsége" (aud) így egyezik a Supabase elvárásával. + Future _ensureGoogleInitialized() async { + if (_googleInitialized) return; + final webClientId = dotenv.env['GOOGLE_WEB_CLIENT_ID']; + if (webClientId == null || webClientId.isEmpty) { + throw AuthServiceException( + 'Hiányzó GOOGLE_WEB_CLIENT_ID a .env fájlból.'); + } + await GoogleSignIn.instance.initialize(serverClientId: webClientId); + _googleInitialized = true; + } + + /// Google bejelentkezés → Supabase session. + /// Visszatérés: true = bejelentkezve; false = a felhasználó megszakította. + /// Egyéb hibánál [AuthServiceException]-t dob, magyar üzenettel. + Future signInWithGoogle() async { + if (isBusy.value) return false; + isBusy.value = true; + try { + await _ensureGoogleInitialized(); + + // Interaktív bejelentkezés — megszakítást kivétel jelzi (7.x). + final GoogleSignInAccount account; + try { + account = await GoogleSignIn.instance.authenticate(); + } on GoogleSignInException catch (e) { + if (e.code == GoogleSignInExceptionCode.canceled) return false; + throw AuthServiceException('Google-bejelentkezési hiba: ' + '${e.description ?? e.code.name}'); + } + + final idToken = account.authentication.idToken; + if (idToken == null) { + throw AuthServiceException( + 'A Google nem adott vissza idToken-t — ellenőrizd a ' + 'GOOGLE_WEB_CLIENT_ID beállítást.'); + } + + // accessToken a 7.x-ben külön engedélyezési lépés (a Supabase + // Google idToken-flow-ja kéri). + const scopes = ['email', 'profile']; + final authorization = + await account.authorizationClient.authorizationForScopes(scopes) ?? + await account.authorizationClient.authorizeScopes(scopes); + + await _supabase.auth.signInWithIdToken( + provider: OAuthProvider.google, + idToken: idToken, + accessToken: authorization.accessToken, + ); + + // currentUser-t az onAuthStateChange listener állítja be. + if (kDebugMode) { + debugPrint('[Auth] Bejelentkezve: ${account.email}'); + } + return true; + } on AuthException catch (e) { + throw AuthServiceException('Supabase hitelesítési hiba: ${e.message}'); + } finally { + isBusy.value = false; + } + } + + /// Kijelentkezés mindkét oldalról (Supabase session + Google fiók). + Future signOut() async { + isBusy.value = true; + try { + await _supabase.auth.signOut(); + if (_googleInitialized) { + try { + await GoogleSignIn.instance.signOut(); + } catch (_) { + // A Google-oldali kijelentkezés hibája nem kritikus. + } + } + } finally { + isBusy.value = false; + } + } +} diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 151545f..d18163c 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:terepi_seged/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.dart'; import 'package:terepi_seged/routes/app_pages.dart'; +import 'package:terepi_seged/services/auth_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/services/project_service.dart'; @@ -160,6 +161,18 @@ class AppDrawer extends StatelessWidget { // _showBasemapPicker(context); }, ), + // ListTile( + // leading: const Icon(Icons.man_3), + // title: const Text('Bejelentkezés'), + // onTap: () async { + // Get.back(); + // // _showBasemapPicker(context); + // final crtl1 = Get.find(); + // if (crtl1 != null) { + // await crtl1.signIn(); + // } + // }, + // ), ListTile( leading: const Icon(Icons.straighten), @@ -234,49 +247,76 @@ class _DrawerHeader extends StatelessWidget { @override Widget build(BuildContext context) { - // TODO: Obx(() { final user = AuthService.to.currentUser.value; ... }) - // amikor az AuthService be lesz kötve. - return Container( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), - child: Row(children: [ - CircleAvatar( - radius: 22, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Text( - '?', // user?.fullName[0].toUpperCase() ?? '?' - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onPrimaryContainer, + return Obx(() { + final auth = AuthService.to; + final signedIn = auth.isSignedIn; + + return InkWell( + // Kijelentkezve: koppintás = Google-bejelentkezés indítása. + onTap: signedIn || auth.isBusy.value ? null : _signIn, + child: Container( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + child: Row(children: [ + CircleAvatar( + radius: 22, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + backgroundImage: + auth.avatarUrl != null ? NetworkImage(auth.avatarUrl!) : null, + child: auth.avatarUrl == null + ? Text( + signedIn ? auth.displayName[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ) + : null, ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Ismeretlen felhasználó', - // user?.fullName ?? 'Ismeretlen felhasználó' - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + signedIn ? auth.displayName : 'Nincs bejelentkezve', + style: const TextStyle( + fontWeight: FontWeight.w600, fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + Text( + signedIn + ? auth.email + : 'Koppints a Google-bejelentkezéshez', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.secondary, + ), + overflow: TextOverflow.ellipsis, + ), + ], ), - Text( - 'Vendég', // user?.role.label ?? '' - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.secondary, - ), - ), - ], - ), + ), + if (!signedIn) + auth.isBusy.value + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.login, size: 18), + ]), ), - ]), - ); + ); + }); + } + + Future _signIn() async { + try { + await AuthService.to.signInWithGoogle(); + } catch (e) { + Get.snackbar('Bejelentkezés', e.toString(), + snackPosition: SnackPosition.BOTTOM); + } } } @@ -332,7 +372,7 @@ class _DrawerFooter extends StatelessWidget { FilledButton( onPressed: () { Get.back(); - // AuthService.to.signOut(); + AuthService.to.signOut(); }, child: const Text('Kilépés'), ), diff --git a/lib/widgets/project/join_project_sheet.dart b/lib/widgets/project/join_project_sheet.dart new file mode 100644 index 0000000..8497f94 --- /dev/null +++ b/lib/widgets/project/join_project_sheet.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../services/project_service.dart'; + +/// „Csatlakozás közös projekthez" — a Supabase-en elérhető közös +/// projektek listája, egy koppintásos csatlakozással. +/// +/// A lista a ts_shared_projects view-ból jön (név, tulajdonos, tagszám, +/// és hogy a felhasználó már tag-e). Hálózat nélkül a lista nem érhető el — +/// erről barátságos üzenet szól; a MÁR csatlakozott projektek viszont +/// offline is teljes értékűen használhatók, ez csak az ÚJ csatlakozáshoz +/// kell. +class JoinProjectSheet extends StatefulWidget { + const JoinProjectSheet({super.key}); + + static Future show() { + return Get.bottomSheet( + const JoinProjectSheet(), + isScrollControlled: true, + ); + } + + @override + State createState() => _JoinProjectSheetState(); +} + +class _JoinProjectSheetState extends State { + late Future>> _future; + String? _joiningId; + + @override + void initState() { + super.initState(); + // TODO + // _future = ProjectService.to.fetchSharedProjects(); + } + + void _reload() { + setState(() { + // TODO + //_future = ProjectService.to.fetchSharedProjects(); + }); + } + + Future _join(Map row) async { + setState(() => _joiningId = row['id'] as String); + try { + // TODO + //final project = await ProjectService.to.joinSharedProject(row); + Get.back(); + // Get.snackbar( + // 'Csatlakozva', + // '„${project.name}" — az adatok letöltése a háttérben fut.', + // snackPosition: SnackPosition.BOTTOM, + // ); + } catch (e) { + Get.snackbar( + 'Hiba a csatlakozáskor', + e.toString(), + snackPosition: SnackPosition.BOTTOM, + backgroundColor: const Color(0xFFB71C1C), + colorText: const Color(0xFFFFFFFF), + ); + } finally { + if (mounted) setState(() => _joiningId = null); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SafeArea( + top: false, + child: Material( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.75, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 10), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.groups_outlined), + const SizedBox(width: 8), + Text('Közös projektek', + style: Theme.of(context).textTheme.titleLarge), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Frissítés', + onPressed: _reload, + ), + ], + ), + ), + const Divider(height: 16), + Flexible( + child: FutureBuilder>>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(40), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snap.hasError) { + return _Message( + icon: Icons.cloud_off, + text: 'A közös projektlista most nem érhető el.\n' + 'Ellenőrizd a hálózatot, majd frissíts.\n\n' + 'A már csatlakozott projektjeid offline is\n' + 'teljes értékűen használhatók.', + onRetry: _reload, + ); + } + final rows = snap.data ?? []; + if (rows.isEmpty) { + return const _Message( + icon: Icons.folder_off_outlined, + text: 'Még nincs közös projekt.\n' + 'Hozz létre egyet „Közös projekt" típussal,\n' + 'és a csapat többi tagja itt fogja látni.', + ); + } + return ListView.separated( + shrinkWrap: true, + itemCount: rows.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final r = rows[i]; + final isMember = r['is_member'] == true; + final joining = _joiningId == r['id']; + + return ListTile( + leading: Icon(Icons.folder_shared_outlined, + color: _hexColor(r['color'] as String?)), + title: Text(r['name'] as String? ?? ''), + subtitle: Text( + [ + if ((r['owner_name'] as String?)?.isNotEmpty ?? + false) + r['owner_name'], + '${r['member_count'] ?? 0} tag', + if ((r['client'] as String?)?.isNotEmpty ?? false) + r['client'], + ].join(' · '), + style: const TextStyle(fontSize: 12), + ), + trailing: isMember + ? const Chip( + label: Text('Tag vagy'), + visualDensity: VisualDensity.compact, + ) + : joining + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : FilledButton.tonal( + onPressed: () => _join(r), + child: const Text('Csatlakozás'), + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } +} + +class _Message extends StatelessWidget { + final IconData icon; + final String text; + final VoidCallback? onRetry; + const _Message({required this.icon, required this.text, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 40, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text(text, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600)), + if (onRetry != null) ...[ + const SizedBox(height: 12), + TextButton(onPressed: onRetry, child: const Text('Újra')), + ], + ], + ), + ); + } +} + +Color _hexColor(String? hex) { + if (hex == null || hex.isEmpty) return const Color(0xFF185FA5); + final h = hex.replaceFirst('#', ''); + return Color(int.parse(h.length == 6 ? 'FF$h' : h, radix: 16)); +} diff --git a/pubspec.yaml b/pubspec.yaml index 3dbec71..6fe5829 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,10 +47,10 @@ dependencies: firebase_storage: ^13.4.3 firebase_crashlytics: ^5.2.4 firebase_analytics: ^12.4.3 - google_sign_in: ^6.2.2 flutter_secure_storage: ^9.2.4 googleapis: ^13.2.0 googleapis_auth: ^1.6.0 + google_sign_in: ^7.2.0 file_picker: ^9.0.0 # file_picker: ^5.0.0 sqflite: ^2.4.2