Add Jarvis Google Calendar OAuth broker
This commit is contained in:
+375
-2
@@ -1,9 +1,12 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -151,6 +154,214 @@ app.MapGet("/api/app-config", async (HttpContext context, OtthonDbContext db) =>
|
|||||||
!string.IsNullOrWhiteSpace(googleClientId)));
|
!string.IsNullOrWhiteSpace(googleClientId)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/google/oauth/start", async (
|
||||||
|
HttpContext context,
|
||||||
|
OtthonDbContext db) =>
|
||||||
|
{
|
||||||
|
if (!HasApiToken(context))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var workspaceToken = GetWorkspaceToken(context);
|
||||||
|
if (string.IsNullOrWhiteSpace(workspaceToken))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientId = await GetGoogleClientId(db);
|
||||||
|
var clientSecret = await GetGoogleClientSecret(db);
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Google OAuth client is not configured." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateValue = RandomToken(32);
|
||||||
|
db.GoogleOAuthStates.Add(new GoogleOAuthState
|
||||||
|
{
|
||||||
|
State = stateValue,
|
||||||
|
WorkspaceToken = workspaceToken,
|
||||||
|
CreatedUtc = DateTimeOffset.UtcNow,
|
||||||
|
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(15)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var redirectUri = GoogleOAuthRedirectUri();
|
||||||
|
var authUri = QueryHelpers.AddQueryString("https://accounts.google.com/o/oauth2/v2/auth", new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["client_id"] = clientId,
|
||||||
|
["redirect_uri"] = redirectUri,
|
||||||
|
["response_type"] = "code",
|
||||||
|
["scope"] = "openid email https://www.googleapis.com/auth/calendar.readonly",
|
||||||
|
["access_type"] = "offline",
|
||||||
|
["prompt"] = "consent",
|
||||||
|
["state"] = stateValue
|
||||||
|
});
|
||||||
|
|
||||||
|
return Results.Ok(new GoogleOAuthStartResponse(authUri));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/google/oauth/callback", async (
|
||||||
|
string? code,
|
||||||
|
string? state,
|
||||||
|
string? error,
|
||||||
|
OtthonDbContext db,
|
||||||
|
IHttpClientFactory httpClientFactory) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(error))
|
||||||
|
{
|
||||||
|
return Results.Content(GoogleOAuthResultPage(false, $"Google rejected the request: {Html(error)}"), "text/html; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
|
||||||
|
{
|
||||||
|
return Results.Content(GoogleOAuthResultPage(false, "Missing OAuth code or state."), "text/html; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedState = await db.GoogleOAuthStates.FindAsync(state);
|
||||||
|
if (savedState is null || savedState.ExpiresUtc < DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return Results.Content(GoogleOAuthResultPage(false, "OAuth session expired. Start Google connection again from the tablet."), "text/html; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientId = await GetGoogleClientId(db);
|
||||||
|
var clientSecret = await GetGoogleClientSecret(db);
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
|
||||||
|
{
|
||||||
|
return Results.Content(GoogleOAuthResultPage(false, "Google OAuth client is not configured in JARVIS."), "text/html; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
var http = httpClientFactory.CreateClient();
|
||||||
|
var tokenResponse = await http.PostAsync(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["code"] = code,
|
||||||
|
["client_id"] = clientId,
|
||||||
|
["client_secret"] = clientSecret,
|
||||||
|
["redirect_uri"] = GoogleOAuthRedirectUri(),
|
||||||
|
["grant_type"] = "authorization_code"
|
||||||
|
}));
|
||||||
|
var tokenBody = await tokenResponse.Content.ReadAsStringAsync();
|
||||||
|
if (!tokenResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return Results.Content(GoogleOAuthResultPage(false, $"Token exchange failed: {Html(tokenBody)}"), "text/html; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var tokenJson = JsonDocument.Parse(tokenBody);
|
||||||
|
var accessToken = GetString(tokenJson.RootElement, "access_token");
|
||||||
|
var refreshToken = GetString(tokenJson.RootElement, "refresh_token");
|
||||||
|
var expiresIn = tokenJson.RootElement.TryGetProperty("expires_in", out var expiresElement)
|
||||||
|
? expiresElement.GetInt32()
|
||||||
|
: 3600;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(accessToken))
|
||||||
|
{
|
||||||
|
return Results.Content(GoogleOAuthResultPage(false, "Google did not return an access token."), "text/html; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
var email = await LoadGoogleEmail(http, accessToken);
|
||||||
|
var auth = await db.GoogleCalendarAuths.FindAsync(savedState.WorkspaceToken);
|
||||||
|
if (auth is null)
|
||||||
|
{
|
||||||
|
auth = new GoogleCalendarAuth
|
||||||
|
{
|
||||||
|
WorkspaceToken = savedState.WorkspaceToken,
|
||||||
|
AccessToken = accessToken,
|
||||||
|
RefreshToken = refreshToken ?? "",
|
||||||
|
Email = email,
|
||||||
|
ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60)),
|
||||||
|
UpdatedUtc = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
db.GoogleCalendarAuths.Add(auth);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
auth.AccessToken = accessToken;
|
||||||
|
if (!string.IsNullOrWhiteSpace(refreshToken))
|
||||||
|
{
|
||||||
|
auth.RefreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
auth.Email = email ?? auth.Email;
|
||||||
|
auth.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60));
|
||||||
|
auth.UpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.GoogleOAuthStates.Remove(savedState);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Content(GoogleOAuthResultPage(true, $"Google Calendar connected for {Html(auth.Email ?? "the selected account")}."), "text/html; charset=utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/google/oauth/status", async (HttpContext context, OtthonDbContext db) =>
|
||||||
|
{
|
||||||
|
if (!HasApiToken(context)) return Results.Unauthorized();
|
||||||
|
var workspaceToken = GetWorkspaceToken(context);
|
||||||
|
if (string.IsNullOrWhiteSpace(workspaceToken))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
|
||||||
|
}
|
||||||
|
var auth = await db.GoogleCalendarAuths.FindAsync(workspaceToken);
|
||||||
|
return Results.Ok(new GoogleOAuthStatusResponse(auth is not null, auth?.Email, auth?.UpdatedUtc));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/google/calendars", async (
|
||||||
|
HttpContext context,
|
||||||
|
OtthonDbContext db,
|
||||||
|
IHttpClientFactory httpClientFactory) =>
|
||||||
|
{
|
||||||
|
if (!HasApiToken(context)) return Results.Unauthorized();
|
||||||
|
var workspaceToken = GetWorkspaceToken(context);
|
||||||
|
if (string.IsNullOrWhiteSpace(workspaceToken))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
|
||||||
|
}
|
||||||
|
var token = await GetGoogleAccessToken(db, httpClientFactory, workspaceToken);
|
||||||
|
if (token is null) return Results.BadRequest(new { message = "Google Calendar is not connected." });
|
||||||
|
|
||||||
|
var http = httpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader&showHidden=true");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
var response = await http.SendAsync(request);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
return Results.Content(body, "application/json; charset=utf-8", statusCode: (int)response.StatusCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/google/events", async (
|
||||||
|
HttpContext context,
|
||||||
|
string calendarId,
|
||||||
|
string? timeMin,
|
||||||
|
string? timeMax,
|
||||||
|
OtthonDbContext db,
|
||||||
|
IHttpClientFactory httpClientFactory) =>
|
||||||
|
{
|
||||||
|
if (!HasApiToken(context)) return Results.Unauthorized();
|
||||||
|
var workspaceToken = GetWorkspaceToken(context);
|
||||||
|
if (string.IsNullOrWhiteSpace(workspaceToken))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Missing X-Workspace-Token header." });
|
||||||
|
}
|
||||||
|
var token = await GetGoogleAccessToken(db, httpClientFactory, workspaceToken);
|
||||||
|
if (token is null) return Results.BadRequest(new { message = "Google Calendar is not connected." });
|
||||||
|
|
||||||
|
var query = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["singleEvents"] = "true",
|
||||||
|
["orderBy"] = "startTime",
|
||||||
|
["timeMin"] = timeMin,
|
||||||
|
["timeMax"] = timeMax,
|
||||||
|
["maxResults"] = "2500"
|
||||||
|
};
|
||||||
|
var uri = QueryHelpers.AddQueryString(
|
||||||
|
$"https://www.googleapis.com/calendar/v3/calendars/{Uri.EscapeDataString(calendarId)}/events",
|
||||||
|
query);
|
||||||
|
var http = httpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
var response = await http.SendAsync(request);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
return Results.Content(body, "application/json; charset=utf-8", statusCode: (int)response.StatusCode);
|
||||||
|
});
|
||||||
|
|
||||||
app.MapPost("/api/devices/register", async (
|
app.MapPost("/api/devices/register", async (
|
||||||
DeviceRegistrationRequest request,
|
DeviceRegistrationRequest request,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
@@ -661,6 +872,88 @@ static async Task SetSetting(OtthonDbContext db, string key, string value)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string RandomToken(int byteCount)
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(byteCount);
|
||||||
|
return Convert.ToBase64String(bytes)
|
||||||
|
.Replace("+", "-")
|
||||||
|
.Replace("/", "_")
|
||||||
|
.TrimEnd('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
static string GoogleOAuthRedirectUri() =>
|
||||||
|
"https://api-test.a-web.hu/api/google/oauth/callback";
|
||||||
|
|
||||||
|
static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId";
|
||||||
|
static string GoogleCalendarClientSecretSettingKey() => "GoogleCalendarClientSecret";
|
||||||
|
|
||||||
|
static async Task<string?> GetGoogleClientId(OtthonDbContext db) =>
|
||||||
|
await GetSetting(db, GoogleCalendarServerClientIdSettingKey());
|
||||||
|
|
||||||
|
static async Task<string?> GetGoogleClientSecret(OtthonDbContext db) =>
|
||||||
|
await GetSetting(db, GoogleCalendarClientSecretSettingKey());
|
||||||
|
|
||||||
|
static async Task<string?> LoadGoogleEmail(HttpClient http, string accessToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/oauth2/v2/userinfo");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
var response = await http.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
using var json = JsonDocument.Parse(body);
|
||||||
|
return GetString(json.RootElement, "email");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<string?> GetGoogleAccessToken(
|
||||||
|
OtthonDbContext db,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
string workspaceToken)
|
||||||
|
{
|
||||||
|
var auth = await db.GoogleCalendarAuths.FindAsync(workspaceToken);
|
||||||
|
if (auth is null) return null;
|
||||||
|
if (auth.ExpiresUtc > DateTimeOffset.UtcNow.AddMinutes(2))
|
||||||
|
{
|
||||||
|
return auth.AccessToken;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(auth.RefreshToken)) return null;
|
||||||
|
|
||||||
|
var clientId = await GetGoogleClientId(db);
|
||||||
|
var clientSecret = await GetGoogleClientSecret(db);
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) return null;
|
||||||
|
|
||||||
|
var http = httpClientFactory.CreateClient();
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["client_id"] = clientId,
|
||||||
|
["client_secret"] = clientSecret,
|
||||||
|
["refresh_token"] = auth.RefreshToken,
|
||||||
|
["grant_type"] = "refresh_token"
|
||||||
|
}));
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
|
using var json = JsonDocument.Parse(body);
|
||||||
|
var accessToken = GetString(json.RootElement, "access_token");
|
||||||
|
if (string.IsNullOrWhiteSpace(accessToken)) return null;
|
||||||
|
var expiresIn = json.RootElement.TryGetProperty("expires_in", out var expiresElement)
|
||||||
|
? expiresElement.GetInt32()
|
||||||
|
: 3600;
|
||||||
|
auth.AccessToken = accessToken;
|
||||||
|
auth.ExpiresUtc = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn - 60));
|
||||||
|
auth.UpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return auth.AccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
static void ApplyProductLookup(Product target, Product lookup, string source)
|
static void ApplyProductLookup(Product target, Product lookup, string source)
|
||||||
{
|
{
|
||||||
target.Name = lookup.Name;
|
target.Name = lookup.Name;
|
||||||
@@ -747,6 +1040,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
|
|||||||
await EnsureFoodLearningSchemaAsync(db);
|
await EnsureFoodLearningSchemaAsync(db);
|
||||||
await EnsureWorkspaceSchemaAsync(db);
|
await EnsureWorkspaceSchemaAsync(db);
|
||||||
await EnsureAppSettingsSchemaAsync(db);
|
await EnsureAppSettingsSchemaAsync(db);
|
||||||
|
await EnsureGoogleCalendarSchemaAsync(db);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (attempt < maxAttempts)
|
catch (Exception ex) when (attempt < maxAttempts)
|
||||||
@@ -765,6 +1059,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
|
|||||||
await EnsureFoodLearningSchemaAsync(db);
|
await EnsureFoodLearningSchemaAsync(db);
|
||||||
await EnsureWorkspaceSchemaAsync(db);
|
await EnsureWorkspaceSchemaAsync(db);
|
||||||
await EnsureAppSettingsSchemaAsync(db);
|
await EnsureAppSettingsSchemaAsync(db);
|
||||||
|
await EnsureGoogleCalendarSchemaAsync(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task EnsureProductSchemaAsync(OtthonDbContext db)
|
static async Task EnsureProductSchemaAsync(OtthonDbContext db)
|
||||||
@@ -815,6 +1110,33 @@ END
|
|||||||
await db.Database.ExecuteSqlRawAsync(sql);
|
await db.Database.ExecuteSqlRawAsync(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async Task EnsureGoogleCalendarSchemaAsync(OtthonDbContext db)
|
||||||
|
{
|
||||||
|
var sql = """
|
||||||
|
IF OBJECT_ID('GoogleCalendarAuths', 'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE GoogleCalendarAuths (
|
||||||
|
WorkspaceToken nvarchar(160) NOT NULL CONSTRAINT PK_GoogleCalendarAuths PRIMARY KEY,
|
||||||
|
Email nvarchar(320) NULL,
|
||||||
|
AccessToken nvarchar(max) NOT NULL,
|
||||||
|
RefreshToken nvarchar(max) NOT NULL,
|
||||||
|
ExpiresUtc datetimeoffset NOT NULL,
|
||||||
|
UpdatedUtc datetimeoffset NOT NULL
|
||||||
|
);
|
||||||
|
END
|
||||||
|
IF OBJECT_ID('GoogleOAuthStates', 'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE GoogleOAuthStates (
|
||||||
|
[State] nvarchar(160) NOT NULL CONSTRAINT PK_GoogleOAuthStates PRIMARY KEY,
|
||||||
|
WorkspaceToken nvarchar(160) NOT NULL,
|
||||||
|
CreatedUtc datetimeoffset NOT NULL,
|
||||||
|
ExpiresUtc datetimeoffset NOT NULL
|
||||||
|
);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
await db.Database.ExecuteSqlRawAsync(sql);
|
||||||
|
}
|
||||||
|
|
||||||
static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db)
|
static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db)
|
||||||
{
|
{
|
||||||
var sql = """
|
var sql = """
|
||||||
@@ -1047,9 +1369,27 @@ static string SettingsPage(string googleClientId) => $$"""
|
|||||||
</html>
|
</html>
|
||||||
""";
|
""";
|
||||||
|
|
||||||
static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? "");
|
static string GoogleOAuthResultPage(bool success, string message) => $$"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="hu">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Google Calendar connection</title>
|
||||||
|
<style>{{AdminCss()}}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card narrow">
|
||||||
|
<p class="eyebrow">JARVIS API</p>
|
||||||
|
<h1>{{(success ? "Google Calendar connected" : "Google Calendar error")}}</h1>
|
||||||
|
<p class="{{(success ? "hint" : "error")}}">{{message}}</p>
|
||||||
|
<p>You can close this browser window and return to Közös Tér.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
|
||||||
static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId";
|
static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? "");
|
||||||
|
|
||||||
static string AdminCss() => """
|
static string AdminCss() => """
|
||||||
:root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; }
|
:root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; }
|
||||||
@@ -1091,11 +1431,19 @@ public sealed class OtthonDbContext(DbContextOptions<OtthonDbContext> options)
|
|||||||
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
|
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
|
||||||
public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>();
|
public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>();
|
||||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||||
|
public DbSet<GoogleCalendarAuth> GoogleCalendarAuths => Set<GoogleCalendarAuth>();
|
||||||
|
public DbSet<GoogleOAuthState> GoogleOAuthStates => Set<GoogleOAuthState>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<AppSetting>().HasKey(setting => setting.Key);
|
modelBuilder.Entity<AppSetting>().HasKey(setting => setting.Key);
|
||||||
modelBuilder.Entity<AppSetting>().Property(setting => setting.Key).HasMaxLength(160);
|
modelBuilder.Entity<AppSetting>().Property(setting => setting.Key).HasMaxLength(160);
|
||||||
|
modelBuilder.Entity<GoogleCalendarAuth>().HasKey(auth => auth.WorkspaceToken);
|
||||||
|
modelBuilder.Entity<GoogleCalendarAuth>().Property(auth => auth.WorkspaceToken).HasMaxLength(160);
|
||||||
|
modelBuilder.Entity<GoogleCalendarAuth>().Property(auth => auth.Email).HasMaxLength(320);
|
||||||
|
modelBuilder.Entity<GoogleOAuthState>().HasKey(state => state.State);
|
||||||
|
modelBuilder.Entity<GoogleOAuthState>().Property(state => state.State).HasMaxLength(160);
|
||||||
|
modelBuilder.Entity<GoogleOAuthState>().Property(state => state.WorkspaceToken).HasMaxLength(160);
|
||||||
modelBuilder.Entity<WorkspaceState>().HasKey(workspace => workspace.Token);
|
modelBuilder.Entity<WorkspaceState>().HasKey(workspace => workspace.Token);
|
||||||
modelBuilder.Entity<WorkspaceState>().Property(workspace => workspace.Token).HasMaxLength(160);
|
modelBuilder.Entity<WorkspaceState>().Property(workspace => workspace.Token).HasMaxLength(160);
|
||||||
modelBuilder.Entity<Product>().HasKey(product => product.Barcode);
|
modelBuilder.Entity<Product>().HasKey(product => product.Barcode);
|
||||||
@@ -1177,6 +1525,24 @@ public sealed class AppSetting
|
|||||||
public DateTimeOffset UpdatedUtc { get; set; }
|
public DateTimeOffset UpdatedUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class GoogleCalendarAuth
|
||||||
|
{
|
||||||
|
public required string WorkspaceToken { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public required string AccessToken { get; set; }
|
||||||
|
public required string RefreshToken { get; set; }
|
||||||
|
public DateTimeOffset ExpiresUtc { get; set; }
|
||||||
|
public DateTimeOffset UpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GoogleOAuthState
|
||||||
|
{
|
||||||
|
public required string State { get; set; }
|
||||||
|
public required string WorkspaceToken { get; set; }
|
||||||
|
public DateTimeOffset CreatedUtc { get; set; }
|
||||||
|
public DateTimeOffset ExpiresUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record LoginForm(string Password);
|
public sealed record LoginForm(string Password);
|
||||||
public sealed record GoogleCalendarSettingsForm(string? ServerClientId);
|
public sealed record GoogleCalendarSettingsForm(string? ServerClientId);
|
||||||
public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform);
|
public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform);
|
||||||
@@ -1252,6 +1618,13 @@ public sealed record AppConfigResponse(
|
|||||||
string? GoogleCalendarServerClientId,
|
string? GoogleCalendarServerClientId,
|
||||||
bool GoogleCalendarConfigured);
|
bool GoogleCalendarConfigured);
|
||||||
|
|
||||||
|
public sealed record GoogleOAuthStartResponse(string AuthorizationUrl);
|
||||||
|
|
||||||
|
public sealed record GoogleOAuthStatusResponse(
|
||||||
|
bool Connected,
|
||||||
|
string? Email,
|
||||||
|
DateTimeOffset? UpdatedUtc);
|
||||||
|
|
||||||
public sealed record ProductResponse(
|
public sealed record ProductResponse(
|
||||||
bool Found,
|
bool Found,
|
||||||
string Barcode,
|
string Barcode,
|
||||||
|
|||||||
+137
-204
@@ -25,15 +25,7 @@ class AccountsPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AccountsPageState extends State<AccountsPage> {
|
class _AccountsPageState extends State<AccountsPage> {
|
||||||
static const calendarScopes = [
|
|
||||||
'https://www.googleapis.com/auth/calendar.readonly',
|
|
||||||
];
|
|
||||||
|
|
||||||
final googleSignIn = GoogleSignIn.instance;
|
|
||||||
List<GoogleCalendarInfo> googleCalendars = [];
|
List<GoogleCalendarInfo> googleCalendars = [];
|
||||||
GoogleSignInAccount? signedInAccount;
|
|
||||||
String? runtimeServerClientId;
|
|
||||||
bool initialized = false;
|
|
||||||
bool loadingCalendars = false;
|
bool loadingCalendars = false;
|
||||||
bool syncing = false;
|
bool syncing = false;
|
||||||
String? statusMessage;
|
String? statusMessage;
|
||||||
@@ -41,209 +33,156 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadGoogleAppConfigAndInitialize();
|
_loadGoogleConnectionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
GoogleCalendarConnection? get connection =>
|
GoogleCalendarConnection? get connection =>
|
||||||
widget.connections.isEmpty ? null : widget.connections.first;
|
widget.connections.isEmpty ? null : widget.connections.first;
|
||||||
|
|
||||||
Future<void> _loadGoogleAppConfigAndInitialize() async {
|
String? get _workspaceToken {
|
||||||
setState(() {
|
final token = widget.jarvisSettings.workspaceToken?.trim();
|
||||||
loadingCalendars = true;
|
return token == null || token.isEmpty ? null : token;
|
||||||
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 {
|
Map<String, String> get _jarvisHeaders {
|
||||||
if (googleCalendarServerClientId.trim().isNotEmpty) {
|
final headers = <String, String>{
|
||||||
return googleCalendarServerClientId.trim();
|
'X-Otthon-Token': widget.jarvisSettings.apiToken.trim(),
|
||||||
}
|
};
|
||||||
|
final workspaceToken = _workspaceToken;
|
||||||
final apiToken = widget.jarvisSettings.apiToken.trim();
|
if (workspaceToken != null) {
|
||||||
if (apiToken.isEmpty) {
|
headers['X-Workspace-Token'] = workspaceToken;
|
||||||
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;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _signInGoogle() async {
|
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(() {
|
setState(() {
|
||||||
loadingCalendars = true;
|
loadingCalendars = true;
|
||||||
statusMessage = 'Google bejelentkez\u00e9s...';
|
statusMessage = 'JARVIS Google kapcsolat ellen\u0151rz\u00e9se...';
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
final configError = _jarvisConfigError();
|
||||||
if (!initialized) {
|
if (configError != null) {
|
||||||
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(() {
|
setState(() {
|
||||||
loadingCalendars = false;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await googleSignIn.initialize(
|
final response = await http
|
||||||
serverClientId: clientId,
|
.get(_jarvisUri('/api/google/oauth/status'), headers: _jarvisHeaders)
|
||||||
);
|
.timeout(const Duration(seconds: 10));
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('JARVIS status ${response.statusCode}: ${response.body}');
|
||||||
|
}
|
||||||
|
final payload = jsonDecode(response.body);
|
||||||
|
if (payload is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Hib\u00e1s JARVIS v\u00e1lasz.');
|
||||||
|
}
|
||||||
|
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 =
|
||||||
|
'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 = 'JARVIS Google kapcsolat nem el\u00e9rhet\u0151: $error';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadGoogleCalendars({bool promptIfNecessary = true}) async {
|
Future<void> _startGoogleConnection() async {
|
||||||
|
setState(() {
|
||||||
|
loadingCalendars = true;
|
||||||
|
statusMessage = 'Google \u00f6sszek\u00f6t\u00e9s ind\u00edt\u00e1sa JARVIS-on...';
|
||||||
|
});
|
||||||
|
|
||||||
|
final configError = _jarvisConfigError();
|
||||||
|
if (configError != null) {
|
||||||
|
setState(() {
|
||||||
|
loadingCalendars = false;
|
||||||
|
statusMessage = configError;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
loadingCalendars = true;
|
loadingCalendars = true;
|
||||||
statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...';
|
statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...';
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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
|
final response = await http
|
||||||
.get(uri, headers: headers)
|
.get(_jarvisUri('/api/google/calendars'), headers: _jarvisHeaders)
|
||||||
.timeout(const Duration(seconds: 15));
|
.timeout(const Duration(seconds: 15));
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception(
|
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);
|
return a.name.compareTo(b.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
final account = signedInAccount;
|
|
||||||
final current = connection;
|
final current = connection;
|
||||||
if (account != null) {
|
if (current == null) {
|
||||||
widget.onChanged([
|
widget.onChanged([
|
||||||
GoogleCalendarConnection(
|
GoogleCalendarConnection(
|
||||||
id: current?.id ?? uniqueId(),
|
id: uniqueId(),
|
||||||
accountEmail: account.email,
|
accountEmail: 'Google Calendar',
|
||||||
enabledCalendarIds: current?.enabledCalendarIds ?? const [],
|
enabledCalendarIds: const [],
|
||||||
calendarPersonIds: current?.calendarPersonIds ?? const {},
|
calendarPersonIds: const {},
|
||||||
lastSync: current?.lastSync,
|
lastSync: null,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -312,9 +250,8 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (googleCalendars.isEmpty) {
|
if (googleCalendars.isEmpty) {
|
||||||
await _loadGoogleCalendars(promptIfNecessary: true);
|
await _loadGoogleCalendars();
|
||||||
}
|
}
|
||||||
final headers = await _googleHeaders(promptIfNecessary: true);
|
|
||||||
final selectedCalendars = googleCalendars
|
final selectedCalendars = googleCalendars
|
||||||
.where((calendar) => current.enabledCalendarIds.contains(calendar.id))
|
.where((calendar) => current.enabledCalendarIds.contains(calendar.id))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -329,19 +266,16 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||||||
final end = start.add(const Duration(days: 180));
|
final end = start.add(const Duration(days: 180));
|
||||||
|
|
||||||
for (final calendar in selectedCalendars) {
|
for (final calendar in selectedCalendars) {
|
||||||
final uri = Uri.https(
|
final uri = _jarvisUri(
|
||||||
'www.googleapis.com',
|
'/api/google/events',
|
||||||
'/calendar/v3/calendars/${Uri.encodeComponent(calendar.id)}/events',
|
|
||||||
{
|
{
|
||||||
'singleEvents': 'true',
|
'calendarId': calendar.id,
|
||||||
'orderBy': 'startTime',
|
|
||||||
'timeMin': start.toUtc().toIso8601String(),
|
'timeMin': start.toUtc().toIso8601String(),
|
||||||
'timeMax': end.toUtc().toIso8601String(),
|
'timeMax': end.toUtc().toIso8601String(),
|
||||||
'maxResults': '2500',
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final response = await http
|
final response = await http
|
||||||
.get(uri, headers: headers)
|
.get(uri, headers: _jarvisHeaders)
|
||||||
.timeout(const Duration(seconds: 20));
|
.timeout(const Duration(seconds: 20));
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception('${calendar.name}: Events API ${response.statusCode}');
|
throw Exception('${calendar.name}: Events API ${response.statusCode}');
|
||||||
@@ -396,13 +330,13 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||||||
.length;
|
.length;
|
||||||
|
|
||||||
return PageFrame(
|
return PageFrame(
|
||||||
eyebrow: 'Google Calendar API szinkron',
|
eyebrow: 'JARVIS Google Calendar szinkron',
|
||||||
title: 'Google napt\u00e1rak',
|
title: 'Google napt\u00e1rak',
|
||||||
action: FilledButton.icon(
|
action: FilledButton.icon(
|
||||||
onPressed: loadingCalendars ? null : _signInGoogle,
|
onPressed: loadingCalendars ? null : _startGoogleConnection,
|
||||||
icon: const Icon(Icons.login),
|
icon: const Icon(Icons.open_in_browser),
|
||||||
label: Text(
|
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(
|
child: ListView(
|
||||||
@@ -491,24 +425,23 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: loadingCalendars
|
onPressed: loadingCalendars
|
||||||
? null
|
? null
|
||||||
: () => _loadGoogleCalendars(promptIfNecessary: true),
|
: _loadGoogleConnectionStatus,
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'),
|
label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await googleSignIn.disconnect();
|
|
||||||
widget.onChanged([]);
|
widget.onChanged([]);
|
||||||
widget.onSyncedEventsChanged([]);
|
widget.onSyncedEventsChanged([]);
|
||||||
setState(() {
|
setState(() {
|
||||||
signedInAccount = null;
|
|
||||||
googleCalendars = [];
|
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),
|
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) ...[
|
if (statusMessage != null) ...[
|
||||||
|
|||||||
+1
-1
@@ -7,11 +7,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:geocoding/geocoding.dart' as geocoding;
|
import 'package:geocoding/geocoding.dart' as geocoding;
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:timezone/data/latest.dart' as timezone_data;
|
import 'package:timezone/data/latest.dart' as timezone_data;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
part 'data.dart';
|
part 'data.dart';
|
||||||
part 'app_shell.dart';
|
part 'app_shell.dart';
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import Foundation
|
|||||||
|
|
||||||
import geocoding_darwin
|
import geocoding_darwin
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import google_sign_in_ios
|
|
||||||
import mobile_scanner
|
import mobile_scanner
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
GeocodingDarwinPlugin.register(with: registry.registrar(forPlugin: "GeocodingDarwinPlugin"))
|
GeocodingDarwinPlugin.register(with: registry.registrar(forPlugin: "GeocodingDarwinPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
|
||||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-48
@@ -304,54 +304,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
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:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -685,6 +637,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
url_launcher:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: url_launcher
|
||||||
|
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.2"
|
||||||
|
url_launcher_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_android
|
||||||
|
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.32"
|
||||||
|
url_launcher_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_ios
|
||||||
|
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.4.1"
|
||||||
|
url_launcher_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_linux
|
||||||
|
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
|
url_launcher_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_macos
|
||||||
|
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.5"
|
||||||
|
url_launcher_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_platform_interface
|
||||||
|
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
|
url_launcher_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_web
|
||||||
|
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
url_launcher_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_windows
|
||||||
|
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.5"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ dependencies:
|
|||||||
geolocator: ^14.0.3
|
geolocator: ^14.0.3
|
||||||
geocoding: ^5.0.0
|
geocoding: ^5.0.0
|
||||||
mobile_scanner: ^7.4.0
|
mobile_scanner: ^7.4.0
|
||||||
google_sign_in: ^7.2.0
|
url_launcher: ^6.3.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <geolocator_windows/geolocator_windows.h>
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
GeolocatorWindowsRegisterWithRegistrar(
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
geolocator_windows
|
geolocator_windows
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user