Add Google calendar API and workspace sync
This commit is contained in:
parent
164f7ccad7
commit
ab6ce48a83
@ -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();
|
app.Run();
|
||||||
|
|
||||||
static bool HasApiToken(HttpContext context)
|
static bool HasApiToken(HttpContext context)
|
||||||
@ -496,6 +566,43 @@ static string? Clean(string? value)
|
|||||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
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<WorkspaceState> 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)
|
static void ApplyProductLookup(Product target, Product lookup, string source)
|
||||||
{
|
{
|
||||||
target.Name = lookup.Name;
|
target.Name = lookup.Name;
|
||||||
@ -580,6 +687,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
|
|||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
await EnsureProductSchemaAsync(db);
|
await EnsureProductSchemaAsync(db);
|
||||||
await EnsureFoodLearningSchemaAsync(db);
|
await EnsureFoodLearningSchemaAsync(db);
|
||||||
|
await EnsureWorkspaceSchemaAsync(db);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (attempt < maxAttempts)
|
catch (Exception ex) when (attempt < maxAttempts)
|
||||||
@ -596,6 +704,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
|
|||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
await EnsureProductSchemaAsync(db);
|
await EnsureProductSchemaAsync(db);
|
||||||
await EnsureFoodLearningSchemaAsync(db);
|
await EnsureFoodLearningSchemaAsync(db);
|
||||||
|
await EnsureWorkspaceSchemaAsync(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task EnsureProductSchemaAsync(OtthonDbContext db)
|
static async Task EnsureProductSchemaAsync(OtthonDbContext db)
|
||||||
@ -615,6 +724,22 @@ IF COL_LENGTH('Products', 'IngredientsText') IS NULL
|
|||||||
await db.Database.ExecuteSqlRawAsync(sql);
|
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)
|
static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db)
|
||||||
{
|
{
|
||||||
var sql = """
|
var sql = """
|
||||||
@ -843,9 +968,12 @@ public sealed class OtthonDbContext(DbContextOptions<OtthonDbContext> options)
|
|||||||
public DbSet<RegisteredDevice> Devices => Set<RegisteredDevice>();
|
public DbSet<RegisteredDevice> Devices => Set<RegisteredDevice>();
|
||||||
public DbSet<Product> Products => Set<Product>();
|
public DbSet<Product> Products => Set<Product>();
|
||||||
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
|
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
|
||||||
|
public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
modelBuilder.Entity<WorkspaceState>().HasKey(workspace => workspace.Token);
|
||||||
|
modelBuilder.Entity<WorkspaceState>().Property(workspace => workspace.Token).HasMaxLength(160);
|
||||||
modelBuilder.Entity<Product>().HasKey(product => product.Barcode);
|
modelBuilder.Entity<Product>().HasKey(product => product.Barcode);
|
||||||
modelBuilder.Entity<Product>().Property(product => product.Barcode).HasMaxLength(64);
|
modelBuilder.Entity<Product>().Property(product => product.Barcode).HasMaxLength(64);
|
||||||
modelBuilder.Entity<Product>().Property(product => product.Name).HasMaxLength(300);
|
modelBuilder.Entity<Product>().Property(product => product.Name).HasMaxLength(300);
|
||||||
@ -910,6 +1038,14 @@ public sealed class FoodLearningEvent
|
|||||||
public DateTimeOffset CreatedUtc { get; set; }
|
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 LoginForm(string Password);
|
||||||
public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform);
|
public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform);
|
||||||
public sealed record ProductUpsertRequest(
|
public sealed record ProductUpsertRequest(
|
||||||
@ -973,6 +1109,13 @@ public sealed record ShoppingRecommendationResponse(
|
|||||||
DateTimeOffset LastSeenUtc,
|
DateTimeOffset LastSeenUtc,
|
||||||
IReadOnlyList<string> Reasons);
|
IReadOnlyList<string> Reasons);
|
||||||
|
|
||||||
|
public sealed record WorkspaceStateRequest(string StateJson);
|
||||||
|
|
||||||
|
public sealed record WorkspaceStateResponse(
|
||||||
|
string WorkspaceToken,
|
||||||
|
string StateJson,
|
||||||
|
DateTimeOffset UpdatedUtc);
|
||||||
|
|
||||||
public sealed record ProductResponse(
|
public sealed record ProductResponse(
|
||||||
bool Found,
|
bool Found,
|
||||||
string Barcode,
|
string Barcode,
|
||||||
|
|||||||
@ -187,6 +187,7 @@ class HomeShell extends StatefulWidget {
|
|||||||
class _HomeShellState extends State<HomeShell> {
|
class _HomeShellState extends State<HomeShell> {
|
||||||
int selected = 0;
|
int selected = 0;
|
||||||
bool jarvisBackendOnline = false;
|
bool jarvisBackendOnline = false;
|
||||||
|
bool workspaceSyncing = false;
|
||||||
Timer? jarvisBackendTimer;
|
Timer? jarvisBackendTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -567,6 +568,64 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(height: 10),
|
||||||
Center(
|
Center(
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
@ -641,6 +700,119 @@ class _HomeShellState extends State<HomeShell> {
|
|||||||
_checkJarvisBackend();
|
_checkJarvisBackend();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<String, dynamic>) {
|
||||||
|
throw Exception('Invalid workspace response.');
|
||||||
|
}
|
||||||
|
final stateJson = payload['stateJson'] as String? ?? '{}';
|
||||||
|
final state = jsonDecode(stateJson);
|
||||||
|
if (state is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Invalid workspace state.');
|
||||||
|
}
|
||||||
|
final nextPeople = state['people'] is List
|
||||||
|
? (state['people'] as List)
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(Person.fromJson)
|
||||||
|
.toList()
|
||||||
|
: widget.people;
|
||||||
|
final nextShopping = state['shopping'] is List
|
||||||
|
? (state['shopping'] as List)
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(ShoppingItem.fromJson)
|
||||||
|
.toList()
|
||||||
|
: shopping;
|
||||||
|
final nextStock = state['stock'] is List
|
||||||
|
? (state['stock'] as List)
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.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<void> _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<JarvisSettings> _registerJarvisDevice(
|
Future<JarvisSettings> _registerJarvisDevice(
|
||||||
JarvisSettings settings,
|
JarvisSettings settings,
|
||||||
String deviceName,
|
String deviceName,
|
||||||
|
|||||||
135
lib/data.dart
135
lib/data.dart
@ -219,6 +219,7 @@ class JarvisSettings {
|
|||||||
JarvisSettings({
|
JarvisSettings({
|
||||||
required this.apiUrl,
|
required this.apiUrl,
|
||||||
required this.apiToken,
|
required this.apiToken,
|
||||||
|
this.workspaceToken,
|
||||||
this.deviceName,
|
this.deviceName,
|
||||||
this.deviceId,
|
this.deviceId,
|
||||||
this.deviceToken,
|
this.deviceToken,
|
||||||
@ -227,6 +228,7 @@ class JarvisSettings {
|
|||||||
|
|
||||||
final String apiUrl;
|
final String apiUrl;
|
||||||
final String apiToken;
|
final String apiToken;
|
||||||
|
final String? workspaceToken;
|
||||||
final String? deviceName;
|
final String? deviceName;
|
||||||
final int? deviceId;
|
final int? deviceId;
|
||||||
final String? deviceToken;
|
final String? deviceToken;
|
||||||
@ -240,6 +242,7 @@ class JarvisSettings {
|
|||||||
? defaultJarvisApiUrl
|
? defaultJarvisApiUrl
|
||||||
: json['apiUrl'] as String? ?? JarvisSettings.defaults().apiUrl,
|
: json['apiUrl'] as String? ?? JarvisSettings.defaults().apiUrl,
|
||||||
apiToken: json['apiToken'] as String? ?? '',
|
apiToken: json['apiToken'] as String? ?? '',
|
||||||
|
workspaceToken: json['workspaceToken'] as String?,
|
||||||
deviceName: json['deviceName'] as String?,
|
deviceName: json['deviceName'] as String?,
|
||||||
deviceId: json['deviceId'] as int?,
|
deviceId: json['deviceId'] as int?,
|
||||||
deviceToken: json['deviceToken'] as String?,
|
deviceToken: json['deviceToken'] as String?,
|
||||||
@ -250,6 +253,7 @@ class JarvisSettings {
|
|||||||
JarvisSettings copyWith({
|
JarvisSettings copyWith({
|
||||||
String? apiUrl,
|
String? apiUrl,
|
||||||
String? apiToken,
|
String? apiToken,
|
||||||
|
String? workspaceToken,
|
||||||
String? deviceName,
|
String? deviceName,
|
||||||
int? deviceId,
|
int? deviceId,
|
||||||
String? deviceToken,
|
String? deviceToken,
|
||||||
@ -259,6 +263,7 @@ class JarvisSettings {
|
|||||||
return JarvisSettings(
|
return JarvisSettings(
|
||||||
apiUrl: apiUrl ?? this.apiUrl,
|
apiUrl: apiUrl ?? this.apiUrl,
|
||||||
apiToken: apiToken ?? this.apiToken,
|
apiToken: apiToken ?? this.apiToken,
|
||||||
|
workspaceToken: workspaceToken ?? this.workspaceToken,
|
||||||
deviceName: clearDevice ? null : deviceName ?? this.deviceName,
|
deviceName: clearDevice ? null : deviceName ?? this.deviceName,
|
||||||
deviceId: clearDevice ? null : deviceId ?? this.deviceId,
|
deviceId: clearDevice ? null : deviceId ?? this.deviceId,
|
||||||
deviceToken: clearDevice ? null : deviceToken ?? this.deviceToken,
|
deviceToken: clearDevice ? null : deviceToken ?? this.deviceToken,
|
||||||
@ -271,6 +276,7 @@ class JarvisSettings {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'apiUrl': apiUrl,
|
'apiUrl': apiUrl,
|
||||||
'apiToken': apiToken,
|
'apiToken': apiToken,
|
||||||
|
'workspaceToken': workspaceToken,
|
||||||
'deviceName': deviceName,
|
'deviceName': deviceName,
|
||||||
'deviceId': deviceId,
|
'deviceId': deviceId,
|
||||||
'deviceToken': deviceToken,
|
'deviceToken': deviceToken,
|
||||||
@ -335,49 +341,40 @@ class GoogleCalendarConnection {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class GoogleCalendarOption {
|
class GoogleCalendarInfo {
|
||||||
const GoogleCalendarOption({
|
const GoogleCalendarInfo({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.description,
|
|
||||||
required this.color,
|
required this.color,
|
||||||
|
this.description = '',
|
||||||
|
this.primary = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String description;
|
final String description;
|
||||||
final Color color;
|
final Color color;
|
||||||
}
|
final bool primary;
|
||||||
|
|
||||||
const availableGoogleCalendars = [
|
factory GoogleCalendarInfo.fromJson(Map<String, dynamic> json) {
|
||||||
GoogleCalendarOption(
|
final backgroundColor = json['backgroundColor'] as String?;
|
||||||
id: 'primary',
|
return GoogleCalendarInfo(
|
||||||
name: 'Saj\u00e1t napt\u00e1r',
|
id: json['id'] as String? ?? 'primary',
|
||||||
description: 'A f\u0151 Google Calendar esem\u00e9nyeid.',
|
name: json['summaryOverride'] as String? ??
|
||||||
color: Color(0xFF4285F4),
|
json['summary'] as String? ??
|
||||||
),
|
'N\u00e9vtelen Google napt\u00e1r',
|
||||||
GoogleCalendarOption(
|
description: [
|
||||||
id: 'family',
|
json['description'] as String?,
|
||||||
name: 'Csal\u00e1di napt\u00e1r',
|
json['accessRole'] as String?,
|
||||||
description:
|
]
|
||||||
'K\u00f6z\u00f6s csal\u00e1di programok \u00e9s eml\u00e9keztet\u0151k.',
|
.whereType<String>()
|
||||||
color: Color(0xFF34A853),
|
.where((part) => part.trim().isNotEmpty)
|
||||||
),
|
.join(' \u00b7 '),
|
||||||
GoogleCalendarOption(
|
color: parseGoogleColor(backgroundColor),
|
||||||
id: 'school',
|
primary: json['primary'] == true,
|
||||||
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),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
List<GoogleCalendarConnection> defaultGoogleConnections() => [];
|
List<GoogleCalendarConnection> defaultGoogleConnections() => [];
|
||||||
|
|
||||||
@ -472,7 +469,7 @@ class CalendarEvent {
|
|||||||
final List<String> todos;
|
final List<String> todos;
|
||||||
final EventSource source;
|
final EventSource source;
|
||||||
|
|
||||||
bool get isSynced => source == EventSource.deviceCalendar;
|
bool get isSynced => source == EventSource.googleCalendar;
|
||||||
bool get isUncategorized => personId == null;
|
bool get isUncategorized => personId == null;
|
||||||
bool get isForEveryone => personId == everyonePersonId;
|
bool get isForEveryone => personId == everyonePersonId;
|
||||||
|
|
||||||
@ -531,31 +528,37 @@ class CalendarEvent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory CalendarEvent.fromDeviceEvent(
|
factory CalendarEvent.fromGoogleEvent(
|
||||||
device_calendar.Event event,
|
Map<String, dynamic> event,
|
||||||
device_calendar.Calendar calendar, {
|
GoogleCalendarInfo calendar, {
|
||||||
String? personId,
|
String? personId,
|
||||||
}) {
|
}) {
|
||||||
final startDate = event.start?.toLocal() ?? DateTime.now();
|
final startJson = event['start'] as Map<String, dynamic>? ?? const {};
|
||||||
final endDate = event.end?.toLocal();
|
final endJson = event['end'] as Map<String, dynamic>? ?? 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(
|
return CalendarEvent(
|
||||||
event.title?.trim().isNotEmpty == true
|
title?.trim().isNotEmpty == true
|
||||||
? event.title!.trim()
|
? title!.trim()
|
||||||
: 'N\u00e9vtelen napt\u00e1resem\u00e9ny',
|
: 'N\u00e9vtelen Google esem\u00e9ny',
|
||||||
event.allDay == true ? 'Eg\u00e9sz nap' : formatTime(startDate),
|
allDay ? 'Eg\u00e9sz nap' : formatTime(startDate),
|
||||||
event.allDay == true || endDate == null ? '' : formatTime(endDate),
|
allDay || endDate == null ? '' : formatTime(endDate),
|
||||||
Color(calendar.color ?? const Color(0xFF4285F4).toARGB32()),
|
calendar.color,
|
||||||
date: DateTime(startDate.year, startDate.month, startDate.day),
|
date: DateTime(startDate.year, startDate.month, startDate.day),
|
||||||
place: [
|
place: [
|
||||||
calendar.name ?? 'Napt\u00e1r',
|
calendar.name,
|
||||||
if ((event.location ?? '').trim().isNotEmpty) event.location!.trim(),
|
if ((location ?? '').trim().isNotEmpty) location!.trim(),
|
||||||
].join(' \u00b7 '),
|
].join(' \u00b7 '),
|
||||||
externalId: event.eventId,
|
externalId: event['id'] as String?,
|
||||||
calendarId: calendar.id,
|
calendarId: calendar.id,
|
||||||
calendarName: calendar.name,
|
calendarName: calendar.name,
|
||||||
personId: personId,
|
personId: personId,
|
||||||
note: event.description ?? '',
|
note: description ?? '',
|
||||||
source: EventSource.deviceCalendar,
|
source: EventSource.googleCalendar,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -577,7 +580,7 @@ class CalendarEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EventSource { local, deviceCalendar }
|
enum EventSource { local, googleCalendar }
|
||||||
|
|
||||||
class ShoppingItem {
|
class ShoppingItem {
|
||||||
ShoppingItem(this.name, this.amount, this.category, {this.done = false});
|
ShoppingItem(this.name, this.amount, this.category, {this.done = false});
|
||||||
@ -586,6 +589,20 @@ class ShoppingItem {
|
|||||||
final String amount;
|
final String amount;
|
||||||
final String category;
|
final String category;
|
||||||
bool done;
|
bool done;
|
||||||
|
|
||||||
|
factory ShoppingItem.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'amount': amount,
|
||||||
|
'category': category,
|
||||||
|
'done': done,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class StockItem {
|
class StockItem {
|
||||||
@ -596,6 +613,22 @@ class StockItem {
|
|||||||
String unit;
|
String unit;
|
||||||
String location;
|
String location;
|
||||||
String expiry;
|
String expiry;
|
||||||
|
|
||||||
|
factory StockItem.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'amount': amount,
|
||||||
|
'unit': unit,
|
||||||
|
'location': location,
|
||||||
|
'expiry': expiry,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class JarvisProduct {
|
class JarvisProduct {
|
||||||
|
|||||||
@ -1005,6 +1005,7 @@ class JarvisSettingsDialog extends StatefulWidget {
|
|||||||
class _JarvisSettingsDialogState extends State<JarvisSettingsDialog> {
|
class _JarvisSettingsDialogState extends State<JarvisSettingsDialog> {
|
||||||
late final TextEditingController apiUrl;
|
late final TextEditingController apiUrl;
|
||||||
late final TextEditingController apiToken;
|
late final TextEditingController apiToken;
|
||||||
|
late final TextEditingController workspaceToken;
|
||||||
late final TextEditingController deviceName;
|
late final TextEditingController deviceName;
|
||||||
late JarvisSettings currentSettings;
|
late JarvisSettings currentSettings;
|
||||||
bool registering = false;
|
bool registering = false;
|
||||||
@ -1017,6 +1018,9 @@ class _JarvisSettingsDialogState extends State<JarvisSettingsDialog> {
|
|||||||
currentSettings = widget.settings;
|
currentSettings = widget.settings;
|
||||||
apiUrl = TextEditingController(text: currentSettings.apiUrl);
|
apiUrl = TextEditingController(text: currentSettings.apiUrl);
|
||||||
apiToken = TextEditingController(text: currentSettings.apiToken);
|
apiToken = TextEditingController(text: currentSettings.apiToken);
|
||||||
|
workspaceToken = TextEditingController(
|
||||||
|
text: currentSettings.workspaceToken ?? '',
|
||||||
|
);
|
||||||
deviceName = TextEditingController(
|
deviceName = TextEditingController(
|
||||||
text: currentSettings.deviceName ?? 'Family tablet',
|
text: currentSettings.deviceName ?? 'Family tablet',
|
||||||
);
|
);
|
||||||
@ -1026,6 +1030,7 @@ class _JarvisSettingsDialogState extends State<JarvisSettingsDialog> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
apiUrl.dispose();
|
apiUrl.dispose();
|
||||||
apiToken.dispose();
|
apiToken.dispose();
|
||||||
|
workspaceToken.dispose();
|
||||||
deviceName.dispose();
|
deviceName.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -1033,6 +1038,9 @@ class _JarvisSettingsDialogState extends State<JarvisSettingsDialog> {
|
|||||||
JarvisSettings get draft => currentSettings.copyWith(
|
JarvisSettings get draft => currentSettings.copyWith(
|
||||||
apiUrl: normalizeJarvisUrl(apiUrl.text),
|
apiUrl: normalizeJarvisUrl(apiUrl.text),
|
||||||
apiToken: apiToken.text.trim(),
|
apiToken: apiToken.text.trim(),
|
||||||
|
workspaceToken: workspaceToken.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: workspaceToken.text.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -1074,6 +1082,18 @@ class _JarvisSettingsDialogState extends State<JarvisSettingsDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
TextField(
|
||||||
controller: deviceName,
|
controller: deviceName,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
|
|||||||
621
lib/google_calendar_page.dart
Normal file
621
lib/google_calendar_page.dart
Normal file
@ -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<GoogleCalendarConnection> connections;
|
||||||
|
final List<CalendarEvent> cachedEvents;
|
||||||
|
final List<Person> people;
|
||||||
|
final ValueChanged<List<GoogleCalendarConnection>> onChanged;
|
||||||
|
final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountsPage> createState() => _AccountsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountsPageState extends State<AccountsPage> {
|
||||||
|
static const calendarScopes = [
|
||||||
|
'https://www.googleapis.com/auth/calendar.readonly',
|
||||||
|
];
|
||||||
|
|
||||||
|
final googleSignIn = GoogleSignIn.instance;
|
||||||
|
List<GoogleCalendarInfo> googleCalendars = [];
|
||||||
|
GoogleSignInAccount? signedInAccount;
|
||||||
|
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<void> _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<Map<String, String>> _googleHeaders({
|
||||||
|
bool promptIfNecessary = true,
|
||||||
|
}) async {
|
||||||
|
final account = signedInAccount;
|
||||||
|
final headers = account == null
|
||||||
|
? await googleSignIn.authorizationClient.authorizationHeaders(
|
||||||
|
calendarScopes,
|
||||||
|
promptIfNecessary: promptIfNecessary,
|
||||||
|
)
|
||||||
|
: await account.authorizationClient.authorizationHeaders(
|
||||||
|
calendarScopes,
|
||||||
|
promptIfNecessary: promptIfNecessary,
|
||||||
|
);
|
||||||
|
if (headers == null) {
|
||||||
|
throw Exception('A Google Calendar jogosults\u00e1g nincs megadva.');
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _signInGoogle() async {
|
||||||
|
setState(() {
|
||||||
|
loadingCalendars = true;
|
||||||
|
statusMessage = 'Google bejelentkez\u00e9s...';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!initialized) {
|
||||||
|
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<void> _loadGoogleCalendars({bool promptIfNecessary = true}) async {
|
||||||
|
setState(() {
|
||||||
|
loadingCalendars = true;
|
||||||
|
statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final headers = await _googleHeaders(
|
||||||
|
promptIfNecessary: promptIfNecessary,
|
||||||
|
);
|
||||||
|
final uri = Uri.https(
|
||||||
|
'www.googleapis.com',
|
||||||
|
'/calendar/v3/users/me/calendarList',
|
||||||
|
{'minAccessRole': 'reader', 'showHidden': 'true'},
|
||||||
|
);
|
||||||
|
final response = await http
|
||||||
|
.get(uri, headers: headers)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'CalendarList API ${response.statusCode}: ${response.body}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final payload = jsonDecode(response.body);
|
||||||
|
if (payload is! Map<String, dynamic>) {
|
||||||
|
throw Exception('Hib\u00e1s Google Calendar v\u00e1lasz.');
|
||||||
|
}
|
||||||
|
final items = payload['items'];
|
||||||
|
final calendars = items is List
|
||||||
|
? items
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(GoogleCalendarInfo.fromJson)
|
||||||
|
.toList()
|
||||||
|
: <GoogleCalendarInfo>[];
|
||||||
|
calendars.sort((a, b) {
|
||||||
|
if (a.primary != b.primary) return a.primary ? -1 : 1;
|
||||||
|
return a.name.compareTo(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
final account = signedInAccount;
|
||||||
|
final current = connection;
|
||||||
|
if (account != null) {
|
||||||
|
widget.onChanged([
|
||||||
|
GoogleCalendarConnection(
|
||||||
|
id: current?.id ?? uniqueId(),
|
||||||
|
accountEmail: account.email,
|
||||||
|
enabledCalendarIds: current?.enabledCalendarIds ?? const [],
|
||||||
|
calendarPersonIds: current?.calendarPersonIds ?? const {},
|
||||||
|
lastSync: current?.lastSync,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
googleCalendars = calendars;
|
||||||
|
loadingCalendars = false;
|
||||||
|
statusMessage = calendars.isEmpty
|
||||||
|
? 'A Google fi\u00f3kban nem tal\u00e1ltam olvashat\u00f3 napt\u00e1rat.'
|
||||||
|
: '${calendars.length} Google napt\u00e1r el\u00e9rhet\u0151. V\u00e1laszd ki, melyik ker\u00fclj\u00f6n be a csal\u00e1di napt\u00e1rba.';
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
loadingCalendars = false;
|
||||||
|
statusMessage =
|
||||||
|
'Google napt\u00e1rlista lek\u00e9r\u00e9se nem siker\u00fclt: $error';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncSelectedCalendars() async {
|
||||||
|
final current = connection;
|
||||||
|
if (current == null || current.enabledCalendarIds.isEmpty) {
|
||||||
|
setState(
|
||||||
|
() => statusMessage =
|
||||||
|
'V\u00e1lassz ki legal\u00e1bb egy Google napt\u00e1rat.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
syncing = true;
|
||||||
|
statusMessage = 'Google esem\u00e9nyek beolvas\u00e1sa...';
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (googleCalendars.isEmpty) {
|
||||||
|
await _loadGoogleCalendars(promptIfNecessary: true);
|
||||||
|
}
|
||||||
|
final headers = await _googleHeaders(promptIfNecessary: true);
|
||||||
|
final selectedCalendars = googleCalendars
|
||||||
|
.where((calendar) => current.enabledCalendarIds.contains(calendar.id))
|
||||||
|
.toList();
|
||||||
|
final synced = <CalendarEvent>[];
|
||||||
|
final syncDetails = <String>[];
|
||||||
|
final now = DateTime.now();
|
||||||
|
final start = DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
).subtract(const Duration(days: 30));
|
||||||
|
final end = start.add(const Duration(days: 180));
|
||||||
|
|
||||||
|
for (final calendar in selectedCalendars) {
|
||||||
|
final uri = Uri.https(
|
||||||
|
'www.googleapis.com',
|
||||||
|
'/calendar/v3/calendars/${Uri.encodeComponent(calendar.id)}/events',
|
||||||
|
{
|
||||||
|
'singleEvents': 'true',
|
||||||
|
'orderBy': 'startTime',
|
||||||
|
'timeMin': start.toUtc().toIso8601String(),
|
||||||
|
'timeMax': end.toUtc().toIso8601String(),
|
||||||
|
'maxResults': '2500',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final response = await http
|
||||||
|
.get(uri, headers: headers)
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('${calendar.name}: Events API ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final payload = jsonDecode(response.body);
|
||||||
|
if (payload is! Map<String, dynamic>) continue;
|
||||||
|
final rawEvents = payload['items'];
|
||||||
|
final events = rawEvents is List
|
||||||
|
? rawEvents.whereType<Map<String, dynamic>>().toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
syncDetails.add('${calendar.name}: ${events.length}');
|
||||||
|
for (final event in events) {
|
||||||
|
if (event['status'] == 'cancelled') continue;
|
||||||
|
synced.add(
|
||||||
|
CalendarEvent.fromGoogleEvent(
|
||||||
|
event,
|
||||||
|
calendar,
|
||||||
|
personId: current.calendarPersonIds[calendar.id],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synced.sort((a, b) {
|
||||||
|
final dateCompare = a.date.compareTo(b.date);
|
||||||
|
return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start);
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.onSyncedEventsChanged(synced);
|
||||||
|
widget.onChanged([current.copyWith(lastSync: DateTime.now())]);
|
||||||
|
setState(() {
|
||||||
|
syncing = false;
|
||||||
|
statusMessage =
|
||||||
|
'${synced.length} Google esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.'
|
||||||
|
'${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}';
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
syncing = false;
|
||||||
|
statusMessage = 'A Google szinkron nem siker\u00fclt: $error';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final current = connection;
|
||||||
|
final hasConnection = current != null && current.accountEmail.isNotEmpty;
|
||||||
|
final cachedUncategorized = widget.cachedEvents
|
||||||
|
.where((event) => event.isUncategorized)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
return PageFrame(
|
||||||
|
eyebrow: 'Google Calendar API szinkron',
|
||||||
|
title: 'Google napt\u00e1rak',
|
||||||
|
action: FilledButton.icon(
|
||||||
|
onPressed: loadingCalendars ? null : _signInGoogle,
|
||||||
|
icon: const Icon(Icons.login),
|
||||||
|
label: Text(
|
||||||
|
hasConnection ? 'Fi\u00f3k v\u00e1lt\u00e1sa' : 'Google bel\u00e9p\u00e9s',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
children: [
|
||||||
|
GoogleSyncHero(
|
||||||
|
accountEmail: current?.accountEmail,
|
||||||
|
enabledCount: current?.enabledCalendarIds.length ?? 0,
|
||||||
|
cachedCount: widget.cachedEvents.length,
|
||||||
|
uncategorizedCount: cachedUncategorized,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
if (!hasConnection)
|
||||||
|
const EmptyGoogleConnectionCard()
|
||||||
|
else ...[
|
||||||
|
const Text(
|
||||||
|
'Google Calendar API-b\u00f3l el\u00e9rhet\u0151 napt\u00e1rak',
|
||||||
|
style: TextStyle(
|
||||||
|
color: ink,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (loadingCalendars)
|
||||||
|
const LinearProgressIndicator()
|
||||||
|
else
|
||||||
|
...googleCalendars.map(
|
||||||
|
(calendar) => GoogleCalendarToggleCard(
|
||||||
|
calendar: calendar,
|
||||||
|
enabled: current.enabledCalendarIds.contains(calendar.id),
|
||||||
|
people: widget.people,
|
||||||
|
selectedPersonId: current.calendarPersonIds[calendar.id],
|
||||||
|
onChanged: (enabled) {
|
||||||
|
final nextIds = [...current.enabledCalendarIds];
|
||||||
|
final nextPersonIds = {...current.calendarPersonIds};
|
||||||
|
if (enabled) {
|
||||||
|
if (!nextIds.contains(calendar.id)) {
|
||||||
|
nextIds.add(calendar.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextIds.remove(calendar.id);
|
||||||
|
nextPersonIds.remove(calendar.id);
|
||||||
|
}
|
||||||
|
widget.onChanged([
|
||||||
|
current.copyWith(
|
||||||
|
enabledCalendarIds: nextIds,
|
||||||
|
calendarPersonIds: nextPersonIds,
|
||||||
|
lastSync: current.lastSync,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onPersonChanged: (personId) {
|
||||||
|
final nextPersonIds = {...current.calendarPersonIds};
|
||||||
|
if (personId == null) {
|
||||||
|
nextPersonIds.remove(calendar.id);
|
||||||
|
} else {
|
||||||
|
nextPersonIds[calendar.id] = personId;
|
||||||
|
}
|
||||||
|
widget.onChanged([
|
||||||
|
current.copyWith(
|
||||||
|
calendarPersonIds: nextPersonIds,
|
||||||
|
lastSync: current.lastSync,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: syncing ? null : _syncSelectedCalendars,
|
||||||
|
icon: syncing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.sync),
|
||||||
|
label: Text(
|
||||||
|
syncing
|
||||||
|
? 'Szinkroniz\u00e1l\u00e1s...'
|
||||||
|
: 'Szinkroniz\u00e1l\u00e1s most',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: loadingCalendars
|
||||||
|
? null
|
||||||
|
: () => _loadGoogleCalendars(promptIfNecessary: true),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
await googleSignIn.disconnect();
|
||||||
|
widget.onChanged([]);
|
||||||
|
widget.onSyncedEventsChanged([]);
|
||||||
|
setState(() {
|
||||||
|
signedInAccount = null;
|
||||||
|
googleCalendars = [];
|
||||||
|
statusMessage = 'Google kapcsolat lev\u00e1lasztva.';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.link_off),
|
||||||
|
label: const Text('Google kapcsolat lev\u00e1laszt\u00e1sa'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (statusMessage != null) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(statusMessage!, style: const TextStyle(color: Colors.black54)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: mint,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: sage),
|
||||||
|
SizedBox(width: 13),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Ez a szinkron egyir\u00e1ny\u00fa: a Google Calendar API-b\u00f3l olvas, az esem\u00e9nyeket JSON cache-be menti, \u00e9s nem \u00edr vissza Google napt\u00e1rba. Offline \u00e1llapotban a kor\u00e1bban beolvasott cache marad l\u00e1that\u00f3.',
|
||||||
|
style: TextStyle(color: ink, height: 1.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GoogleSyncHero extends StatelessWidget {
|
||||||
|
const GoogleSyncHero({
|
||||||
|
super.key,
|
||||||
|
required this.accountEmail,
|
||||||
|
required this.enabledCount,
|
||||||
|
required this.cachedCount,
|
||||||
|
required this.uncategorizedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? accountEmail;
|
||||||
|
final int enabledCount;
|
||||||
|
final int cachedCount;
|
||||||
|
final int uncategorizedCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final connected = accountEmail != null && accountEmail!.isNotEmpty;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(22),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF18332D), Color(0xFF2F6B5B)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: sage.withValues(alpha: .22),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 58,
|
||||||
|
height: 58,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: .16),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.g_mobiledata_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 44,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
connected
|
||||||
|
? accountEmail!
|
||||||
|
: 'M\u00e9g nincs Google napt\u00e1rkapcsolat',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
connected
|
||||||
|
? '$enabledCount napt\u00e1r bekapcsolva \u00b7 $cachedCount esem\u00e9ny cache-ben \u00b7 $uncategorizedCount kategoriz\u00e1latlan'
|
||||||
|
: 'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rakb\u00f3l hozzon esem\u00e9nyeket.',
|
||||||
|
style: const TextStyle(color: Colors.white70, height: 1.35),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyGoogleConnectionCard extends StatelessWidget {
|
||||||
|
const EmptyGoogleConnectionCard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.calendar_month_outlined, color: sage),
|
||||||
|
SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Google bel\u00e9p\u00e9s ut\u00e1n az app k\u00f6zvetlen\u00fcl a Google Calendar API-b\u00f3l k\u00e9ri le a napt\u00e1rakat. Android rendszer-napt\u00e1r szinkron m\u00e1r nem kell hozz\u00e1.',
|
||||||
|
style: TextStyle(color: ink, height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GoogleCalendarToggleCard extends StatelessWidget {
|
||||||
|
const GoogleCalendarToggleCard({
|
||||||
|
super.key,
|
||||||
|
required this.calendar,
|
||||||
|
required this.enabled,
|
||||||
|
required this.people,
|
||||||
|
required this.selectedPersonId,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onPersonChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final GoogleCalendarInfo calendar;
|
||||||
|
final bool enabled;
|
||||||
|
final List<Person> people;
|
||||||
|
final String? selectedPersonId;
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
final ValueChanged<String?> onPersonChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
value: enabled,
|
||||||
|
activeThumbColor: calendar.color,
|
||||||
|
onChanged: onChanged,
|
||||||
|
secondary: CircleAvatar(
|
||||||
|
backgroundColor: calendar.color.withValues(alpha: .14),
|
||||||
|
child: Icon(
|
||||||
|
calendar.primary ? Icons.star_rounded : Icons.calendar_today,
|
||||||
|
color: calendar.color,
|
||||||
|
size: 19,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
calendar.name,
|
||||||
|
style: const TextStyle(color: ink, fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
calendar.description.isEmpty
|
||||||
|
? calendar.id
|
||||||
|
: '${calendar.description} \u00b7 ${calendar.id}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (enabled)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(72, 0, 16, 0),
|
||||||
|
child: DropdownButtonFormField<String?>(
|
||||||
|
initialValue: selectedPersonId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Kihez tartozzanak az esem\u00e9nyek?',
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Kategoriz\u00e1latlan'),
|
||||||
|
),
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: everyonePersonId,
|
||||||
|
child: Text('Mindenki'),
|
||||||
|
),
|
||||||
|
...people.map(
|
||||||
|
(person) => DropdownMenuItem<String?>(
|
||||||
|
value: person.id,
|
||||||
|
child: Text('${person.avatar} ${person.name}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: onPersonChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,11 +3,11 @@ import 'dart:io';
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:device_calendar/device_calendar.dart' as device_calendar;
|
|
||||||
import 'package:flutter/material.dart';
|
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';
|
||||||
@ -17,6 +17,7 @@ part 'data.dart';
|
|||||||
part 'app_shell.dart';
|
part 'app_shell.dart';
|
||||||
part 'calendar_widgets.dart';
|
part 'calendar_widgets.dart';
|
||||||
part 'pages.dart';
|
part 'pages.dart';
|
||||||
|
part 'google_calendar_page.dart';
|
||||||
part 'dialogs.dart';
|
part 'dialogs.dart';
|
||||||
part 'utils.dart';
|
part 'utils.dart';
|
||||||
|
|
||||||
|
|||||||
608
lib/pages.dart
608
lib/pages.dart
@ -420,614 +420,6 @@ class _StockPageState extends State<StockPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<GoogleCalendarConnection> connections;
|
|
||||||
final List<CalendarEvent> cachedEvents;
|
|
||||||
final List<Person> people;
|
|
||||||
final ValueChanged<List<GoogleCalendarConnection>> onChanged;
|
|
||||||
final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AccountsPage> createState() => _AccountsPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AccountsPageState extends State<AccountsPage> {
|
|
||||||
final calendarPlugin = device_calendar.DeviceCalendarPlugin();
|
|
||||||
List<device_calendar.Calendar> 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<void> _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 <device_calendar.Calendar>[])
|
|
||||||
.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<String>()
|
|
||||||
.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<void> _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 = <CalendarEvent>[];
|
|
||||||
final syncDetails = <String>[];
|
|
||||||
final now = DateTime.now();
|
|
||||||
final start = DateTime(
|
|
||||||
now.year,
|
|
||||||
now.month,
|
|
||||||
now.day,
|
|
||||||
).subtract(const Duration(days: 30));
|
|
||||||
final end = start.add(const Duration(days: 180));
|
|
||||||
|
|
||||||
for (final calendar in selectedCalendars) {
|
|
||||||
final result = await calendarPlugin.retrieveEvents(
|
|
||||||
calendar.id,
|
|
||||||
device_calendar.RetrieveEventsParams(startDate: start, endDate: end),
|
|
||||||
);
|
|
||||||
final events = result.data ?? const <device_calendar.Event>[];
|
|
||||||
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<void> _connectGoogle(BuildContext context) async {
|
|
||||||
final email = await showDialog<String>(
|
|
||||||
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<GoogleAccountDialog> createState() => _GoogleAccountDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GoogleAccountDialogState extends State<GoogleAccountDialog> {
|
|
||||||
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<bool> 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<Person> people;
|
|
||||||
final String? selectedPersonId;
|
|
||||||
final ValueChanged<bool> onChanged;
|
|
||||||
final ValueChanged<String?> 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<String>()
|
|
||||||
.where((part) => part.isNotEmpty)
|
|
||||||
.join(' \u00b7 '),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (enabled)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 0),
|
|
||||||
child: DropdownButtonFormField<String?>(
|
|
||||||
initialValue: selectedPersonId,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Kihez tartozzanak az esem\u00e9nyek?',
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem<String?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('Kategoriz\u00e1latlan'),
|
|
||||||
),
|
|
||||||
const DropdownMenuItem<String?>(
|
|
||||||
value: everyonePersonId,
|
|
||||||
child: Text('Mindenki'),
|
|
||||||
),
|
|
||||||
...people.map(
|
|
||||||
(person) => DropdownMenuItem<String?>(
|
|
||||||
value: person.id,
|
|
||||||
child: Text('${person.avatar} ${person.name}'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: onPersonChanged,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class InfoBox extends StatelessWidget {
|
class InfoBox extends StatelessWidget {
|
||||||
const InfoBox({super.key});
|
const InfoBox({super.key});
|
||||||
|
|
||||||
|
|||||||
@ -58,6 +58,32 @@ DateTime? parseDate(String value) {
|
|||||||
return DateTime(year, month, day);
|
return DateTime(year, month, day);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DateTime? parseGoogleEventDate(Map<String, dynamic> 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 uniqueId() => DateTime.now().microsecondsSinceEpoch.toString();
|
||||||
|
|
||||||
String normalizeJarvisUrl(String value) {
|
String normalizeJarvisUrl(String value) {
|
||||||
|
|||||||
@ -7,12 +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
|
||||||
|
|
||||||
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
64
pubspec.lock
64
pubspec.lock
@ -137,14 +137,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.14"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -312,6 +304,54 @@ 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:
|
||||||
@ -589,14 +629,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
sprintf:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sprintf
|
|
||||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.0"
|
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -35,12 +35,12 @@ dependencies:
|
|||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
path_provider: ^2.1.6
|
path_provider: ^2.1.6
|
||||||
device_calendar: ^4.3.3
|
|
||||||
timezone: ^0.9.4
|
timezone: ^0.9.4
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
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
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user