naptar-es-feladatkezelo/lib/google_calendar_page.dart

730 lines
24 KiB
Dart

part of 'main.dart';
class AccountsPage extends StatefulWidget {
const AccountsPage({
super.key,
required this.connections,
required this.cachedEvents,
required this.people,
required this.jarvisSettings,
required this.serverClientId,
required this.onChanged,
required this.onSyncedEventsChanged,
});
final List<GoogleCalendarConnection> connections;
final List<CalendarEvent> cachedEvents;
final List<Person> people;
final JarvisSettings jarvisSettings;
final String serverClientId;
final ValueChanged<List<GoogleCalendarConnection>> onChanged;
final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged;
@override
State<AccountsPage> createState() => _AccountsPageState();
}
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;
@override
void initState() {
super.initState();
_loadGoogleAppConfigAndInitialize();
}
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();
}
Future<String?> _loadGoogleServerClientId() async {
if (googleCalendarServerClientId.trim().isNotEmpty) {
return googleCalendarServerClientId.trim();
}
final apiToken = widget.jarvisSettings.apiToken.trim();
if (apiToken.isEmpty) {
return null;
}
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})
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
return null;
}
final payload = jsonDecode(response.body);
if (payload is! Map<String, dynamic>) {
return null;
}
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);
} else {
setState(() {
loadingCalendars = false;
statusMessage =
'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rak szinkroniz\u00e1ljanak.';
});
}
} catch (error) {
if (!mounted) return;
setState(() {
loadingCalendars = false;
statusMessage = _friendlyGoogleError(
'Google inicializ\u00e1l\u00e1s nem siker\u00fclt',
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 {
setState(() {
loadingCalendars = true;
statusMessage = 'Google bejelentkez\u00e9s...';
});
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,
);
setState(() {
loadingCalendars = false;
statusMessage = friendlyMessage;
});
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,
);
}
Future<void> _loadGoogleCalendars({bool promptIfNecessary = true}) 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)
.timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw Exception(
'CalendarList API ${response.statusCode}: ${response.body}',
);
}
final payload = jsonDecode(response.body);
if (payload is! Map<String, dynamic>) {
throw Exception('Hib\u00e1s Google Calendar v\u00e1lasz.');
}
final items = payload['items'];
final calendars = items is List
? items
.whereType<Map<String, dynamic>>()
.map(GoogleCalendarInfo.fromJson)
.toList()
: <GoogleCalendarInfo>[];
calendars.sort((a, b) {
if (a.primary != b.primary) return a.primary ? -1 : 1;
return a.name.compareTo(b.name);
});
final account = signedInAccount;
final current = connection;
if (account != null) {
widget.onChanged([
GoogleCalendarConnection(
id: current?.id ?? uniqueId(),
accountEmail: account.email,
enabledCalendarIds: current?.enabledCalendarIds ?? const [],
calendarPersonIds: current?.calendarPersonIds ?? const {},
lastSync: current?.lastSync,
),
]);
}
if (!mounted) return;
setState(() {
googleCalendars = calendars;
loadingCalendars = false;
statusMessage = calendars.isEmpty
? 'A Google fi\u00f3kban nem tal\u00e1ltam olvashat\u00f3 napt\u00e1rat.'
: '${calendars.length} Google napt\u00e1r el\u00e9rhet\u0151. V\u00e1laszd ki, melyik ker\u00fclj\u00f6n be a csal\u00e1di napt\u00e1rba.';
});
} catch (error) {
if (!mounted) return;
setState(() {
loadingCalendars = false;
statusMessage =
'Google napt\u00e1rlista lek\u00e9r\u00e9se nem siker\u00fclt: $error';
});
}
}
Future<void> _syncSelectedCalendars() async {
final current = connection;
if (current == null || current.enabledCalendarIds.isEmpty) {
setState(
() => statusMessage =
'V\u00e1lassz ki legal\u00e1bb egy Google napt\u00e1rat.',
);
return;
}
setState(() {
syncing = true;
statusMessage = 'Google esem\u00e9nyek beolvas\u00e1sa...';
});
try {
if (googleCalendars.isEmpty) {
await _loadGoogleCalendars(promptIfNecessary: true);
}
final headers = await _googleHeaders(promptIfNecessary: true);
final selectedCalendars = googleCalendars
.where((calendar) => current.enabledCalendarIds.contains(calendar.id))
.toList();
final synced = <CalendarEvent>[];
final syncDetails = <String>[];
final now = DateTime.now();
final start = DateTime(
now.year,
now.month,
now.day,
).subtract(const Duration(days: 30));
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',
{
'singleEvents': 'true',
'orderBy': 'startTime',
'timeMin': start.toUtc().toIso8601String(),
'timeMax': end.toUtc().toIso8601String(),
'maxResults': '2500',
},
);
final response = await http
.get(uri, headers: headers)
.timeout(const Duration(seconds: 20));
if (response.statusCode != 200) {
throw Exception('${calendar.name}: Events API ${response.statusCode}');
}
final payload = jsonDecode(response.body);
if (payload is! Map<String, dynamic>) continue;
final rawEvents = payload['items'];
final events = rawEvents is List
? rawEvents.whereType<Map<String, dynamic>>().toList()
: <Map<String, dynamic>>[];
syncDetails.add('${calendar.name}: ${events.length}');
for (final event in events) {
if (event['status'] == 'cancelled') continue;
synced.add(
CalendarEvent.fromGoogleEvent(
event,
calendar,
personId: current.calendarPersonIds[calendar.id],
),
);
}
}
synced.sort((a, b) {
final dateCompare = a.date.compareTo(b.date);
return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start);
});
widget.onSyncedEventsChanged(synced);
widget.onChanged([current.copyWith(lastSync: DateTime.now())]);
setState(() {
syncing = false;
statusMessage =
'${synced.length} Google esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.'
'${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}';
});
} catch (error) {
setState(() {
syncing = false;
statusMessage = 'A Google szinkron nem siker\u00fclt: $error';
});
}
}
@override
Widget build(BuildContext context) {
final current = connection;
final hasConnection = current != null && current.accountEmail.isNotEmpty;
final cachedUncategorized = widget.cachedEvents
.where((event) => event.isUncategorized)
.length;
return PageFrame(
eyebrow: 'Google Calendar API szinkron',
title: 'Google napt\u00e1rak',
action: FilledButton.icon(
onPressed: loadingCalendars ? null : _signInGoogle,
icon: const Icon(Icons.login),
label: Text(
hasConnection ? 'Fi\u00f3k v\u00e1lt\u00e1sa' : 'Google bel\u00e9p\u00e9s',
),
),
child: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
GoogleSyncHero(
accountEmail: current?.accountEmail,
enabledCount: current?.enabledCalendarIds.length ?? 0,
cachedCount: widget.cachedEvents.length,
uncategorizedCount: cachedUncategorized,
),
const SizedBox(height: 22),
if (!hasConnection)
const EmptyGoogleConnectionCard()
else ...[
const Text(
'Google Calendar API-b\u00f3l el\u00e9rhet\u0151 napt\u00e1rak',
style: TextStyle(
color: ink,
fontWeight: FontWeight.w800,
fontSize: 18,
),
),
const SizedBox(height: 10),
if (loadingCalendars)
const LinearProgressIndicator()
else
...googleCalendars.map(
(calendar) => GoogleCalendarToggleCard(
calendar: calendar,
enabled: current.enabledCalendarIds.contains(calendar.id),
people: widget.people,
selectedPersonId: current.calendarPersonIds[calendar.id],
onChanged: (enabled) {
final nextIds = [...current.enabledCalendarIds];
final nextPersonIds = {...current.calendarPersonIds};
if (enabled) {
if (!nextIds.contains(calendar.id)) {
nextIds.add(calendar.id);
}
} else {
nextIds.remove(calendar.id);
nextPersonIds.remove(calendar.id);
}
widget.onChanged([
current.copyWith(
enabledCalendarIds: nextIds,
calendarPersonIds: nextPersonIds,
lastSync: current.lastSync,
),
]);
},
onPersonChanged: (personId) {
final nextPersonIds = {...current.calendarPersonIds};
if (personId == null) {
nextPersonIds.remove(calendar.id);
} else {
nextPersonIds[calendar.id] = personId;
}
widget.onChanged([
current.copyWith(
calendarPersonIds: nextPersonIds,
lastSync: current.lastSync,
),
]);
},
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: syncing ? null : _syncSelectedCalendars,
icon: syncing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: Text(
syncing
? 'Szinkroniz\u00e1l\u00e1s...'
: 'Szinkroniz\u00e1l\u00e1s most',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: loadingCalendars
? null
: () => _loadGoogleCalendars(promptIfNecessary: true),
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.';
});
},
icon: const Icon(Icons.link_off),
label: const Text('Google kapcsolat lev\u00e1laszt\u00e1sa'),
),
],
if (statusMessage != null) ...[
const SizedBox(height: 14),
Text(statusMessage!, style: const TextStyle(color: Colors.black54)),
],
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: mint,
borderRadius: BorderRadius.circular(18),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: sage),
SizedBox(width: 13),
Expanded(
child: Text(
'Ez a szinkron egyir\u00e1ny\u00fa: a Google Calendar API-b\u00f3l olvas, az esem\u00e9nyeket JSON cache-be menti, \u00e9s nem \u00edr vissza Google napt\u00e1rba. Offline \u00e1llapotban a kor\u00e1bban beolvasott cache marad l\u00e1that\u00f3.',
style: TextStyle(color: ink, height: 1.45),
),
),
],
),
),
],
),
);
}
}
class GoogleSyncHero extends StatelessWidget {
const GoogleSyncHero({
super.key,
required this.accountEmail,
required this.enabledCount,
required this.cachedCount,
required this.uncategorizedCount,
});
final String? accountEmail;
final int enabledCount;
final int cachedCount;
final int uncategorizedCount;
@override
Widget build(BuildContext context) {
final connected = accountEmail != null && accountEmail!.isNotEmpty;
return Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF18332D), Color(0xFF2F6B5B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: sage.withValues(alpha: .22),
blurRadius: 28,
offset: const Offset(0, 16),
),
],
),
child: Row(
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .16),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.g_mobiledata_rounded,
color: Colors.white,
size: 44,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connected
? accountEmail!
: 'M\u00e9g nincs Google napt\u00e1rkapcsolat',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 20,
),
),
const SizedBox(height: 5),
Text(
connected
? '$enabledCount napt\u00e1r bekapcsolva \u00b7 $cachedCount esem\u00e9ny cache-ben \u00b7 $uncategorizedCount kategoriz\u00e1latlan'
: 'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rakb\u00f3l hozzon esem\u00e9nyeket.',
style: const TextStyle(color: Colors.white70, height: 1.35),
),
],
),
),
],
),
);
}
}
class EmptyGoogleConnectionCard extends StatelessWidget {
const EmptyGoogleConnectionCard({super.key});
@override
Widget build(BuildContext context) {
return const Card(
child: Padding(
padding: EdgeInsets.all(20),
child: Row(
children: [
Icon(Icons.calendar_month_outlined, color: sage),
SizedBox(width: 14),
Expanded(
child: Text(
'Google bel\u00e9p\u00e9s ut\u00e1n az app k\u00f6zvetlen\u00fcl a Google Calendar API-b\u00f3l k\u00e9ri le a napt\u00e1rakat. Android rendszer-napt\u00e1r szinkron m\u00e1r nem kell hozz\u00e1.',
style: TextStyle(color: ink, height: 1.4),
),
),
],
),
),
);
}
}
class GoogleCalendarToggleCard extends StatelessWidget {
const GoogleCalendarToggleCard({
super.key,
required this.calendar,
required this.enabled,
required this.people,
required this.selectedPersonId,
required this.onChanged,
required this.onPersonChanged,
});
final GoogleCalendarInfo calendar;
final bool enabled;
final List<Person> people;
final String? selectedPersonId;
final ValueChanged<bool> onChanged;
final ValueChanged<String?> onPersonChanged;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
children: [
SwitchListTile(
value: enabled,
activeThumbColor: calendar.color,
onChanged: onChanged,
secondary: CircleAvatar(
backgroundColor: calendar.color.withValues(alpha: .14),
child: Icon(
calendar.primary ? Icons.star_rounded : Icons.calendar_today,
color: calendar.color,
size: 19,
),
),
title: Text(
calendar.name,
style: const TextStyle(color: ink, fontWeight: FontWeight.w800),
),
subtitle: Text(
calendar.description.isEmpty
? calendar.id
: '${calendar.description} \u00b7 ${calendar.id}',
),
),
if (enabled)
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 0),
child: DropdownButtonFormField<String?>(
initialValue: selectedPersonId,
decoration: const InputDecoration(
labelText: 'Kihez tartozzanak az esem\u00e9nyek?',
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Kategoriz\u00e1latlan'),
),
const DropdownMenuItem<String?>(
value: everyonePersonId,
child: Text('Mindenki'),
),
...people.map(
(person) => DropdownMenuItem<String?>(
value: person.id,
child: Text('${person.avatar} ${person.name}'),
),
),
],
onChanged: onPersonChanged,
),
),
],
),
),
);
}
}