Add Google Calendar integration with sync

This commit is contained in:
2026-03-27 20:01:20 +05:00
parent 26ef99d194
commit f1b887fa8d
4 changed files with 674 additions and 46 deletions
+244
View File
@@ -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<String> _scopes = [
gcal.CalendarApi.calendarEventsScope,
gcal.CalendarApi.calendarReadonlyScope,
];
String? _accessToken;
String? _refreshToken;
DateTime? _tokenExpiry;
gcal.CalendarApi? _calendarApi;
// Авторизация через OAuth2
Future<void> 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<List<CalendarEventModel>> 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<CalendarEventModel> createEvent({
required String title,
required DateTime startTime,
required DateTime endTime,
String? description,
String? location,
List<String>? 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<CalendarEventModel> 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<void> 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<Map<String, dynamic>> 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();
+404 -37
View File
@@ -1,8 +1,145 @@
import 'package:flutter/material.dart'; 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}); const CalendarScreen({super.key});
@override
ConsumerState<CalendarScreen> createState() => _CalendarScreenState();
}
class _CalendarScreenState extends ConsumerState<CalendarScreen> {
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
List<CalendarEventModel> _events = [];
bool _isLoading = false;
bool _isSyncing = false;
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
_loadEvents();
}
Future<void> _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<CalendarEventModel> _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<void> _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<CalendarEventModel> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -10,62 +147,248 @@ class CalendarScreen extends StatelessWidget {
title: const Text('Календарь'), title: const Text('Календарь'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.sync), icon: _isSyncing
onPressed: () { ? const SizedBox(
// Синхронизация с Google Calendar 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( body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [ children: [
_buildCalendarHeader(), TableCalendar(
Expanded( firstDay: DateTime.utc(2020, 1, 1),
child: ListView.builder( lastDay: DateTime.utc(2030, 12, 31),
padding: const EdgeInsets.all(16), focusedDay: _focusedDay,
itemCount: 5, selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
itemBuilder: (context, index) { eventLoader: _getEventsForDay,
return _CalendarEventCard(index: index); 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( floatingActionButton: FloatingActionButton(
onPressed: () {}, onPressed: () => _showAddEventDialog(),
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
); );
} }
Widget _buildCalendarHeader() { Widget _buildEventList() {
return Container( final selectedEvents = _getEventsForDay(_selectedDay ?? _focusedDay);
padding: const EdgeInsets.all(16),
child: Row( if (selectedEvents.isEmpty) {
mainAxisAlignment: MainAxisAlignment.spaceBetween, return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton( const Icon(
icon: const Icon(Icons.chevron_left), Icons.event_busy,
onPressed: () {}, size: 64,
color: Colors.grey,
), ),
const SizedBox(height: 16),
const Text( const Text(
'Март 2026', 'Нет событий на этот день',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: TextStyle(color: Colors.grey, fontSize: 16),
), ),
IconButton( const SizedBox(height: 8),
icon: const Icon(Icons.chevron_right), TextButton.icon(
onPressed: () {}, onPressed: () => _showAddEventDialog(),
icon: const Icon(Icons.add),
label: const Text('Добавить событие'),
), ),
], ],
), ),
); );
} }
return ListView.builder(
padding: const EdgeInsets.all(16),
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;
});
}
},
),
),
],
),
],
),
),
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 { class _EventCard extends StatelessWidget {
final int index; final CalendarEventModel event;
const _CalendarEventCard({required this.index}); const _EventCard({required this.event});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -76,19 +399,63 @@ class _CalendarEventCard extends StatelessWidget {
width: 4, width: 4,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getEventColor(index), color: _hexToColor(event.color),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
title: Text('Событие #${index + 1}'), title: Text(
subtitle: Text('10:00 - 11:00'), event.title,
trailing: const Icon(Icons.more_vert), 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) { Color _hexToColor(String hex) {
final colors = [Colors.blue, Colors.green, Colors.orange, Colors.purple]; hex = hex.replaceAll('#', '');
return colors[index % colors.length]; 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')}';
} }
} }
+16
View File
@@ -1096,6 +1096,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -1181,6 +1189,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "25.2.7" 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: term_glyph:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -30,6 +30,7 @@ dependencies:
# Google Calendar # Google Calendar
googleapis: ^13.2.0 googleapis: ^13.2.0
googleapis_auth: ^1.6.0 googleapis_auth: ^1.6.0
table_calendar: ^3.0.9
# AI Services # AI Services
http: ^1.2.0 http: ^1.2.0