diff --git a/api/OtthonApi/Program.cs b/api/OtthonApi/Program.cs index 65f3707..92dbfcc 100644 --- a/api/OtthonApi/Program.cs +++ b/api/OtthonApi/Program.cs @@ -465,6 +465,76 @@ app.MapGet("/api/shopping/recommendations", async ( }); }); +app.MapGet("/api/workspace/state", async ( + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var token = GetWorkspaceToken(context); + if (string.IsNullOrWhiteSpace(token)) + { + return Results.BadRequest(new { message = "Missing X-Workspace-Token header." }); + } + + var workspace = await GetOrCreateWorkspace(db, token); + return Results.Ok(new WorkspaceStateResponse( + workspace.Token, + workspace.StateJson, + workspace.UpdatedUtc)); +}); + +app.MapPut("/api/workspace/state", async ( + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var token = GetWorkspaceToken(context); + if (string.IsNullOrWhiteSpace(token)) + { + return Results.BadRequest(new { message = "Missing X-Workspace-Token header." }); + } + + string? stateJson; + try + { + using var bodyDocument = await JsonDocument.ParseAsync(context.Request.Body); + var root = bodyDocument.RootElement; + stateJson = root.TryGetProperty("stateJson", out var stateProperty) + && stateProperty.ValueKind == JsonValueKind.String + ? stateProperty.GetString()?.Trim() + : root.GetRawText(); + + if (string.IsNullOrWhiteSpace(stateJson)) + { + return Results.BadRequest(new { message = "Workspace state cannot be empty." }); + } + + using var _ = JsonDocument.Parse(stateJson); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + return Results.BadRequest(new { message = "Invalid workspace state JSON. Send UTF-8 encoded JSON." }); + } + + var workspace = await GetOrCreateWorkspace(db, token); + workspace.StateJson = stateJson; + workspace.UpdatedUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + return Results.Ok(new WorkspaceStateResponse( + workspace.Token, + workspace.StateJson, + workspace.UpdatedUtc)); +}); + app.Run(); static bool HasApiToken(HttpContext context) @@ -496,6 +566,43 @@ static string? Clean(string? value) return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } +static string? GetWorkspaceToken(HttpContext context) +{ + if (context.Request.Headers.TryGetValue("X-Workspace-Token", out var headerToken)) + { + return Clean(headerToken.ToString()); + } + + if (context.Request.Query.TryGetValue("workspaceToken", out var queryToken)) + { + return Clean(queryToken.ToString()); + } + + return null; +} + +static async Task GetOrCreateWorkspace( + OtthonDbContext db, + string token) +{ + var workspace = await db.Workspaces.FindAsync(token); + if (workspace is not null) + { + return workspace; + } + + workspace = new WorkspaceState + { + Token = token, + StateJson = "{}", + CreatedUtc = DateTimeOffset.UtcNow, + UpdatedUtc = DateTimeOffset.UtcNow + }; + db.Workspaces.Add(workspace); + await db.SaveChangesAsync(); + return workspace; +} + static void ApplyProductLookup(Product target, Product lookup, string source) { target.Name = lookup.Name; @@ -580,6 +687,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await db.Database.EnsureCreatedAsync(); await EnsureProductSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db); + await EnsureWorkspaceSchemaAsync(db); return; } catch (Exception ex) when (attempt < maxAttempts) @@ -596,6 +704,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await db.Database.EnsureCreatedAsync(); await EnsureProductSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db); + await EnsureWorkspaceSchemaAsync(db); } static async Task EnsureProductSchemaAsync(OtthonDbContext db) @@ -615,6 +724,22 @@ IF COL_LENGTH('Products', 'IngredientsText') IS NULL await db.Database.ExecuteSqlRawAsync(sql); } +static async Task EnsureWorkspaceSchemaAsync(OtthonDbContext db) +{ + var sql = """ +IF OBJECT_ID('Workspaces', 'U') IS NULL +BEGIN + CREATE TABLE Workspaces ( + Token nvarchar(160) NOT NULL CONSTRAINT PK_Workspaces PRIMARY KEY, + StateJson nvarchar(max) NOT NULL, + CreatedUtc datetimeoffset NOT NULL, + UpdatedUtc datetimeoffset NOT NULL + ); +END +"""; + await db.Database.ExecuteSqlRawAsync(sql); +} + static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db) { var sql = """ @@ -843,9 +968,12 @@ public sealed class OtthonDbContext(DbContextOptions options) public DbSet Devices => Set(); public DbSet Products => Set(); public DbSet FoodLearningEvents => Set(); + public DbSet Workspaces => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasKey(workspace => workspace.Token); + modelBuilder.Entity().Property(workspace => workspace.Token).HasMaxLength(160); modelBuilder.Entity().HasKey(product => product.Barcode); modelBuilder.Entity().Property(product => product.Barcode).HasMaxLength(64); modelBuilder.Entity().Property(product => product.Name).HasMaxLength(300); @@ -910,6 +1038,14 @@ public sealed class FoodLearningEvent public DateTimeOffset CreatedUtc { get; set; } } +public sealed class WorkspaceState +{ + public required string Token { get; set; } + public required string StateJson { get; set; } + public DateTimeOffset CreatedUtc { get; set; } + public DateTimeOffset UpdatedUtc { get; set; } +} + public sealed record LoginForm(string Password); public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform); public sealed record ProductUpsertRequest( @@ -973,6 +1109,13 @@ public sealed record ShoppingRecommendationResponse( DateTimeOffset LastSeenUtc, IReadOnlyList Reasons); +public sealed record WorkspaceStateRequest(string StateJson); + +public sealed record WorkspaceStateResponse( + string WorkspaceToken, + string StateJson, + DateTimeOffset UpdatedUtc); + public sealed record ProductResponse( bool Found, string Barcode, diff --git a/lib/app_shell.dart b/lib/app_shell.dart index 34b1903..a75cb7c 100644 --- a/lib/app_shell.dart +++ b/lib/app_shell.dart @@ -187,6 +187,7 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State { int selected = 0; bool jarvisBackendOnline = false; + bool workspaceSyncing = false; Timer? jarvisBackendTimer; @override @@ -567,6 +568,64 @@ class _HomeShellState extends State { ], ), ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: workspaceSyncing + ? null + : _downloadWorkspaceState, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide( + color: Colors.white.withValues( + alpha: .22, + ), + ), + padding: const EdgeInsets.symmetric( + vertical: 6, + ), + ), + child: const Icon( + Icons.cloud_download_outlined, + size: 16, + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: OutlinedButton( + onPressed: workspaceSyncing + ? null + : _uploadWorkspaceState, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide( + color: Colors.white.withValues( + alpha: .22, + ), + ), + padding: const EdgeInsets.symmetric( + vertical: 6, + ), + ), + child: workspaceSyncing + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon( + Icons.cloud_upload_outlined, + size: 16, + ), + ), + ), + ], + ), const SizedBox(height: 10), Center( child: Opacity( @@ -641,6 +700,119 @@ class _HomeShellState extends State { _checkJarvisBackend(); } + Future _downloadWorkspaceState() async { + final workspaceToken = widget.jarvisSettings.workspaceToken?.trim(); + if (workspaceToken == null || workspaceToken.isEmpty) { + _showWorkspaceMessage('Adj meg k\u00f6z\u00f6s munkat\u00e9r tokent.'); + return; + } + + setState(() => workspaceSyncing = true); + try { + final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl)); + final response = await http + .get( + baseUri.resolve('/api/workspace/state'), + headers: { + 'X-Otthon-Token': widget.jarvisSettings.apiToken, + 'X-Workspace-Token': workspaceToken, + }, + ) + .timeout(const Duration(seconds: 12)); + if (response.statusCode != 200) { + throw Exception('workspace GET ${response.statusCode}'); + } + final payload = jsonDecode(response.body); + if (payload is! Map) { + throw Exception('Invalid workspace response.'); + } + final stateJson = payload['stateJson'] as String? ?? '{}'; + final state = jsonDecode(stateJson); + if (state is! Map) { + throw Exception('Invalid workspace state.'); + } + final nextPeople = state['people'] is List + ? (state['people'] as List) + .whereType>() + .map(Person.fromJson) + .toList() + : widget.people; + final nextShopping = state['shopping'] is List + ? (state['shopping'] as List) + .whereType>() + .map(ShoppingItem.fromJson) + .toList() + : shopping; + final nextStock = state['stock'] is List + ? (state['stock'] as List) + .whereType>() + .map(StockItem.fromJson) + .toList() + : stock; + + setState(() { + shopping + ..clear() + ..addAll(nextShopping); + stock + ..clear() + ..addAll(nextStock); + }); + widget.onPeopleChanged(nextPeople); + _showWorkspaceMessage('K\u00f6z\u00f6s munkat\u00e9r let\u00f6ltve.'); + } catch (error) { + _showWorkspaceMessage('Workspace hiba: $error'); + } finally { + if (mounted) setState(() => workspaceSyncing = false); + } + } + + Future _uploadWorkspaceState() async { + final workspaceToken = widget.jarvisSettings.workspaceToken?.trim(); + if (workspaceToken == null || workspaceToken.isEmpty) { + _showWorkspaceMessage('Adj meg k\u00f6z\u00f6s munkat\u00e9r tokent.'); + return; + } + + setState(() => workspaceSyncing = true); + try { + final baseUri = Uri.parse(normalizeJarvisUrl(widget.jarvisSettings.apiUrl)); + final stateJson = jsonEncode({ + 'version': 1, + 'updatedAt': DateTime.now().toIso8601String(), + 'people': widget.people.map((person) => person.toJson()).toList(), + 'shopping': shopping.map((item) => item.toJson()).toList(), + 'stock': stock.map((item) => item.toJson()).toList(), + }); + final response = await http + .put( + baseUri.resolve('/api/workspace/state'), + headers: { + 'Content-Type': 'application/json', + 'X-Otthon-Token': widget.jarvisSettings.apiToken, + 'X-Workspace-Token': workspaceToken, + }, + body: jsonEncode({'stateJson': stateJson}), + ) + .timeout(const Duration(seconds: 12)); + if (response.statusCode != 200) { + throw Exception('workspace PUT ${response.statusCode}'); + } + _showWorkspaceMessage('K\u00f6z\u00f6s munkat\u00e9r felt\u00f6ltve.'); + } catch (error) { + _showWorkspaceMessage('Workspace hiba: $error'); + } finally { + if (mounted) setState(() => workspaceSyncing = false); + } + } + + void _showWorkspaceMessage(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + Future _registerJarvisDevice( JarvisSettings settings, String deviceName, diff --git a/lib/data.dart b/lib/data.dart index 9849ff3..696455d 100644 --- a/lib/data.dart +++ b/lib/data.dart @@ -219,6 +219,7 @@ class JarvisSettings { JarvisSettings({ required this.apiUrl, required this.apiToken, + this.workspaceToken, this.deviceName, this.deviceId, this.deviceToken, @@ -227,6 +228,7 @@ class JarvisSettings { final String apiUrl; final String apiToken; + final String? workspaceToken; final String? deviceName; final int? deviceId; final String? deviceToken; @@ -240,6 +242,7 @@ class JarvisSettings { ? defaultJarvisApiUrl : json['apiUrl'] as String? ?? JarvisSettings.defaults().apiUrl, apiToken: json['apiToken'] as String? ?? '', + workspaceToken: json['workspaceToken'] as String?, deviceName: json['deviceName'] as String?, deviceId: json['deviceId'] as int?, deviceToken: json['deviceToken'] as String?, @@ -250,6 +253,7 @@ class JarvisSettings { JarvisSettings copyWith({ String? apiUrl, String? apiToken, + String? workspaceToken, String? deviceName, int? deviceId, String? deviceToken, @@ -259,6 +263,7 @@ class JarvisSettings { return JarvisSettings( apiUrl: apiUrl ?? this.apiUrl, apiToken: apiToken ?? this.apiToken, + workspaceToken: workspaceToken ?? this.workspaceToken, deviceName: clearDevice ? null : deviceName ?? this.deviceName, deviceId: clearDevice ? null : deviceId ?? this.deviceId, deviceToken: clearDevice ? null : deviceToken ?? this.deviceToken, @@ -271,6 +276,7 @@ class JarvisSettings { Map toJson() => { 'apiUrl': apiUrl, 'apiToken': apiToken, + 'workspaceToken': workspaceToken, 'deviceName': deviceName, 'deviceId': deviceId, 'deviceToken': deviceToken, @@ -335,49 +341,40 @@ class GoogleCalendarConnection { }; } -class GoogleCalendarOption { - const GoogleCalendarOption({ +class GoogleCalendarInfo { + const GoogleCalendarInfo({ required this.id, required this.name, - required this.description, required this.color, + this.description = '', + this.primary = false, }); final String id; final String name; final String description; final Color color; -} + final bool primary; -const availableGoogleCalendars = [ - GoogleCalendarOption( - id: 'primary', - name: 'Saj\u00e1t napt\u00e1r', - description: 'A f\u0151 Google Calendar esem\u00e9nyeid.', - color: Color(0xFF4285F4), - ), - GoogleCalendarOption( - id: 'family', - name: 'Csal\u00e1di napt\u00e1r', - description: - 'K\u00f6z\u00f6s csal\u00e1di programok \u00e9s eml\u00e9keztet\u0151k.', - color: Color(0xFF34A853), - ), - GoogleCalendarOption( - id: 'school', - name: 'Ovi / iskola', - description: - 'Iskolai, \u00f3vodai \u00e9s k\u00fcl\u00f6n\u00f3r\u00e1s esem\u00e9nyek.', - color: Color(0xFFFBBC05), - ), - GoogleCalendarOption( - id: 'work', - name: 'Munka', - description: - 'Munkahelyi id\u0151pontok, ha szeretn\u00e9d l\u00e1tni \u0151ket.', - color: Color(0xFF9B78B4), - ), -]; + factory GoogleCalendarInfo.fromJson(Map json) { + final backgroundColor = json['backgroundColor'] as String?; + return GoogleCalendarInfo( + id: json['id'] as String? ?? 'primary', + name: json['summaryOverride'] as String? ?? + json['summary'] as String? ?? + 'N\u00e9vtelen Google napt\u00e1r', + description: [ + json['description'] as String?, + json['accessRole'] as String?, + ] + .whereType() + .where((part) => part.trim().isNotEmpty) + .join(' \u00b7 '), + color: parseGoogleColor(backgroundColor), + primary: json['primary'] == true, + ); + } +} List defaultGoogleConnections() => []; @@ -472,7 +469,7 @@ class CalendarEvent { final List todos; final EventSource source; - bool get isSynced => source == EventSource.deviceCalendar; + bool get isSynced => source == EventSource.googleCalendar; bool get isUncategorized => personId == null; bool get isForEveryone => personId == everyonePersonId; @@ -531,31 +528,37 @@ class CalendarEvent { ); } - factory CalendarEvent.fromDeviceEvent( - device_calendar.Event event, - device_calendar.Calendar calendar, { + factory CalendarEvent.fromGoogleEvent( + Map event, + GoogleCalendarInfo calendar, { String? personId, }) { - final startDate = event.start?.toLocal() ?? DateTime.now(); - final endDate = event.end?.toLocal(); + final startJson = event['start'] as Map? ?? const {}; + final endJson = event['end'] as Map? ?? const {}; + final startDate = parseGoogleEventDate(startJson) ?? DateTime.now(); + final endDate = parseGoogleEventDate(endJson); + final allDay = startJson['date'] != null; + final title = event['summary'] as String?; + final location = event['location'] as String?; + final description = event['description'] as String?; return CalendarEvent( - event.title?.trim().isNotEmpty == true - ? event.title!.trim() - : 'N\u00e9vtelen napt\u00e1resem\u00e9ny', - event.allDay == true ? 'Eg\u00e9sz nap' : formatTime(startDate), - event.allDay == true || endDate == null ? '' : formatTime(endDate), - Color(calendar.color ?? const Color(0xFF4285F4).toARGB32()), + title?.trim().isNotEmpty == true + ? title!.trim() + : 'N\u00e9vtelen Google esem\u00e9ny', + allDay ? 'Eg\u00e9sz nap' : formatTime(startDate), + allDay || endDate == null ? '' : formatTime(endDate), + calendar.color, date: DateTime(startDate.year, startDate.month, startDate.day), place: [ - calendar.name ?? 'Napt\u00e1r', - if ((event.location ?? '').trim().isNotEmpty) event.location!.trim(), + calendar.name, + if ((location ?? '').trim().isNotEmpty) location!.trim(), ].join(' \u00b7 '), - externalId: event.eventId, + externalId: event['id'] as String?, calendarId: calendar.id, calendarName: calendar.name, personId: personId, - note: event.description ?? '', - source: EventSource.deviceCalendar, + note: description ?? '', + source: EventSource.googleCalendar, ); } @@ -577,7 +580,7 @@ class CalendarEvent { }; } -enum EventSource { local, deviceCalendar } +enum EventSource { local, googleCalendar } class ShoppingItem { ShoppingItem(this.name, this.amount, this.category, {this.done = false}); @@ -586,6 +589,20 @@ class ShoppingItem { final String amount; final String category; bool done; + + factory ShoppingItem.fromJson(Map json) => ShoppingItem( + json['name'] as String? ?? '', + json['amount'] as String? ?? '1 db', + json['category'] as String? ?? 'Egy\u00e9b', + done: json['done'] as bool? ?? false, + ); + + Map toJson() => { + 'name': name, + 'amount': amount, + 'category': category, + 'done': done, + }; } class StockItem { @@ -596,6 +613,22 @@ class StockItem { String unit; String location; String expiry; + + factory StockItem.fromJson(Map json) => StockItem( + json['name'] as String? ?? '', + json['amount'] as int? ?? 1, + json['unit'] as String? ?? 'db', + json['location'] as String? ?? 'Kamra', + json['expiry'] as String? ?? 'nincs megadva', + ); + + Map toJson() => { + 'name': name, + 'amount': amount, + 'unit': unit, + 'location': location, + 'expiry': expiry, + }; } class JarvisProduct { diff --git a/lib/dialogs.dart b/lib/dialogs.dart index 90fc9ce..6c44d7c 100644 --- a/lib/dialogs.dart +++ b/lib/dialogs.dart @@ -1005,6 +1005,7 @@ class JarvisSettingsDialog extends StatefulWidget { class _JarvisSettingsDialogState extends State { late final TextEditingController apiUrl; late final TextEditingController apiToken; + late final TextEditingController workspaceToken; late final TextEditingController deviceName; late JarvisSettings currentSettings; bool registering = false; @@ -1017,6 +1018,9 @@ class _JarvisSettingsDialogState extends State { currentSettings = widget.settings; apiUrl = TextEditingController(text: currentSettings.apiUrl); apiToken = TextEditingController(text: currentSettings.apiToken); + workspaceToken = TextEditingController( + text: currentSettings.workspaceToken ?? '', + ); deviceName = TextEditingController( text: currentSettings.deviceName ?? 'Family tablet', ); @@ -1026,6 +1030,7 @@ class _JarvisSettingsDialogState extends State { void dispose() { apiUrl.dispose(); apiToken.dispose(); + workspaceToken.dispose(); deviceName.dispose(); super.dispose(); } @@ -1033,6 +1038,9 @@ class _JarvisSettingsDialogState extends State { JarvisSettings get draft => currentSettings.copyWith( apiUrl: normalizeJarvisUrl(apiUrl.text), apiToken: apiToken.text.trim(), + workspaceToken: workspaceToken.text.trim().isEmpty + ? null + : workspaceToken.text.trim(), ); @override @@ -1074,6 +1082,18 @@ class _JarvisSettingsDialogState extends State { ), ), const SizedBox(height: 12), + TextField( + controller: workspaceToken, + obscureText: true, + decoration: const InputDecoration( + labelText: 'K\u00f6z\u00f6s munkat\u00e9r token', + hintText: + 'Ugyanez ker\u00fclj\u00f6n minden csal\u00e1di eszk\u00f6zre', + helperText: + 'Ezzel l\u00e1tja t\u00f6bb app ugyanazt a csal\u00e1dot, k\u00e9szletet \u00e9s bev\u00e1s\u00e1rl\u00f3list\u00e1t.', + ), + ), + const SizedBox(height: 12), TextField( controller: deviceName, decoration: const InputDecoration( diff --git a/lib/google_calendar_page.dart b/lib/google_calendar_page.dart new file mode 100644 index 0000000..90a1edf --- /dev/null +++ b/lib/google_calendar_page.dart @@ -0,0 +1,621 @@ +part of 'main.dart'; + +class AccountsPage extends StatefulWidget { + const AccountsPage({ + super.key, + required this.connections, + required this.cachedEvents, + required this.people, + required this.onChanged, + required this.onSyncedEventsChanged, + }); + + final List connections; + final List cachedEvents; + final List people; + final ValueChanged> onChanged; + final ValueChanged> onSyncedEventsChanged; + + @override + State createState() => _AccountsPageState(); +} + +class _AccountsPageState extends State { + static const calendarScopes = [ + 'https://www.googleapis.com/auth/calendar.readonly', + ]; + + final googleSignIn = GoogleSignIn.instance; + List googleCalendars = []; + GoogleSignInAccount? signedInAccount; + bool initialized = false; + bool loadingCalendars = false; + bool syncing = false; + String? statusMessage; + + @override + void initState() { + super.initState(); + _initializeGoogle(); + } + + GoogleCalendarConnection? get connection => + widget.connections.isEmpty ? null : widget.connections.first; + + Future _initializeGoogle() async { + setState(() { + loadingCalendars = true; + statusMessage = 'Google kapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...'; + }); + + try { + await googleSignIn.initialize(); + 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 = 'Google inicializ\u00e1l\u00e1s nem siker\u00fclt: $error'; + }); + } + } + + Future> _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 _signInGoogle() async { + setState(() { + loadingCalendars = true; + statusMessage = 'Google bejelentkez\u00e9s...'; + }); + + try { + if (!initialized) { + await googleSignIn.initialize(); + initialized = true; + } + final account = await googleSignIn.authenticate(scopeHint: calendarScopes); + signedInAccount = account; + await _loadGoogleCalendars(promptIfNecessary: true); + } catch (error) { + if (!mounted) return; + setState(() { + loadingCalendars = false; + statusMessage = 'Google bejelentkez\u00e9s nem siker\u00fclt: $error'; + }); + } + } + + Future _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) { + throw Exception('Hib\u00e1s Google Calendar v\u00e1lasz.'); + } + final items = payload['items']; + final calendars = items is List + ? items + .whereType>() + .map(GoogleCalendarInfo.fromJson) + .toList() + : []; + 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 _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 = []; + final syncDetails = []; + 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) continue; + final rawEvents = payload['items']; + final events = rawEvents is List + ? rawEvents.whereType>().toList() + : >[]; + 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 people; + final String? selectedPersonId; + final ValueChanged onChanged; + final ValueChanged 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( + initialValue: selectedPersonId, + decoration: const InputDecoration( + labelText: 'Kihez tartozzanak az esem\u00e9nyek?', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Kategoriz\u00e1latlan'), + ), + const DropdownMenuItem( + value: everyonePersonId, + child: Text('Mindenki'), + ), + ...people.map( + (person) => DropdownMenuItem( + value: person.id, + child: Text('${person.avatar} ${person.name}'), + ), + ), + ], + onChanged: onPersonChanged, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index ea2014c..1e8c85e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,11 +3,11 @@ import 'dart:io'; import 'dart:async'; import 'dart:math' as math; -import 'package:device_calendar/device_calendar.dart' as device_calendar; 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'; @@ -17,6 +17,7 @@ part 'data.dart'; part 'app_shell.dart'; part 'calendar_widgets.dart'; part 'pages.dart'; +part 'google_calendar_page.dart'; part 'dialogs.dart'; part 'utils.dart'; diff --git a/lib/pages.dart b/lib/pages.dart index f28d005..c5dee3b 100644 --- a/lib/pages.dart +++ b/lib/pages.dart @@ -420,614 +420,6 @@ class _StockPageState extends State { } } -class AccountsPage extends StatefulWidget { - const AccountsPage({ - super.key, - required this.connections, - required this.cachedEvents, - required this.people, - required this.onChanged, - required this.onSyncedEventsChanged, - }); - - final List connections; - final List cachedEvents; - final List people; - final ValueChanged> onChanged; - final ValueChanged> onSyncedEventsChanged; - - @override - State createState() => _AccountsPageState(); -} - -class _AccountsPageState extends State { - final calendarPlugin = device_calendar.DeviceCalendarPlugin(); - List deviceCalendars = []; - bool loadingCalendars = false; - bool syncing = false; - String? statusMessage; - - @override - void initState() { - super.initState(); - _loadDeviceCalendars(); - } - - GoogleCalendarConnection? get connection => - widget.connections.isEmpty ? null : widget.connections.first; - - Future _loadDeviceCalendars() async { - setState(() { - loadingCalendars = true; - statusMessage = null; - }); - - try { - final permissions = await calendarPlugin.requestPermissions(); - if (permissions.isSuccess != true || permissions.data != true) { - setState(() { - loadingCalendars = false; - statusMessage = - 'Napt\u00e1r olvas\u00e1si enged\u00e9ly n\u00e9lk\u00fcl nem lehet szinkroniz\u00e1lni.'; - }); - return; - } - - final result = await calendarPlugin.retrieveCalendars(); - final calendars = - (result.data ?? const []) - .where((calendar) => calendar.id != null) - .toList() - ..sort((a, b) => (a.name ?? '').compareTo(b.name ?? '')); - - setState(() { - deviceCalendars = calendars; - loadingCalendars = false; - if (calendars.isEmpty) { - statusMessage = - 'Nem tal\u00e1ltam lok\u00e1lis napt\u00e1rat az eszk\u00f6z\u00f6n. Androidon ellen\u0151rizd: Be\u00e1ll\u00edt\u00e1sok > Fi\u00f3kok > Google > Fi\u00f3kszinkroniz\u00e1l\u00e1s > Napt\u00e1r legyen bekapcsolva.'; - } else { - final accountTypes = calendars - .map((calendar) => calendar.accountType) - .whereType() - .where((type) => type.trim().isNotEmpty) - .toSet() - .toList() - ..sort(); - statusMessage = - '${calendars.length} napt\u00e1rat l\u00e1tok az eszk\u00f6z\u00f6n.' - '${accountTypes.isEmpty ? '' : ' Account t\u00edpusok: ${accountTypes.join(', ')}.'}'; - } - }); - } catch (error) { - setState(() { - loadingCalendars = false; - statusMessage = - 'Nem siker\u00fclt lek\u00e9rni az eszk\u00f6z napt\u00e1rait: $error'; - }); - } - } - - Future _syncSelectedCalendars() async { - final current = connection; - if (current == null || current.enabledCalendarIds.isEmpty) { - setState( - () => - statusMessage = 'V\u00e1lassz ki legal\u00e1bb egy napt\u00e1rat.', - ); - return; - } - - setState(() { - syncing = true; - statusMessage = - 'Esem\u00e9nyek beolvas\u00e1sa a lok\u00e1lis napt\u00e1rt\u00e1rb\u00f3l\u2026'; - }); - - try { - final selectedCalendars = deviceCalendars - .where((calendar) => current.enabledCalendarIds.contains(calendar.id)) - .toList(); - final synced = []; - final syncDetails = []; - 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 result = await calendarPlugin.retrieveEvents( - calendar.id, - device_calendar.RetrieveEventsParams(startDate: start, endDate: end), - ); - final events = result.data ?? const []; - syncDetails.add('${calendar.name ?? 'Napt\u00e1r'}: ${events.length}'); - for (final event in events) { - synced.add( - CalendarEvent.fromDeviceEvent( - 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} esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.' - '${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}'; - }); - } catch (error) { - setState(() { - syncing = false; - statusMessage = 'A szinkroniz\u00e1l\u00e1s nem siker\u00fclt: $error'; - }); - } - } - - @override - Widget build(BuildContext context) { - final current = connection; - final hasConnection = current != null; - final cachedUncategorized = widget.cachedEvents - .where((event) => event.isUncategorized) - .length; - - return PageFrame( - eyebrow: 'Lok\u00e1lis napt\u00e1r szinkron', - title: 'Eszk\u00f6z napt\u00e1rak', - action: FilledButton.icon( - onPressed: () => _connectGoogle(context), - icon: const Icon(Icons.add_link), - label: Text( - hasConnection - ? 'Fi\u00f3k c\u00edmk\u00e9je' - : 'Fi\u00f3k c\u00edmke', - ), - ), - 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( - 'Eszk\u00f6z\u00f6n el\u00e9rhet\u0151 napt\u00e1rak', - style: TextStyle( - color: ink, - fontWeight: FontWeight.w800, - fontSize: 18, - ), - ), - const SizedBox(height: 10), - if (loadingCalendars) - const LinearProgressIndicator() - else - ...deviceCalendars.map( - (calendar) => DeviceCalendarToggleCard( - 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) { - if (calendar.id == null) return; - 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\u2026' - : 'Szinkroniz\u00e1l\u00e1s most', - ), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _loadDeviceCalendars, - icon: const Icon(Icons.refresh), - label: const Text('Napt\u00e1rlista friss\u00edt\u00e9se'), - ), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: () { - widget.onChanged([]); - widget.onSyncedEventsChanged([]); - }, - icon: const Icon(Icons.link_off), - label: const Text('Napt\u00e1rkapcsolat 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: az eszk\u00f6z lok\u00e1lis napt\u00e1rt\u00e1r\u00e1b\u00f3l olvas, a beolvasott esem\u00e9nyeket JSON cache-be menti, \u00e9s nem \u00edr vissza k\u00fcls\u0151 napt\u00e1rba. ' - 'Google, Apple/iCloud, Outlook vagy m\u00e1s napt\u00e1r akkor jelenik meg itt, ha az oper\u00e1ci\u00f3s rendszer napt\u00e1rt\u00e1r\u00e1ban el\u00e9rhet\u0151.', - style: TextStyle(color: ink, height: 1.45), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Future _connectGoogle(BuildContext context) async { - final email = await showDialog( - context: context, - builder: (context) => - GoogleAccountDialog(initialEmail: connection?.accountEmail ?? ''), - ); - if (email == null || email.isEmpty) return; - - widget.onChanged([ - GoogleCalendarConnection( - id: connection?.id ?? uniqueId(), - accountEmail: email, - enabledCalendarIds: connection?.enabledCalendarIds ?? const [], - lastSync: DateTime.now(), - ), - ]); - } -} - -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 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' - : 'Adj meg egy c\u00edmk\u00e9t, majd v\u00e1laszd ki, mely eszk\u00f6znapt\u00e1rakb\u00f3l hozzon esem\u00e9nyeket.', - style: const TextStyle(color: Colors.white70, height: 1.35), - ), - ], - ), - ), - ], - ), - ); - } -} - -class GoogleAccountDialog extends StatefulWidget { - const GoogleAccountDialog({super.key, required this.initialEmail}); - - final String initialEmail; - - @override - State createState() => _GoogleAccountDialogState(); -} - -class _GoogleAccountDialogState extends State { - late final TextEditingController controller; - - @override - void initState() { - super.initState(); - controller = TextEditingController( - text: widget.initialEmail.isEmpty - ? 'csalad@gmail.com' - : widget.initialEmail, - ); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Napt\u00e1rkapcsolat c\u00edmk\u00e9je'), - content: TextField( - controller: controller, - autofocus: true, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Fi\u00f3k / kapcsolat neve', - helperText: 'Pl. csalad@gmail.com, iCloud, munkahelyi napt\u00e1r', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('M\u00e9gse'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('Ment\u00e9s'), - ), - ], - ); - } -} - -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( - 'Adj hozz\u00e1 egy Google fi\u00f3kot, majd v\u00e1laszd ki, mely napt\u00e1rak jelenjenek meg a helyi csal\u00e1di n\u00e9zetben.', - style: TextStyle(color: ink, height: 1.4), - ), - ), - ], - ), - ), - ); - } -} - -class GoogleCalendarToggleCard extends StatelessWidget { - const GoogleCalendarToggleCard({ - super.key, - required this.calendar, - required this.enabled, - required this.onChanged, - }); - - final GoogleCalendarOption calendar; - final bool enabled; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return Card( - child: SwitchListTile( - value: enabled, - activeThumbColor: calendar.color, - onChanged: onChanged, - secondary: CircleAvatar( - backgroundColor: calendar.color.withValues(alpha: .14), - child: Icon(Icons.calendar_today, color: calendar.color, size: 19), - ), - title: Text( - calendar.name, - style: const TextStyle(color: ink, fontWeight: FontWeight.w800), - ), - subtitle: Text(calendar.description), - ), - ); - } -} - -class DeviceCalendarToggleCard extends StatelessWidget { - const DeviceCalendarToggleCard({ - super.key, - required this.calendar, - required this.enabled, - required this.people, - required this.selectedPersonId, - required this.onChanged, - required this.onPersonChanged, - }); - - final device_calendar.Calendar calendar; - final bool enabled; - final List people; - final String? selectedPersonId; - final ValueChanged onChanged; - final ValueChanged onPersonChanged; - - @override - Widget build(BuildContext context) { - final calendarColor = Color( - calendar.color ?? const Color(0xFF4285F4).toARGB32(), - ); - return Card( - child: Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Column( - children: [ - SwitchListTile( - value: enabled, - activeThumbColor: calendarColor, - onChanged: onChanged, - secondary: CircleAvatar( - backgroundColor: calendarColor.withValues(alpha: .14), - child: Icon( - Icons.calendar_today, - color: calendarColor, - size: 19, - ), - ), - title: Text( - calendar.name ?? 'N\u00e9vtelen napt\u00e1r', - style: const TextStyle(color: ink, fontWeight: FontWeight.w800), - ), - subtitle: Text( - [ - calendar.accountName, - calendar.accountType, - if (calendar.isReadOnly == true) 'csak olvashat\u00f3', - ] - .whereType() - .where((part) => part.isNotEmpty) - .join(' \u00b7 '), - ), - ), - if (enabled) - Padding( - padding: const EdgeInsets.fromLTRB(72, 0, 16, 0), - child: DropdownButtonFormField( - initialValue: selectedPersonId, - decoration: const InputDecoration( - labelText: 'Kihez tartozzanak az esem\u00e9nyek?', - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Kategoriz\u00e1latlan'), - ), - const DropdownMenuItem( - value: everyonePersonId, - child: Text('Mindenki'), - ), - ...people.map( - (person) => DropdownMenuItem( - value: person.id, - child: Text('${person.avatar} ${person.name}'), - ), - ), - ], - onChanged: onPersonChanged, - ), - ), - ], - ), - ), - ); - } -} - class InfoBox extends StatelessWidget { const InfoBox({super.key}); diff --git a/lib/utils.dart b/lib/utils.dart index 58ddf69..3a4f7d0 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -58,6 +58,32 @@ DateTime? parseDate(String value) { return DateTime(year, month, day); } +DateTime? parseGoogleEventDate(Map value) { + final dateTime = value['dateTime'] as String?; + if (dateTime != null && dateTime.trim().isNotEmpty) { + return DateTime.tryParse(dateTime)?.toLocal(); + } + + final date = value['date'] as String?; + if (date == null || date.trim().isEmpty) return null; + final parsed = DateTime.tryParse(date); + if (parsed == null) return null; + return DateTime(parsed.year, parsed.month, parsed.day); +} + +Color parseGoogleColor(String? value) { + final raw = value?.trim(); + if (raw == null || !raw.startsWith('#')) { + return const Color(0xFF4285F4); + } + + final hex = raw.substring(1); + if (hex.length != 6) return const Color(0xFF4285F4); + final colorValue = int.tryParse(hex, radix: 16); + if (colorValue == null) return const Color(0xFF4285F4); + return Color(0xFF000000 | colorValue); +} + String uniqueId() => DateTime.now().microsecondsSinceEpoch.toString(); String normalizeJarvisUrl(String value) { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8037017..4c877b2 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,14 @@ import Foundation import geocoding_darwin import geolocator_apple +import google_sign_in_ios import mobile_scanner import package_info_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeocodingDarwinPlugin.register(with: registry.registrar(forPlugin: "GeocodingDarwinPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 55eefda..a2ce2fc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.14" - device_calendar: - dependency: "direct main" - description: - name: device_calendar - sha256: "683fb93ec302b6a65c0ce57df40ff9dcc2404f59c67a2f8b93e59318c8a0a225" - url: "https://pub.dev" - source: hosted - version: "4.3.3" fake_async: dependency: transitive description: @@ -312,6 +304,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" + url: "https://pub.dev" + source: hosted + version: "7.2.16" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" graphs: dependency: transitive description: @@ -589,14 +629,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a8d15eb..19ebba7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,12 +35,12 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 path_provider: ^2.1.6 - device_calendar: ^4.3.3 timezone: ^0.9.4 http: ^1.6.0 geolocator: ^14.0.3 geocoding: ^5.0.0 mobile_scanner: ^7.4.0 + google_sign_in: ^7.2.0 dev_dependencies: flutter_test: