Skip to main content

locality_social_cloud_calendar 1.2.0 copy "locality_social_cloud_calendar: ^1.2.0" to clipboard
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.

example/lib/main.dart

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'),
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
150
points
98
downloads

Documentation

API reference

Publisher

verified publisherlocality.media

Weekly Downloads

A cloud calendar for Locality Social Cloud with support for finding time conflicts, heatmaps, to-do-items, parties, appointments and more.

Homepage
Repository
View/report issues

License

BSD-3-Clause (license)

Dependencies

flutter, locality_core, m511chacha20, theory, uuid

More

Packages that depend on locality_social_cloud_calendar