Add Jarvis Google Calendar OAuth broker

This commit is contained in:
zsolt
2026-08-13 13:22:40 +02:00
parent 94f09a3328
commit 7e433ead88
10 changed files with 567 additions and 236 deletions
+115 -182
View File
@@ -25,15 +25,7 @@ class AccountsPage extends StatefulWidget {
}
class _AccountsPageState extends State<AccountsPage> {
static const calendarScopes = [
'https://www.googleapis.com/auth/calendar.readonly',
];
final googleSignIn = GoogleSignIn.instance;
List<GoogleCalendarInfo> googleCalendars = [];
GoogleSignInAccount? signedInAccount;
String? runtimeServerClientId;
bool initialized = false;
bool loadingCalendars = false;
bool syncing = false;
String? statusMessage;
@@ -41,209 +33,156 @@ class _AccountsPageState extends State<AccountsPage> {
@override
void initState() {
super.initState();
_loadGoogleAppConfigAndInitialize();
_loadGoogleConnectionStatus();
}
GoogleCalendarConnection? get connection =>
widget.connections.isEmpty ? null : widget.connections.first;
Future<void> _loadGoogleAppConfigAndInitialize() async {
setState(() {
loadingCalendars = true;
statusMessage = 'Google napt\u00e1rkapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...';
});
runtimeServerClientId = widget.serverClientId.trim().isNotEmpty
? widget.serverClientId.trim()
: await _loadGoogleServerClientId();
if (!mounted) return;
await _initializeGoogle();
String? get _workspaceToken {
final token = widget.jarvisSettings.workspaceToken?.trim();
return token == null || token.isEmpty ? null : token;
}
Future<String?> _loadGoogleServerClientId() async {
if (googleCalendarServerClientId.trim().isNotEmpty) {
return googleCalendarServerClientId.trim();
Map<String, String> get _jarvisHeaders {
final headers = <String, String>{
'X-Otthon-Token': widget.jarvisSettings.apiToken.trim(),
};
final workspaceToken = _workspaceToken;
if (workspaceToken != null) {
headers['X-Workspace-Token'] = workspaceToken;
}
return headers;
}
final apiToken = widget.jarvisSettings.apiToken.trim();
if (apiToken.isEmpty) {
return null;
Uri _jarvisUri(String path, [Map<String, String?> query = const {}]) {
final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl));
return baseUri.replace(path: path, queryParameters: query.isEmpty ? null : query);
}
String? _jarvisConfigError() {
if (widget.jarvisSettings.apiToken.trim().isEmpty) {
return 'Add meg a JARVIS API tokent a fogasker\u00e9k alatt.';
}
if (_workspaceToken == null) {
return 'Add meg a k\u00f6z\u00f6s munkat\u00e9r tokent a fogasker\u00e9k alatt.';
}
return null;
}
Future<void> _loadGoogleConnectionStatus() async {
setState(() {
loadingCalendars = true;
statusMessage = 'JARVIS Google kapcsolat ellen\u0151rz\u00e9se...';
});
final configError = _jarvisConfigError();
if (configError != null) {
setState(() {
loadingCalendars = false;
statusMessage = configError;
});
return;
}
try {
final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl));
final uri = baseUri.replace(path: '/api/app-config', query: '');
final response = await http
.get(uri, headers: {'X-Otthon-Token': apiToken})
.get(_jarvisUri('/api/google/oauth/status'), headers: _jarvisHeaders)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
return null;
throw Exception('JARVIS status ${response.statusCode}: ${response.body}');
}
final payload = jsonDecode(response.body);
if (payload is! Map<String, dynamic>) {
return null;
throw Exception('Hib\u00e1s JARVIS v\u00e1lasz.');
}
return payload['googleCalendarServerClientId'] as String?;
} catch (_) {
return null;
}
}
Future<void> _initializeGoogle() async {
setState(() {
loadingCalendars = true;
statusMessage = 'Google kapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...';
});
try {
await _ensureGoogleInitialized();
initialized = true;
final lightweight = googleSignIn.attemptLightweightAuthentication();
final account = lightweight == null ? null : await lightweight;
if (!mounted) return;
if (account != null) {
signedInAccount = account;
await _loadGoogleCalendars(promptIfNecessary: false);
final connected = payload['connected'] == true;
final email = payload['email'] as String?;
if (connected) {
final current = connection;
widget.onChanged([
GoogleCalendarConnection(
id: current?.id ?? uniqueId(),
accountEmail: email ?? current?.accountEmail ?? 'Google fi\u00f3k',
enabledCalendarIds: current?.enabledCalendarIds ?? const [],
calendarPersonIds: current?.calendarPersonIds ?? const {},
lastSync: current?.lastSync,
),
]);
await _loadGoogleCalendars();
} else {
setState(() {
loadingCalendars = false;
statusMessage =
'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rak szinkroniz\u00e1ljanak.';
'Google Calendar m\u00e9g nincs \u00f6sszek\u00f6tve. Ind\u00edtsd az \u00f6sszek\u00f6t\u00e9st JARVIS-on kereszt\u00fcl.';
});
}
} catch (error) {
if (!mounted) return;
setState(() {
loadingCalendars = false;
statusMessage = _friendlyGoogleError(
'Google inicializ\u00e1l\u00e1s nem siker\u00fclt',
error,
);
statusMessage = 'JARVIS Google kapcsolat nem el\u00e9rhet\u0151: $error';
});
}
}
Future<Map<String, String>> _googleHeaders({
bool promptIfNecessary = true,
}) async {
final account = signedInAccount;
final headers = account == null
? await googleSignIn.authorizationClient.authorizationHeaders(
calendarScopes,
promptIfNecessary: promptIfNecessary,
)
: await account.authorizationClient.authorizationHeaders(
calendarScopes,
promptIfNecessary: promptIfNecessary,
);
if (headers == null) {
throw Exception('A Google Calendar jogosults\u00e1g nincs megadva.');
}
return headers;
}
Future<void> _signInGoogle() async {
Future<void> _startGoogleConnection() async {
setState(() {
loadingCalendars = true;
statusMessage = 'Google bejelentkez\u00e9s...';
statusMessage = 'Google \u00f6sszek\u00f6t\u00e9s ind\u00edt\u00e1sa JARVIS-on...';
});
try {
if (!initialized) {
runtimeServerClientId = widget.serverClientId.trim().isNotEmpty
? widget.serverClientId.trim()
: await _loadGoogleServerClientId();
await _ensureGoogleInitialized();
initialized = true;
}
final account = await googleSignIn.authenticate();
signedInAccount = account;
await account.authorizationClient.authorizeScopes(calendarScopes);
await _loadGoogleCalendars(promptIfNecessary: true);
} catch (error) {
if (!mounted) return;
final friendlyMessage = _friendlyGoogleError(
'Google bejelentkez\u00e9s nem siker\u00fclt',
error,
);
final configError = _jarvisConfigError();
if (configError != null) {
setState(() {
loadingCalendars = false;
statusMessage = friendlyMessage;
statusMessage = configError;
});
await _showGoogleErrorDialog(friendlyMessage);
}
}
String _friendlyGoogleError(String prefix, Object error) {
final raw = error.toString();
if (raw.contains('serverClientId must be provided') ||
raw.contains('CredentialManager requires a serverClientId')) {
return '$prefix: hi\u00e1nyzik az app Google konfigur\u00e1ci\u00f3ja. '
'A bel\u00e9p\u00e9si ablakos m\u0171k\u00f6d\u00e9shez tedd be az android/app/google-services.json f\u00e1jlt, '
'amely tartalmaz Web application OAuth client ID-t, majd buildeld \u00fajra az APK-t.';
}
if (raw.contains('Account reauth failed')) {
return '$prefix: a Google fi\u00f3k \u00fajrahiteles\u00edt\u00e9se nem siker\u00fclt. '
'Pr\u00f3b\u00e1ld meg a tableten a Google fi\u00f3kot friss\u00edteni/bejelentkeztetni a Be\u00e1ll\u00edt\u00e1sokban, '
'vagy t\u00f6r\u00f6ld az app Google hozz\u00e1f\u00e9r\u00e9s\u00e9t, majd jelentkezz be \u00fajra.';
}
return '$prefix: $raw';
}
Future<void> _showGoogleErrorDialog(String message) async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Google bel\u00e9p\u00e9s nem indult el'),
content: Text(
'$message\n\n'
'A v\u00e1rt m\u0171k\u00f6d\u00e9s az, hogy a Google saj\u00e1t bel\u00e9p\u00e9si/enged\u00e9lyez\u00e9si ablaka ny\u00edlik meg. '
'Ehhez az appnak el\u0151re regisztr\u00e1lt Google konfigur\u00e1ci\u00f3 kell, \u00e9s az eszk\u00f6z\u00f6n Google Play Services / Google fi\u00f3k is sz\u00fcks\u00e9ges.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Rendben'),
),
],
),
);
}
Future<void> _ensureGoogleInitialized() async {
final clientId = runtimeServerClientId;
if (clientId == null || clientId.isEmpty) {
await googleSignIn.initialize();
return;
}
await googleSignIn.initialize(
serverClientId: clientId,
);
try {
final response = await http
.get(_jarvisUri('/api/google/oauth/start'), headers: _jarvisHeaders)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('JARVIS OAuth ${response.statusCode}: ${response.body}');
}
final payload = jsonDecode(response.body);
if (payload is! Map<String, dynamic>) throw Exception('Hib\u00e1s JARVIS v\u00e1lasz.');
final url = payload['authorizationUrl'] as String?;
if (url == null || url.isEmpty) throw Exception('Hi\u00e1nyzik az OAuth URL.');
final launched = await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
if (!launched) throw Exception('Nem siker\u00fclt megnyitni a b\u00f6ng\u00e9sz\u0151t.');
if (!mounted) return;
setState(() {
loadingCalendars = false;
statusMessage =
'A Google enged\u00e9lyez\u00e9s b\u00f6ng\u00e9sz\u0151ben megny\u00edlt. Ha k\u00e9sz, t\u00e9rj vissza ide \u00e9s friss\u00edtsd a napt\u00e1rlist\u00e1t.';
});
} catch (error) {
if (!mounted) return;
setState(() {
loadingCalendars = false;
statusMessage = 'Google \u00f6sszek\u00f6t\u00e9s nem indult el: $error';
});
}
}
Future<void> _loadGoogleCalendars({bool promptIfNecessary = true}) async {
Future<void> _loadGoogleCalendars() async {
setState(() {
loadingCalendars = true;
statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...';
});
try {
final headers = await _googleHeaders(
promptIfNecessary: promptIfNecessary,
);
final uri = Uri.https(
'www.googleapis.com',
'/calendar/v3/users/me/calendarList',
{'minAccessRole': 'reader', 'showHidden': 'true'},
);
final response = await http
.get(uri, headers: headers)
.get(_jarvisUri('/api/google/calendars'), headers: _jarvisHeaders)
.timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw Exception(
'CalendarList API ${response.statusCode}: ${response.body}',
'JARVIS CalendarList ${response.statusCode}: ${response.body}',
);
}
@@ -263,16 +202,15 @@ class _AccountsPageState extends State<AccountsPage> {
return a.name.compareTo(b.name);
});
final account = signedInAccount;
final current = connection;
if (account != null) {
if (current == null) {
widget.onChanged([
GoogleCalendarConnection(
id: current?.id ?? uniqueId(),
accountEmail: account.email,
enabledCalendarIds: current?.enabledCalendarIds ?? const [],
calendarPersonIds: current?.calendarPersonIds ?? const {},
lastSync: current?.lastSync,
id: uniqueId(),
accountEmail: 'Google Calendar',
enabledCalendarIds: const [],
calendarPersonIds: const {},
lastSync: null,
),
]);
}
@@ -312,9 +250,8 @@ class _AccountsPageState extends State<AccountsPage> {
try {
if (googleCalendars.isEmpty) {
await _loadGoogleCalendars(promptIfNecessary: true);
await _loadGoogleCalendars();
}
final headers = await _googleHeaders(promptIfNecessary: true);
final selectedCalendars = googleCalendars
.where((calendar) => current.enabledCalendarIds.contains(calendar.id))
.toList();
@@ -329,19 +266,16 @@ class _AccountsPageState extends State<AccountsPage> {
final end = start.add(const Duration(days: 180));
for (final calendar in selectedCalendars) {
final uri = Uri.https(
'www.googleapis.com',
'/calendar/v3/calendars/${Uri.encodeComponent(calendar.id)}/events',
final uri = _jarvisUri(
'/api/google/events',
{
'singleEvents': 'true',
'orderBy': 'startTime',
'calendarId': calendar.id,
'timeMin': start.toUtc().toIso8601String(),
'timeMax': end.toUtc().toIso8601String(),
'maxResults': '2500',
},
);
final response = await http
.get(uri, headers: headers)
.get(uri, headers: _jarvisHeaders)
.timeout(const Duration(seconds: 20));
if (response.statusCode != 200) {
throw Exception('${calendar.name}: Events API ${response.statusCode}');
@@ -396,13 +330,13 @@ class _AccountsPageState extends State<AccountsPage> {
.length;
return PageFrame(
eyebrow: 'Google Calendar API szinkron',
eyebrow: 'JARVIS Google Calendar szinkron',
title: 'Google napt\u00e1rak',
action: FilledButton.icon(
onPressed: loadingCalendars ? null : _signInGoogle,
icon: const Icon(Icons.login),
onPressed: loadingCalendars ? null : _startGoogleConnection,
icon: const Icon(Icons.open_in_browser),
label: Text(
hasConnection ? 'Fi\u00f3k v\u00e1lt\u00e1sa' : 'Google bel\u00e9p\u00e9s',
hasConnection ? 'Google \u00f6sszek\u00f6t\u00e9s \u00fajranyit\u00e1sa' : 'Google \u00f6sszek\u00f6t\u00e9s',
),
),
child: ListView(
@@ -491,24 +425,23 @@ class _AccountsPageState extends State<AccountsPage> {
OutlinedButton.icon(
onPressed: loadingCalendars
? null
: () => _loadGoogleCalendars(promptIfNecessary: true),
: _loadGoogleConnectionStatus,
icon: const Icon(Icons.refresh),
label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () async {
await googleSignIn.disconnect();
widget.onChanged([]);
widget.onSyncedEventsChanged([]);
setState(() {
signedInAccount = null;
googleCalendars = [];
statusMessage = 'Google kapcsolat lev\u00e1lasztva.';
statusMessage =
'Helyi Google kapcsolat t\u00f6r\u00f6lve. A JARVIS token szerveren marad; \u00faj \u00f6sszek\u00f6t\u00e9ssel fel\u00fcl\u00edrhat\u00f3.';
});
},
icon: const Icon(Icons.link_off),
label: const Text('Google kapcsolat lev\u00e1laszt\u00e1sa'),
label: const Text('Helyi Google cache t\u00f6rl\u00e9se'),
),
],
if (statusMessage != null) ...[
+1 -1
View File
@@ -7,11 +7,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:geocoding/geocoding.dart' as geocoding;
import 'package:geolocator/geolocator.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:http/http.dart' as http;
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:path_provider/path_provider.dart';
import 'package:timezone/data/latest.dart' as timezone_data;
import 'package:url_launcher/url_launcher.dart';
part 'data.dart';
part 'app_shell.dart';