locality_social_cloud_calendar 1.2.0
locality_social_cloud_calendar: ^1.2.0 copied to clipboard
A cloud calendar for Locality Social Cloud with support for finding time conflicts, heatmaps, to-do-items, parties, appointments and more.
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:locality_core/locality_core.dart';
import 'package:locality_social_cloud_calendar/algebra/time/time_set.dart';
import 'package:locality_social_cloud_calendar/cloud_calendar_item.dart';
import 'package:locality_social_cloud_calendar/locality_social_cloud_calendar.dart';
import 'package:provider/provider.dart';
const kRealmId = 'ptr2';
const kRealmSecret = 'h3w1uzXhmHc4JvZi6ZE6bGXlPPISH/t26Pb6vtGzffE=';
const kAccent = Color(0xFF007AFF);
const kNavy = Color(0xFF002D52);
const kPageBg = Color(0xFFF2F4F7);
const kCardBg = Colors.white;
const kTextSecondary = Color(0xFF888888);
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const CalendarExampleApp());
}
class CalendarExampleApp extends StatelessWidget {
const CalendarExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'locality_social_cloud_calendar example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: kNavy),
useMaterial3: true,
scaffoldBackgroundColor: kPageBg,
),
home: const LoginPage(),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Factory + domain (official API)
// ═══════════════════════════════════════════════════════════════════════════
class AppCalendarFactory extends CloudCalendarItemFactory {
@override
CloudCalendarItem createFrom(Broadcast localityEvent) {
final payload = localityEvent.getPayload();
final type = payload['type']?.toString() ?? 'simple';
final occurrenceMap = payload['occurrence'] is Map
? Map<String, dynamic>.from(payload['occurrence'] as Map)
: Map<String, dynamic>.from(payload);
final occurrence = TimeSet.fromMap(occurrenceMap);
if (type == 'festivity') {
return SocialEvent(
localityEvent.uuid,
occurrence: occurrence,
title: payload['title']?.toString() ?? 'Veranstaltung',
description: payload['description']?.toString(),
address: payload['address']?.toString(),
);
}
return SimpleCalendarItem(
localityEvent.uuid,
occurrence: occurrence,
title: payload['title']?.toString() ?? 'Termin',
description: payload['description']?.toString(),
);
}
@override
Map<String, dynamic> toMap(CloudCalendarItem calendarEntry) {
switch (calendarEntry.getType()) {
case 'festivity':
final event = calendarEntry as SocialEvent;
return {
'type': 'festivity',
'title': event.title,
if (event.description != null) 'description': event.description,
if (event.address != null) 'address': event.address,
'occurrence': event.occursAt().toMap(),
};
case 'simple':
default:
if (calendarEntry is SimpleCalendarItem) {
return {
'type': 'simple',
'title': calendarEntry.title,
if (calendarEntry.description != null)
'description': calendarEntry.description,
'occurrence': calendarEntry.occursAt().toMap(),
};
}
return calendarEntry.occursAt().toMap();
}
}
}
class SimpleCalendarItem extends CloudCalendarItem {
final String id;
final TimeSet occurrence;
final String title;
final String? description;
SimpleCalendarItem(
this.id, {
required this.occurrence,
this.title = 'Termin',
this.description,
});
@override
String getId() => id;
@override
TimeSet occursAt() => occurrence;
@override
String getType() => 'simple';
}
enum ParticipationStatus {
invited,
unselectedAttendanceStatus,
definitelyAttending,
probablyAttending,
possiblyAttending,
confirmedNotAttending;
static ParticipationStatus fromString(String value) =>
ParticipationStatus.values.byName(value);
}
class Participation {
final String userId;
ParticipationStatus status;
Participation({required this.userId, required this.status});
}
class SocialEvent extends CloudCalendarItem with PubSub, ChangeNotifier {
final String id;
final TimeSet occurrence;
final String title;
final String? description;
final String? address;
final Map<String, Participation> participationByUserId = {};
SocialEvent(
this.id, {
required this.occurrence,
this.title = 'Veranstaltung',
this.description,
this.address,
});
@override
String getId() => id;
@override
String getTopic() => '$id-socialEvent-participation';
@override
TimeSet occursAt() => occurrence;
@override
String getType() => 'festivity';
@override
void onReceive(Broadcast localityEvent) {
if (localityEvent.event != 'set-status') return;
final payload = localityEvent.getPayload();
final userId = payload['user-id']?.toString();
if (userId == null) return;
final status = ParticipationStatus.fromString(
payload['status']?.toString() ?? 'unselectedAttendanceStatus',
);
if (participationByUserId.containsKey(userId)) {
participationByUserId[userId]!.status = status;
} else {
participationByUserId[userId] =
Participation(userId: userId, status: status);
}
notifyListeners();
}
void setParticipationStatus(PublicUser user, ParticipationStatus status) {
send('set-status', {
'user-id': user.id,
'status': status.name,
});
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Read helpers – only getCalendarEntries()
// ═══════════════════════════════════════════════════════════════════════════
DateTime? _startOf(CloudCalendarItem item) {
final intervals = item.occursAt();
if (intervals.isEmpty) return null;
final millis = intervals
.map((i) => i.startMillisSinceEpoch)
.reduce((a, b) => a < b ? a : b);
return DateTime.fromMillisecondsSinceEpoch(millis);
}
DateTime? _endOf(CloudCalendarItem item) {
final intervals = item.occursAt();
if (intervals.isEmpty) return null;
final millis = intervals
.map((i) => i.endMillisSinceEpoch)
.reduce((a, b) => a > b ? a : b);
return DateTime.fromMillisecondsSinceEpoch(millis);
}
List<CloudCalendarItem> _entriesOnDay(CloudCalendar calendar, DateTime day) {
final dayOnly = DateTime(day.year, day.month, day.day);
final next = dayOnly.add(const Duration(days: 1));
final all = calendar.getCalendarEntries();
final filtered = <CloudCalendarItem>[];
for (final e in all) {
final s = _startOf(e);
// No start → show on "today" only (index handled by caller via null→today)
if (s == null) {
if (dayOnly.year == DateTime.now().year &&
dayOnly.month == DateTime.now().month &&
dayOnly.day == DateTime.now().day) {
filtered.add(e);
}
continue;
}
final sDay = DateTime(s.year, s.month, s.day);
if (!sDay.isBefore(dayOnly) && sDay.isBefore(next)) {
filtered.add(e);
}
}
filtered.sort((a, b) {
final as = _startOf(a)?.millisecondsSinceEpoch ?? 0;
final bs = _startOf(b)?.millisecondsSinceEpoch ?? 0;
return as.compareTo(bs);
});
return filtered;
}
List<int> _heatmap(CloudCalendar calendar) {
final h = List.filled(34, 0);
final now = DateTime.now();
final base = DateTime(now.year, now.month, now.day);
for (final e in calendar.getCalendarEntries()) {
final s = _startOf(e);
if (s == null) continue;
final diff = DateTime(s.year, s.month, s.day).difference(base).inDays;
if (diff >= 0 && diff < 34) h[diff]++;
}
return h;
}
// ═══════════════════════════════════════════════════════════════════════════
// Session
// ═══════════════════════════════════════════════════════════════════════════
class CalendarSession extends ChangeNotifier {
final AuthenticatedSessionUser user;
final CloudCalendar calendar;
int selectedDayIndex = 0;
CalendarSession({required this.user, required this.calendar}) {
calendar.addListener(_onCal);
}
void _onCal() => notifyListeners();
String get userId {
try {
final id = user.publicUser.id;
if (id.isNotEmpty) return id;
} catch (_) {}
return user.toString();
}
DateTime get selectedDay {
final n = DateTime.now();
final d = n.add(Duration(days: selectedDayIndex));
return DateTime(d.year, d.month, d.day);
}
List<CloudCalendarItem> get itemsForSelectedDay =>
_entriesOnDay(calendar, selectedDay);
List<int> get heatmap => _heatmap(calendar);
void selectDay(int i) {
if (selectedDayIndex == i) {
// re-tap: force refresh of entries for this day
notifyListeners();
return;
}
selectedDayIndex = i;
notifyListeners();
}
void addSimple({
required String title,
String? description,
required DateTime startsAt,
required DateTime endsAt,
}) {
calendar.addEvent(
SimpleCalendarItem(
'simple-${DateTime.now().millisecondsSinceEpoch}',
occurrence: TimeSet.fromDateAndDuration(
startTime: startsAt,
duration: endsAt.difference(startsAt),
),
title: title,
description: description,
),
);
}
void addFestivity({
required String title,
String? description,
String? address,
required DateTime startsAt,
required DateTime endsAt,
}) {
calendar.addEvent(
SocialEvent(
'fest-${DateTime.now().millisecondsSinceEpoch}',
occurrence: TimeSet.fromDateAndDuration(
startTime: startsAt,
duration: endsAt.difference(startsAt),
),
title: title,
description: description,
address: address,
),
);
}
@override
void dispose() {
calendar.removeListener(_onCal);
super.dispose();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Login
// ═══════════════════════════════════════════════════════════════════════════
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _user = TextEditingController();
final _pass = TextEditingController();
bool _register = true;
bool _loading = false;
String? _error;
@override
void dispose() {
_user.dispose();
_pass.dispose();
super.dispose();
}
Future<void> _go() async {
final name = _user.text.trim();
final pass = _pass.text;
if (name.isEmpty || pass.isEmpty) {
setState(() => _error = 'Benutzername und Passwort nötig');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final localityCloud = await LocalitySocialCloud.startTest(
userCredentials: UserCredentials(name, pass),
developerCredentials: DeveloperCredentials(
realmId: kRealmId,
realmSecret: kRealmSecret,
),
isRegistration: _register,
);
LocalitySocialCloudCalendar.initialize(factory: AppCalendarFactory());
final user = localityCloud.getLoggedInUser();
final calendar =
await LocalitySocialCloudCalendar.getPrivateCalendarOf(user);
if (!mounted) return;
final session = CalendarSession(user: user, calendar: calendar);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => MultiProvider(
providers: [
// CloudCalendar is Listenable → UI rebuilds on getCalendarEntries changes
ChangeNotifierProvider<CloudCalendar>.value(value: calendar),
ChangeNotifierProvider<CalendarSession>.value(value: session),
],
child: const HomePage(),
),
),
);
} catch (e, st) {
debugPrint('$e\n$st');
if (mounted) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.calendar_month, size: 64, color: kAccent),
const SizedBox(height: 12),
const Text(
'Cloud Calendar',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: kNavy,
),
),
const Text(
'locality_social_cloud_calendar example',
style: TextStyle(color: Colors.black54),
),
const SizedBox(height: 28),
TextField(
controller: _user,
decoration: const InputDecoration(
labelText: 'Benutzername',
border: OutlineInputBorder(),
),
enabled: !_loading,
),
const SizedBox(height: 12),
TextField(
controller: _pass,
decoration: const InputDecoration(
labelText: 'Passwort',
border: OutlineInputBorder(),
),
obscureText: true,
enabled: !_loading,
onSubmitted: (_) => _go(),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Neu registrieren'),
value: _register,
onChanged:
_loading ? null : (v) => setState(() => _register = v),
),
if (_error != null)
Text(_error!,
style: const TextStyle(color: Colors.red, fontSize: 13)),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: Colors.white,
),
onPressed: _loading ? null : _go,
child: _loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_register ? 'Registrieren & Login' : 'Login'),
),
),
],
),
),
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Home
// ═══════════════════════════════════════════════════════════════════════════
class HomePage extends StatelessWidget {
const HomePage({super.key});
Future<void> _create(BuildContext context, CalendarSession session) async {
final choice = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.event),
title: const Text('Veranstaltung (festivity)'),
onTap: () => Navigator.pop(ctx, 'event'),
),
ListTile(
leading: const Icon(Icons.watch_later_outlined),
title: const Text('Termin (simple)'),
onTap: () => Navigator.pop(ctx, 'simple'),
),
],
),
),
);
if (choice == null || !context.mounted) return;
if (choice == 'event') {
final r = await Navigator.of(context).push<_EventFormResult>(
MaterialPageRoute(builder: (_) => const CreateEventForm()),
);
if (r != null) {
session.addFestivity(
title: r.title,
description: r.description,
address: r.address,
startsAt: r.startsAt,
endsAt: r.endsAt,
);
}
} else if (choice == 'simple') {
final r = await Navigator.of(context).push<_ApptFormResult>(
MaterialPageRoute(builder: (_) => const CreateAppointmentForm()),
);
if (r != null) {
session.addSimple(
title: r.title,
description: r.description,
startsAt: r.startsAt,
endsAt: r.endsAt,
);
}
}
}
@override
Widget build(BuildContext context) {
final session = context.watch<CalendarSession>();
context.watch<CloudCalendar>();
final items = session.itemsForSelectedDay;
final heat = session.heatmap;
final df = DateFormat('dd.MM');
return Scaffold(
backgroundColor: kPageBg,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 12, 8),
child: Row(
children: [
const Expanded(
child: Text(
'Kalender',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w300,
color: kNavy,
),
),
),
Text(
session.userId,
style: const TextStyle(fontSize: 11, color: Colors.black54),
),
const SizedBox(width: 8),
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(50),
onTap: () => _create(context, session),
child: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: [kAccent, kNavy]),
),
child: const Icon(Icons.add,
color: Colors.white, size: 20),
),
),
),
],
),
),
SizedBox(
height: 56,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 34,
itemBuilder: (context, index) {
final selected = index == session.selectedDayIndex;
final count = heat[index];
final day = DateTime.now().add(Duration(days: index));
final Gradient g;
if (selected) {
g = const LinearGradient(
colors: [kAccent, Color(0xFF00C6FF)],
);
} else if (count > 0) {
final o = (count * count / 100 + 0.25).clamp(0.25, 1.0);
g = LinearGradient(colors: [
const Color(0xFF79FFF0).withValues(alpha: o),
const Color(0xFF0F89FF).withValues(alpha: o),
]);
} else {
g = const LinearGradient(
colors: [Color(0xFFE8EEF4), Color(0xFFD0D8E0)],
);
}
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
borderRadius: BorderRadius.circular(25),
onTap: () => session.selectDay(index),
child: Container(
width: selected ? 78 : 72,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: g,
borderRadius: BorderRadius.circular(25),
border: selected
? Border.all(color: kAccent, width: 1.5)
: null,
),
child: Text(
index == 0 ? 'Heute' : df.format(day),
style: TextStyle(
fontSize: 13,
color: kNavy,
fontWeight:
selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
);
},
),
),
const SizedBox(height: 8),
// Selected day label
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Text(
session.selectedDayIndex == 0
? 'Heute · ${DateFormat('dd.MM.yyyy').format(session.selectedDay)}'
: DateFormat('dd.MM.yyyy').format(session.selectedDay),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: kNavy,
),
),
),
Expanded(
child: items.isEmpty
? Center(
child: Text(
'Keine Einträge am ${DateFormat('dd.MM.').format(session.selectedDay)}.\n'
'Tippe + um etwas anzulegen.',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.black54),
),
)
: ListView.builder(
key: ValueKey(
'day-${session.selectedDayIndex}-${items.length}'),
padding: const EdgeInsets.only(bottom: 24),
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[i];
if (item is SocialEvent) {
return _EventTile(
item: item,
onOpen: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
ChangeNotifierProvider<SocialEvent>.value(
value: item,
child: EventDetailPage(
event: item,
user: session.user,
),
),
),
);
},
);
}
return _SimpleTile(item: item as SimpleCalendarItem);
},
),
),
],
),
),
);
}
}
class _EventTile extends StatelessWidget {
final SocialEvent item;
final VoidCallback onOpen;
const _EventTile({required this.item, required this.onOpen});
@override
Widget build(BuildContext context) {
final df = DateFormat('dd.MM HH:mm');
final start = _startOf(item);
final end = _endOf(item);
final time = [
if (start != null) df.format(start),
if (end != null) df.format(end),
].join(' – ');
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: kCardBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0x0D000000)),
boxShadow: [
BoxShadow(
color: kNavy.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onOpen,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Veranstaltung',
style: TextStyle(fontSize: 12, color: kTextSecondary)),
Text(
item.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: kNavy,
),
),
if (item.description != null && item.description!.isNotEmpty)
Text(
item.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, color: kTextSecondary),
),
if (time.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(time,
style: const TextStyle(
fontSize: 12, color: kTextSecondary)),
),
if (item.address != null)
Text(item.address!,
style:
const TextStyle(fontSize: 12, color: kTextSecondary)),
],
),
),
),
),
);
}
}
class _SimpleTile extends StatelessWidget {
final SimpleCalendarItem item;
const _SimpleTile({required this.item});
@override
Widget build(BuildContext context) {
final df = DateFormat('HH:mm');
final start = _startOf(item);
final time = start != null ? df.format(start) : '';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: kCardBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0x11707070)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Termin',
style: TextStyle(fontSize: 12, color: kTextSecondary)),
Text(item.title,
style:
const TextStyle(color: kNavy, fontWeight: FontWeight.w500)),
if (item.description != null)
Text(item.description!,
style: const TextStyle(fontSize: 12, color: kTextSecondary)),
if (time.isNotEmpty)
Text(time,
style: const TextStyle(fontSize: 12, color: kTextSecondary)),
],
),
);
}
}
class EventDetailPage extends StatelessWidget {
final SocialEvent event;
final AuthenticatedSessionUser user;
const EventDetailPage({
super.key,
required this.event,
required this.user,
});
@override
Widget build(BuildContext context) {
final live = context.watch<SocialEvent>();
final df = DateFormat('dd.MM.yyyy HH:mm');
final start = _startOf(live);
final end = _endOf(live);
ParticipationStatus? myStatus;
try {
myStatus = live.participationByUserId[user.publicUser.id]?.status;
} catch (_) {}
return Scaffold(
backgroundColor: kPageBg,
appBar: AppBar(
backgroundColor: kCardBg,
foregroundColor: kNavy,
elevation: 0.5,
title: Text(live.title),
),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
Text(live.title,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.w600, color: kNavy)),
if (live.description != null) ...[
const SizedBox(height: 8),
Text(live.description!,
style: const TextStyle(color: kTextSecondary)),
],
const SizedBox(height: 16),
if (live.address != null)
_info(Icons.place, 'Standort', live.address!),
if (start != null)
_info(Icons.calendar_today, 'Beginn', df.format(start)),
if (end != null) _info(Icons.schedule, 'Ende', df.format(end)),
const SizedBox(height: 24),
Text('Teilnahme: ${myStatus?.name ?? '—'}',
style: const TextStyle(color: kNavy)),
const SizedBox(height: 12),
SizedBox(
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: Colors.white,
),
onPressed: () {
live.setParticipationStatus(
user.publicUser,
ParticipationStatus.definitelyAttending,
);
},
child: Text(
myStatus == ParticipationStatus.definitelyAttending
? 'Du hast zugesagt!'
: 'Zusagen',
),
),
),
],
),
);
}
Widget _info(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Icon(icon, color: kAccent, size: 20),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 11, color: kNavy)),
Text(value,
style: const TextStyle(fontSize: 14, color: kTextSecondary)),
],
),
],
),
);
}
}
class _EventFormResult {
final String title, description, address;
final DateTime startsAt, endsAt;
_EventFormResult({
required this.title,
required this.description,
required this.address,
required this.startsAt,
required this.endsAt,
});
}
class CreateEventForm extends StatefulWidget {
const CreateEventForm({super.key});
@override
State<CreateEventForm> createState() => _CreateEventFormState();
}
class _CreateEventFormState extends State<CreateEventForm> {
final _title = TextEditingController();
final _desc = TextEditingController();
final _address = TextEditingController();
DateTime _start = DateTime.now().add(const Duration(hours: 2));
DateTime _end = DateTime.now().add(const Duration(hours: 5));
@override
void dispose() {
_title.dispose();
_desc.dispose();
_address.dispose();
super.dispose();
}
Future<void> _pick(bool start) async {
final base = start ? _start : _end;
final d = await showDatePicker(
context: context,
initialDate: base,
firstDate: DateTime.now().subtract(const Duration(days: 1)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (d == null || !mounted) return;
final t = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(base),
);
if (t == null) return;
setState(() {
final dt = DateTime(d.year, d.month, d.day, t.hour, t.minute);
if (start) {
_start = dt;
if (!_end.isAfter(_start)) {
_end = _start.add(const Duration(hours: 2));
}
} else {
_end = dt;
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: kPageBg,
appBar: AppBar(
backgroundColor: kCardBg,
foregroundColor: kNavy,
elevation: 0.5,
title: const Text('Veranstalten'),
),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
TextField(
controller: _title,
decoration: const InputDecoration(
labelText: 'Titel',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _desc,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Beschreibung',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _address,
decoration: const InputDecoration(
labelText: 'Ort',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Beginn'),
subtitle: Text('$_start'),
trailing: const Icon(Icons.schedule),
onTap: () => _pick(true),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Ende'),
subtitle: Text('$_end'),
trailing: const Icon(Icons.schedule),
onTap: () => _pick(false),
),
const SizedBox(height: 24),
SizedBox(
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: Colors.white,
),
onPressed: () {
if (_title.text.trim().isEmpty) return;
Navigator.pop(
context,
_EventFormResult(
title: _title.text.trim(),
description: _desc.text.trim(),
address: _address.text.trim(),
startsAt: _start,
endsAt: _end,
),
);
},
child: const Text('Veranstaltung erstellen'),
),
),
],
),
);
}
}
class _ApptFormResult {
final String title;
final String? description;
final DateTime startsAt, endsAt;
_ApptFormResult({
required this.title,
this.description,
required this.startsAt,
required this.endsAt,
});
}
class CreateAppointmentForm extends StatefulWidget {
const CreateAppointmentForm({super.key});
@override
State<CreateAppointmentForm> createState() => _CreateAppointmentFormState();
}
class _CreateAppointmentFormState extends State<CreateAppointmentForm> {
final _title = TextEditingController();
final _desc = TextEditingController();
DateTime _when = DateTime.now().add(const Duration(hours: 1));
@override
void dispose() {
_title.dispose();
_desc.dispose();
super.dispose();
}
Future<void> _pick() async {
final d = await showDatePicker(
context: context,
initialDate: _when,
firstDate: DateTime.now().subtract(const Duration(days: 1)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (d == null || !mounted) return;
final t = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_when),
);
if (t == null) return;
setState(() {
_when = DateTime(d.year, d.month, d.day, t.hour, t.minute);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: kPageBg,
appBar: AppBar(
backgroundColor: kCardBg,
foregroundColor: kNavy,
elevation: 0.5,
title: const Text('Termin erstellen'),
),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
TextField(
controller: _title,
decoration: const InputDecoration(
labelText: 'Titel',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _desc,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Beschreibung',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Zeitpunkt'),
subtitle: Text('$_when'),
trailing: const Icon(Icons.schedule),
onTap: _pick,
),
const SizedBox(height: 24),
SizedBox(
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: Colors.white,
),
onPressed: () {
if (_title.text.trim().isEmpty) return;
Navigator.pop(
context,
_ApptFormResult(
title: _title.text.trim(),
description:
_desc.text.trim().isEmpty ? null : _desc.text.trim(),
startsAt: _when,
endsAt: _when.add(const Duration(hours: 1)),
),
);
},
child: const Text('Termin eintragen'),
),
),
],
),
);
}
}