Improve web calendar layout and shared sync state

This commit is contained in:
zsolt
2026-08-13 15:25:50 +02:00
parent 5e75c01b3f
commit 332657fc99
9 changed files with 40551 additions and 40265 deletions
+14
View File
@@ -12,6 +12,19 @@ using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddCors(options =>
{
options.AddPolicy("CalendarWeb", policy =>
{
policy
.WithOrigins(
"https://calendar.a-web.hu",
"http://localhost:5000",
"http://localhost:8080")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddHttpClient("open-food-facts", client => builder.Services.AddHttpClient("open-food-facts", client =>
{ {
client.BaseAddress = new Uri("https://world.openfoodfacts.org/"); client.BaseAddress = new Uri("https://world.openfoodfacts.org/");
@@ -52,6 +65,7 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
} }
app.UseCors("CalendarWeb");
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
+1 -1
View File
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"a10d8ac38de835021c8d2f920dbf50a920ccc0
_flutter.loader.load({ _flutter.loader.load({
serviceWorkerSettings: { serviceWorkerSettings: {
serviceWorkerVersion: "2721834507" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */ serviceWorkerVersion: "1030817064" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
} }
}); });
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -8,10 +8,19 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = /favicon.png {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files /favicon.png =404;
}
location = /manifest.json {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files /manifest.json =404;
}
location = /health { location = /health {
access_log off; access_log off;
add_header Content-Type text/plain; add_header Content-Type text/plain;
return 200 "ok\n"; return 200 "ok\n";
} }
} }
+47 -4
View File
@@ -520,6 +520,8 @@ class _HomeShellState extends State<HomeShell> {
languageCode: widget.languageCode, languageCode: widget.languageCode,
onChanged: widget.onGoogleConnectionsChanged, onChanged: widget.onGoogleConnectionsChanged,
onSyncedEventsChanged: widget.onSyncedEventsChanged, onSyncedEventsChanged: widget.onSyncedEventsChanged,
onWorkspaceStateShouldUpload: () =>
_uploadWorkspaceState(silent: true),
), ),
], ],
); );
@@ -969,6 +971,27 @@ class _HomeShellState extends State<HomeShell> {
.map(Person.fromJson) .map(Person.fromJson)
.toList() .toList()
: widget.people; : widget.people;
final nextAppCalendars = state['appCalendars'] is List
? (state['appCalendars'] as List)
.whereType<Map<String, dynamic>>()
.map(AppCalendar.fromJson)
.toList()
: widget.appCalendars;
final nextGoogleConnections = state['googleConnections'] is List
? (state['googleConnections'] as List)
.whereType<Map<String, dynamic>>()
.map(GoogleCalendarConnection.fromJson)
.toList()
: widget.googleConnections;
final nextSyncedEvents = state['syncedEvents'] is List
? (state['syncedEvents'] as List)
.whereType<Map<String, dynamic>>()
.map(CalendarEvent.fromJson)
.toList()
: widget.syncedEvents;
final nextTodoSuggestions = state['todoSuggestions'] is List
? (state['todoSuggestions'] as List).whereType<String>().toList()
: widget.todoSuggestions;
final nextShopping = state['shopping'] is List final nextShopping = state['shopping'] is List
? (state['shopping'] as List) ? (state['shopping'] as List)
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
@@ -991,6 +1014,10 @@ class _HomeShellState extends State<HomeShell> {
..addAll(nextStock); ..addAll(nextStock);
}); });
widget.onPeopleChanged(nextPeople); widget.onPeopleChanged(nextPeople);
widget.onAppCalendarsChanged(nextAppCalendars);
widget.onGoogleConnectionsChanged(nextGoogleConnections);
widget.onSyncedEventsChanged(nextSyncedEvents);
widget.onTodoSuggestionsChanged(nextTodoSuggestions);
if (!silent) { if (!silent) {
_showWorkspaceMessage('K\u00f6z\u00f6s munkat\u00e9r let\u00f6ltve.'); _showWorkspaceMessage('K\u00f6z\u00f6s munkat\u00e9r let\u00f6ltve.');
} }
@@ -1003,10 +1030,12 @@ class _HomeShellState extends State<HomeShell> {
} }
} }
Future<void> _uploadWorkspaceState() async { Future<void> _uploadWorkspaceState({bool silent = false}) async {
final workspaceToken = widget.jarvisSettings.workspaceToken?.trim(); final workspaceToken = widget.jarvisSettings.workspaceToken?.trim();
if (workspaceToken == null || workspaceToken.isEmpty) { if (workspaceToken == null || workspaceToken.isEmpty) {
_showWorkspaceMessage('Adj meg k\u00f6z\u00f6s munkat\u00e9r tokent.'); if (!silent) {
_showWorkspaceMessage('Adj meg k\u00f6z\u00f6s munkat\u00e9r tokent.');
}
return; return;
} }
@@ -1019,6 +1048,16 @@ class _HomeShellState extends State<HomeShell> {
'version': 1, 'version': 1,
'updatedAt': DateTime.now().toIso8601String(), 'updatedAt': DateTime.now().toIso8601String(),
'people': widget.people.map((person) => person.toJson()).toList(), 'people': widget.people.map((person) => person.toJson()).toList(),
'appCalendars': widget.appCalendars
.map((calendar) => calendar.toJson())
.toList(),
'googleConnections': widget.googleConnections
.map((connection) => connection.toJson())
.toList(),
'syncedEvents': widget.syncedEvents
.map((event) => event.toJson())
.toList(),
'todoSuggestions': widget.todoSuggestions,
'shopping': shopping.map((item) => item.toJson()).toList(), 'shopping': shopping.map((item) => item.toJson()).toList(),
'stock': stock.map((item) => item.toJson()).toList(), 'stock': stock.map((item) => item.toJson()).toList(),
}); });
@@ -1036,9 +1075,13 @@ class _HomeShellState extends State<HomeShell> {
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('workspace PUT ${response.statusCode}'); throw Exception('workspace PUT ${response.statusCode}');
} }
_showWorkspaceMessage('K\u00f6z\u00f6s munkat\u00e9r felt\u00f6ltve.'); if (!silent) {
_showWorkspaceMessage('K\u00f6z\u00f6s munkat\u00e9r felt\u00f6ltve.');
}
} catch (error) { } catch (error) {
_showWorkspaceMessage('Workspace hiba: $error'); if (!silent) {
_showWorkspaceMessage('Workspace hiba: $error');
}
} finally { } finally {
if (mounted) setState(() => workspaceSyncing = false); if (mounted) setState(() => workspaceSyncing = false);
} }
+101 -69
View File
@@ -741,34 +741,56 @@ class WeekBoard extends StatelessWidget {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, constraints) {
children: List.generate(7, (index) { final vertical = constraints.maxWidth < 760;
final date = weekStart.add(Duration(days: index)); final columns = List.generate(7, (index) {
final dayEvents = final date = weekStart.add(Duration(days: index));
events final dayEvents =
.where((event) => isSameDate(event.date, date)) events
.toList() .where((event) => isSameDate(event.date, date))
..sort((a, b) => a.start.compareTo(b.start)); .toList()
final daySecondaryEvents = secondaryEvents ..sort((a, b) => a.start.compareTo(b.start));
.where((event) => isSameDate(event.date, date)) final daySecondaryEvents = secondaryEvents
.toList(); .where((event) => isSameDate(event.date, date))
return Expanded( .toList();
child: Padding( return WeekDayColumn(
padding: EdgeInsets.only(right: index == 6 ? 0 : 6), title: days[index],
child: WeekDayColumn( dateLabel: formatShortDate(date),
title: days[index], active: selectedDay == index,
dateLabel: formatShortDate(date), events: dayEvents,
active: selectedDay == index, secondaryEvents: daySecondaryEvents,
events: dayEvents, appCalendars: appCalendars,
secondaryEvents: daySecondaryEvents, people: people,
appCalendars: appCalendars, compact: vertical,
people: people, onTap: () => onSelectDay(index),
onTap: () => onSelectDay(index), );
), });
),
if (vertical) {
return Column(
children: [
for (var i = 0; i < columns.length; i++) ...[
columns[i],
if (i != columns.length - 1) const SizedBox(height: 8),
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < columns.length; i++)
Expanded(
child: Padding(
padding: EdgeInsets.only(right: i == 6 ? 0 : 6),
child: columns[i],
),
),
],
); );
}), },
), ),
], ],
), ),
@@ -787,6 +809,7 @@ class WeekDayColumn extends StatelessWidget {
required this.secondaryEvents, required this.secondaryEvents,
required this.appCalendars, required this.appCalendars,
required this.people, required this.people,
this.compact = false,
required this.onTap, required this.onTap,
}); });
@@ -797,6 +820,7 @@ class WeekDayColumn extends StatelessWidget {
final List<CalendarEvent> secondaryEvents; final List<CalendarEvent> secondaryEvents;
final List<AppCalendar> appCalendars; final List<AppCalendar> appCalendars;
final List<Person> people; final List<Person> people;
final bool compact;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
@@ -807,7 +831,7 @@ class WeekDayColumn extends StatelessWidget {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
width: double.infinity, width: double.infinity,
constraints: const BoxConstraints(minHeight: 190), constraints: BoxConstraints(minHeight: compact ? 92 : 190),
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: active ? mint : const Color(0xFFF8F8F5), color: active ? mint : const Color(0xFFF8F8F5),
@@ -820,46 +844,54 @@ class WeekDayColumn extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
title, children: [
maxLines: 1, Expanded(
overflow: TextOverflow.ellipsis, child: Text(
style: const TextStyle( title,
color: ink, maxLines: 1,
fontSize: 14, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w900, style: const TextStyle(
), color: ink,
), fontSize: 14,
const SizedBox(height: 7), fontWeight: FontWeight.w900,
Container( ),
width: double.infinity, ),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? sage : Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: active
? [
BoxShadow(
color: sage.withValues(alpha: .18),
blurRadius: 12,
offset: const Offset(0, 5),
),
]
: null,
),
child: Text(
dateLabel,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: TextStyle(
color: active ? Colors.white : sage,
fontSize: 17,
letterSpacing: .5,
fontWeight: FontWeight.w900,
), ),
), const SizedBox(width: 8),
Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 8,
vertical: compact ? 5 : 7,
),
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? sage : Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: active
? [
BoxShadow(
color: sage.withValues(alpha: .18),
blurRadius: 12,
offset: const Offset(0, 5),
),
]
: null,
),
child: Text(
dateLabel,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: TextStyle(
color: active ? Colors.white : sage,
fontSize: compact ? 15 : 17,
letterSpacing: .5,
fontWeight: FontWeight.w900,
),
),
),
],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
if (secondaryEvents.isNotEmpty) ...[ if (secondaryEvents.isNotEmpty) ...[
@@ -870,8 +902,8 @@ class WeekDayColumn extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
if (events.isEmpty) if (events.isEmpty)
const SizedBox( SizedBox(
height: 92, height: compact ? 32 : 92,
child: Center( child: Center(
child: Text( child: Text(
'nincs program', 'nincs program',
+127 -37
View File
@@ -1057,48 +1057,81 @@ class _BarcodeScanDialogState extends State<BarcodeScanDialog> {
content: SizedBox( content: SizedBox(
width: 520, width: 520,
height: 360, height: 360,
child: ClipRRect( child: Column(
borderRadius: BorderRadius.circular(18), children: [
child: Stack( if (kIsWeb)
fit: StackFit.expand, Container(
children: [ margin: const EdgeInsets.only(bottom: 10),
MobileScanner( padding: const EdgeInsets.all(12),
controller: controller, decoration: BoxDecoration(
onDetect: (capture) { color: mint,
if (handled) return; borderRadius: BorderRadius.circular(16),
for (final code in capture.barcodes) { ),
final value = code.rawValue; child: const Row(
if (value == null || value.trim().isEmpty) continue; crossAxisAlignment: CrossAxisAlignment.start,
handled = true; children: [
Navigator.pop(context, value); Icon(Icons.info_outline, color: sage, size: 20),
break; SizedBox(width: 8),
} Expanded(
},
),
const Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.all(12),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text( child: Text(
'Tartsd a vonalk\u00f3dot az el\u0151lapi kamera el\u00e9.', 'Weben a kamera enged\u00e9lyt a b\u00f6ng\u00e9sz\u0151 k\u00e9ri. iPhone/iPad Safari alatt: aA / Webhelybe\u00e1ll\u00edt\u00e1sok / Kamera: Enged\u00e9lyez\u00e9s, majd t\u00f6ltsd \u00fajra az oldalt.',
style: TextStyle(color: Colors.white), style: TextStyle(color: ink, fontSize: 12.5),
), ),
), ),
), ],
), ),
), ),
], Expanded(
), child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Stack(
fit: StackFit.expand,
children: [
MobileScanner(
controller: controller,
placeholderBuilder: (context) =>
const ScannerPermissionHint(),
errorBuilder: (context, error) =>
ScannerPermissionHint(error: error),
onDetectError: (error, stackTrace) {},
onDetect: (capture) {
if (handled) return;
for (final code in capture.barcodes) {
final value = code.rawValue;
if (value == null || value.trim().isEmpty) continue;
handled = true;
Navigator.pop(context, value);
break;
}
},
),
const Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.all(12),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(
'Tartsd a vonalk\u00f3dot az el\u0151lapi kamera el\u00e9.',
style: TextStyle(color: Colors.white),
),
),
),
),
),
],
),
),
),
],
), ),
), ),
actions: [ actions: [
@@ -1111,6 +1144,63 @@ class _BarcodeScanDialogState extends State<BarcodeScanDialog> {
} }
} }
class ScannerPermissionHint extends StatelessWidget {
const ScannerPermissionHint({super.key, this.error});
final MobileScannerException? error;
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFF172B27),
padding: const EdgeInsets.all(20),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.photo_camera_outlined,
color: Colors.white,
size: 42,
),
const SizedBox(height: 14),
Text(
error == null
? 'Kamera enged\u00e9ly k\u00e9r\u00e9se...'
: 'A kamera nem el\u00e9rhet\u0151',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 18,
),
),
const SizedBox(height: 8),
Text(
error == null
? 'Ha a b\u00f6ng\u00e9sz\u0151 r\u00e1k\u00e9rdez, enged\u00e9lyezd a kamer\u00e1t a vonalk\u00f3d beolvas\u00e1s\u00e1hoz.'
: 'Enged\u00e9lyezd a kamer\u00e1t a b\u00f6ng\u00e9sz\u0151 webhelybe\u00e1ll\u00edt\u00e1sain\u00e1l, majd nyisd meg \u00fajra a scannert.',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70, height: 1.35),
),
if (error != null) ...[
const SizedBox(height: 10),
Text(
error!.errorCode.name,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
],
],
),
),
),
);
}
}
typedef JarvisRegisterCallback = typedef JarvisRegisterCallback =
Future<JarvisSettings> Function(JarvisSettings settings, String deviceName); Future<JarvisSettings> Function(JarvisSettings settings, String deviceName);
typedef JarvisStatusCallback = typedef JarvisStatusCallback =
+3
View File
@@ -12,6 +12,7 @@ class AccountsPage extends StatefulWidget {
required this.languageCode, required this.languageCode,
required this.onChanged, required this.onChanged,
required this.onSyncedEventsChanged, required this.onSyncedEventsChanged,
this.onWorkspaceStateShouldUpload,
}); });
final List<GoogleCalendarConnection> connections; final List<GoogleCalendarConnection> connections;
@@ -23,6 +24,7 @@ class AccountsPage extends StatefulWidget {
final String languageCode; final String languageCode;
final ValueChanged<List<GoogleCalendarConnection>> onChanged; final ValueChanged<List<GoogleCalendarConnection>> onChanged;
final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged; final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged;
final VoidCallback? onWorkspaceStateShouldUpload;
@override @override
State<AccountsPage> createState() => _AccountsPageState(); State<AccountsPage> createState() => _AccountsPageState();
@@ -429,6 +431,7 @@ class _AccountsPageState extends State<AccountsPage>
: '${synced.length} Google esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.' : '${synced.length} Google esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.'
'${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}'; '${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}';
}); });
widget.onWorkspaceStateShouldUpload?.call();
} catch (error) { } catch (error) {
setState(() { setState(() {
syncing = false; syncing = false;