From f1b887fa8d4f610a0f836254e6c500b919bd985e Mon Sep 17 00:00:00 2001 From: AlexIves16 Date: Fri, 27 Mar 2026 20:01:20 +0500 Subject: [PATCH] Add Google Calendar integration with sync --- lib/core/google_calendar_service.dart | 244 ++++++++++++++ lib/screens/calendar_screen.dart | 459 +++++++++++++++++++++++--- pubspec.lock | 16 + pubspec.yaml | 1 + 4 files changed, 674 insertions(+), 46 deletions(-) create mode 100644 lib/core/google_calendar_service.dart diff --git a/lib/core/google_calendar_service.dart b/lib/core/google_calendar_service.dart new file mode 100644 index 0000000..a0bde1c --- /dev/null +++ b/lib/core/google_calendar_service.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; +import 'package:googleapis/calendar/v3.dart' as gcal; +import 'package:googleapis_auth/auth_io.dart'; +import 'package:http/http.dart' as http; +import '../models/calendar_event_model.dart'; + +class GoogleCalendarService { + static const List _scopes = [ + gcal.CalendarApi.calendarEventsScope, + gcal.CalendarApi.calendarReadonlyScope, + ]; + + String? _accessToken; + String? _refreshToken; + DateTime? _tokenExpiry; + gcal.CalendarApi? _calendarApi; + + // Авторизация через OAuth2 + Future authorize(String clientId, String clientSecret, String refreshToken) async { + _refreshToken = refreshToken; + + final credentials = ServiceAccountCredentials( + jsonDecode(jsonEncode({ + 'client_id': clientId, + 'client_secret': clientSecret, + 'refresh_token': refreshToken, + 'type': 'authorized_user', + })), + ); + + final client = await clientViaServiceAccount(credentials, _scopes); + _calendarApi = gcal.CalendarApi(client); + } + + // Получение списка событий + Future> getEvents({ + required DateTime timeMin, + required DateTime timeMax, + String calendarId = 'primary', + }) async { + if (_calendarApi == null) { + throw Exception('Not authorized'); + } + + try { + final events = await _calendarApi!.events.list( + calendarId, + timeMin: timeMin.toUtc(), + timeMax: timeMax.toUtc(), + orderBy: 'startTime', + singleEvents: true, + ); + + return events.items?.map((event) => _convertToEventModel(event)).toList() ?? []; + } catch (e) { + print('Error fetching events: $e'); + return []; + } + } + + // Создание события + Future createEvent({ + required String title, + required DateTime startTime, + required DateTime endTime, + String? description, + String? location, + List? attendeeEmails, + String calendarId = 'primary', + }) async { + if (_calendarApi == null) { + throw Exception('Not authorized'); + } + + final event = gcal.Event((e) { + e.summary = title; + e.description = description; + e.location = location; + e.start = gcal.EventDateTime((dt) { + dt.dateTime = startTime.toUtc(); + }); + e.end = gcal.EventDateTime((dt) { + dt.dateTime = endTime.toUtc(); + }); + + if (attendeeEmails != null && attendeeEmails.isNotEmpty) { + e.attendees = attendeeEmails + .map((email) => gcal.EventAttendee((a) => a.email = email)) + .toList(); + } + }); + + final createdEvent = await _calendarApi!.events.insert(event, calendarId); + return _convertToEventModel(createdEvent); + } + + // Обновление события + Future updateEvent( + String eventId, { + String? title, + DateTime? startTime, + DateTime? endTime, + String? description, + String? location, + String calendarId = 'primary', + }) async { + if (_calendarApi == null) { + throw Exception('Not authorized'); + } + + // Сначала получаем текущее событие + final existingEvent = await _calendarApi!.events.get(calendarId, eventId); + + final updatedEvent = gcal.Event((e) { + e.summary = title ?? existingEvent.summary; + e.description = description ?? existingEvent.description; + e.location = location ?? existingEvent.location; + + if (startTime != null) { + e.start = gcal.EventDateTime((dt) { + dt.dateTime = startTime.toUtc(); + }); + } else { + e.start = existingEvent.start; + } + + if (endTime != null) { + e.end = gcal.EventDateTime((dt) { + dt.dateTime = endTime.toUtc(); + }); + } else { + e.end = existingEvent.end; + } + }); + + final result = await _calendarApi!.events.update(updatedEvent, calendarId, eventId); + return _convertToEventModel(result); + } + + // Удаление события + Future deleteEvent(String eventId, String calendarId = 'primary') async { + if (_calendarApi == null) { + throw Exception('Not authorized'); + } + + await _calendarApi!.events.delete(calendarId, eventId); + } + + // Конвертация Google Event в нашу модель + CalendarEventModel _convertToEventModel(gcal.Event event) { + final startTime = event.start?.dateTime ?? event.start?.date ?? DateTime.now(); + final endTime = event.end?.dateTime ?? event.end?.date ?? DateTime.now(); + + return CalendarEventModel( + id: event.id ?? '', + title: event.summary ?? 'Без названия', + description: event.description, + location: event.location ?? '', + startTime: startTime.toLocal(), + endTime: endTime.toLocal(), + isAllDay: event.start?.date != null, + googleEventId: event.id, + attendeeEmails: event.attendees?.map((a) => a.email ?? '').toList() ?? [], + color: _getColorFromEvent(event), + status: event.status ?? 'confirmed', + createdBy: event.creator?.email ?? '', + createdAt: event.created ?? DateTime.now(), + updatedAt: event.updated ?? DateTime.now(), + ); + } + + String _getColorFromEvent(gcal.Event event) { + // Google Calendar использует colorId для цветов + switch (event.colorId) { + case '1': return '#a4bdfc'; // lavender + case '2': return '#7ae7bf'; // sage + case '3': return '#dbadff'; // grape + case '4': return '#ff887c'; // flamo + case '5': return '#fbd75b'; // banana + case '6': return '#ffef8f'; // tangerine + case '7': return '#dc2127'; // tomato + case '8': return '#b9e4e0'; // peacock + case '9': return '#e1e1e1'; // graphite + case '10': return '#5484ed'; // blueberry + case '11': return '#51b749'; // basil + default: return '#2196F3'; // default blue + } + } + + // Получение URL для OAuth авторизации + static String getAuthUrl(String clientId, String redirectUri) { + final scopesEncoded = _scopes.join('%20'); + return 'https://accounts.google.com/o/oauth2/auth' + '?client_id=$clientId' + '&redirect_uri=$redirectUri' + '&scope=$scopesEncoded' + '&response_type=code' + '&access_type=offline' + '&prompt=consent'; + } + + // Обмен кода на токен + Future> exchangeCodeForToken({ + required String code, + required String clientId, + required String clientSecret, + required String redirectUri, + }) async { + final response = await http.post( + Uri.parse('https://oauth2.googleapis.com/token'), + body: { + 'code': code, + 'client_id': clientId, + 'client_secret': clientSecret, + 'redirect_uri': redirectUri, + 'grant_type': 'authorization_code', + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return { + 'accessToken': data['access_token'], + 'refreshToken': data['refresh_token'], + 'expiry': DateTime.now().add(Duration(seconds: data['expires_in'])), + }; + } else { + throw Exception('Failed to exchange code: ${response.body}'); + } + } + + // Проверка авторизации + bool get isAuthorized => _calendarApi != null; + + // Выход + void logout() { + _calendarApi = null; + _accessToken = null; + _refreshToken = null; + _tokenExpiry = null; + } +} + +final googleCalendarService = GoogleCalendarService(); diff --git a/lib/screens/calendar_screen.dart b/lib/screens/calendar_screen.dart index da960f6..0b06f11 100644 --- a/lib/screens/calendar_screen.dart +++ b/lib/screens/calendar_screen.dart @@ -1,8 +1,145 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:table_calendar/table_calendar.dart'; +import '../models/calendar_event_model.dart'; +import '../core/google_calendar_service.dart'; -class CalendarScreen extends StatelessWidget { +class CalendarScreen extends ConsumerStatefulWidget { const CalendarScreen({super.key}); + @override + ConsumerState createState() => _CalendarScreenState(); +} + +class _CalendarScreenState extends ConsumerState { + DateTime _focusedDay = DateTime.now(); + DateTime? _selectedDay; + List _events = []; + bool _isLoading = false; + bool _isSyncing = false; + + @override + void initState() { + super.initState(); + _selectedDay = _focusedDay; + _loadEvents(); + } + + Future _loadEvents() async { + setState(() { + _isLoading = true; + }); + + try { + // Загружаем события за месяц + final timeMin = DateTime(_focusedDay.year, _focusedDay.month, 1); + final timeMax = + DateTime(_focusedDay.year, _focusedDay.month + 1, 0, 23, 59); + + // TODO: Загрузить из Firestore или Google Calendar + // Пока используем тестовые данные + await Future.delayed(const Duration(milliseconds: 500)); + + setState(() { + _events = _generateMockEvents(); + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка: $e')), + ); + } + } + } + + List _generateMockEvents() { + final now = DateTime.now(); + return [ + CalendarEventModel( + id: '1', + title: 'Командная встреча', + startTime: DateTime(now.year, now.month, now.day, 10, 0), + endTime: DateTime(now.year, now.month, now.day, 11, 0), + color: '#2196F3', + description: 'Еженедельный синк с командой', + createdBy: 'user1', + createdAt: now, + updatedAt: now, + ), + CalendarEventModel( + id: '2', + title: 'Дедлайн проекта', + startTime: DateTime(now.year, now.month, now.day + 2, 18, 0), + endTime: DateTime(now.year, now.month, now.day + 2, 19, 0), + color: '#F44336', + description: 'Сдача CRM проекта', + createdBy: 'user1', + createdAt: now, + updatedAt: now, + ), + CalendarEventModel( + id: '3', + title: 'Обед с клиентом', + startTime: DateTime(now.year, now.month, now.day + 5, 13, 0), + endTime: DateTime(now.year, now.month, now.day + 5, 14, 0), + color: '#4CAF50', + location: 'Ресторан "Москва"', + createdBy: 'user1', + createdAt: now, + updatedAt: now, + ), + ]; + } + + Future _syncWithGoogleCalendar() async { + setState(() { + _isSyncing = true; + }); + + try { + // TODO: Реальная синхронизация с Google Calendar + await googleCalendarService.getEvents( + timeMin: DateTime(_focusedDay.year, _focusedDay.month, 1), + timeMax: DateTime(_focusedDay.year, _focusedDay.month + 1, 0, 23, 59), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Синхронизация успешна'), + backgroundColor: Colors.green, + ), + ); + await _loadEvents(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('❌ Ошибка синхронизации: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() { + _isSyncing = false; + }); + } + } + + List _getEventsForDay(DateTime day) { + return _events.where((event) { + return event.startTime.year == day.year && + event.startTime.month == day.month && + event.startTime.day == day.day; + }).toList(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -10,62 +147,248 @@ class CalendarScreen extends StatelessWidget { title: const Text('Календарь'), actions: [ IconButton( - icon: const Icon(Icons.sync), - onPressed: () { - // Синхронизация с Google Calendar - }, + icon: _isSyncing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync), + onPressed: _isSyncing ? null : () => _syncWithGoogleCalendar(), + tooltip: 'Синхронизировать с Google Calendar', + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showAddEventDialog(), + tooltip: 'Добавить событие', ), ], ), - body: Column( - children: [ - _buildCalendarHeader(), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: 5, - itemBuilder: (context, index) { - return _CalendarEventCard(index: index); - }, + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + TableCalendar( + firstDay: DateTime.utc(2020, 1, 1), + lastDay: DateTime.utc(2030, 12, 31), + focusedDay: _focusedDay, + selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + eventLoader: _getEventsForDay, + onDaySelected: (selectedDay, focusedDay) { + setState(() { + _selectedDay = selectedDay; + _focusedDay = focusedDay; + }); + }, + onFormatChanged: (format) { + // Обработка изменения формата + }, + onPageChanged: (focusedDay) { + _focusedDay = focusedDay; + _loadEvents(); + }, + calendarStyle: CalendarStyle( + outsideDaysVisible: false, + weekendTextStyle: const TextStyle(color: Colors.red), + holidayTextStyle: const TextStyle(color: Colors.red), + selectedDecoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + todayDecoration: BoxDecoration( + color: Colors.blue.withOpacity(0.3), + shape: BoxShape.circle, + ), + markerDecoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + ), + headerStyle: const HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + ), + ), + const SizedBox(height: 8), + const Divider(), + Expanded( + child: _buildEventList(), + ), + ], ), - ), - ], - ), floatingActionButton: FloatingActionButton( - onPressed: () {}, + onPressed: () => _showAddEventDialog(), child: const Icon(Icons.add), ), ); } - Widget _buildCalendarHeader() { - return Container( + Widget _buildEventList() { + final selectedEvents = _getEventsForDay(_selectedDay ?? _focusedDay); + + if (selectedEvents.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.event_busy, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + const Text( + 'Нет событий на этот день', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => _showAddEventDialog(), + icon: const Icon(Icons.add), + label: const Text('Добавить событие'), + ), + ], + ), + ); + } + + return ListView.builder( padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: () {}, + itemCount: selectedEvents.length, + itemBuilder: (context, index) { + final event = selectedEvents[index]; + return _EventCard(event: event); + }, + ); + } + + void _showAddEventDialog() { + final titleController = TextEditingController(); + final descriptionController = TextEditingController(); + final locationController = TextEditingController(); + + TimeOfDay _startTime = const TimeOfDay(hour: 10, minute: 0); + TimeOfDay _endTime = const TimeOfDay(hour: 11, minute: 0); + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('Новое событие'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Название', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.title), + ), + ), + const SizedBox(height: 16), + TextField( + controller: descriptionController, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Описание', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.description), + ), + ), + const SizedBox(height: 16), + TextField( + controller: locationController, + decoration: const InputDecoration( + labelText: 'Место', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_on), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ListTile( + title: const Text('Начало'), + subtitle: Text(_startTime.format(context)), + leading: const Icon(Icons.access_time), + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: _startTime, + ); + if (picked != null) { + setDialogState(() { + _startTime = picked; + }); + } + }, + ), + ), + Expanded( + child: ListTile( + title: const Text('Конец'), + subtitle: Text(_endTime.format(context)), + leading: const Icon(Icons.access_time), + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: _endTime, + ); + if (picked != null) { + setDialogState(() { + _endTime = picked; + }); + } + }, + ), + ), + ], + ), + ], + ), ), - const Text( - 'Март 2026', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - IconButton( - icon: const Icon(Icons.chevron_right), - onPressed: () {}, - ), - ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () async { + if (titleController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Введите название')), + ); + return; + } + + // TODO: Сохранить событие + Navigator.pop(context); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Событие создано'), + backgroundColor: Colors.green, + ), + ); + + await _loadEvents(); + }, + child: const Text('Создать'), + ), + ], + ), ), ); } } -class _CalendarEventCard extends StatelessWidget { - final int index; +class _EventCard extends StatelessWidget { + final CalendarEventModel event; - const _CalendarEventCard({required this.index}); + const _EventCard({required this.event}); @override Widget build(BuildContext context) { @@ -76,19 +399,63 @@ class _CalendarEventCard extends StatelessWidget { width: 4, height: 40, decoration: BoxDecoration( - color: _getEventColor(index), + color: _hexToColor(event.color), borderRadius: BorderRadius.circular(2), ), ), - title: Text('Событие #${index + 1}'), - subtitle: Text('10:00 - 11:00'), - trailing: const Icon(Icons.more_vert), + title: Text( + event.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + '${_formatTime(event.startTime)} - ${_formatTime(event.endTime)}', + style: const TextStyle(color: Colors.grey), + ), + if (event.location.isNotEmpty) ...[ + const SizedBox(height: 2), + Row( + children: [ + const Icon(Icons.location_on, size: 12, color: Colors.grey), + const SizedBox(width: 4), + Flexible( + child: Text( + event.location, + style: const TextStyle(color: Colors.grey, fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + trailing: PopupMenuButton( + onSelected: (value) { + // TODO: Действия с событием + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'edit', child: Text('Редактировать')), + const PopupMenuItem(value: 'delete', child: Text('Удалить')), + ], + ), + isThreeLine: true, ), ); } - Color _getEventColor(int index) { - final colors = [Colors.blue, Colors.green, Colors.orange, Colors.purple]; - return colors[index % colors.length]; + Color _hexToColor(String hex) { + hex = hex.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('0xFF$hex')); + } + return Colors.blue; + } + + String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; } } diff --git a/pubspec.lock b/pubspec.lock index ac7e578..b37349a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1096,6 +1096,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -1181,6 +1189,14 @@ packages: url: "https://pub.dev" source: hosted version: "25.2.7" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63 + url: "https://pub.dev" + source: hosted + version: "3.1.3" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b5a9ba7..17e7c59 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: # Google Calendar googleapis: ^13.2.0 googleapis_auth: ^1.6.0 + table_calendar: ^3.0.9 # AI Services http: ^1.2.0