Add Google calendar API and workspace sync
This commit is contained in:
@@ -0,0 +1,621 @@
|
||||
part of 'main.dart';
|
||||
|
||||
class AccountsPage extends StatefulWidget {
|
||||
const AccountsPage({
|
||||
super.key,
|
||||
required this.connections,
|
||||
required this.cachedEvents,
|
||||
required this.people,
|
||||
required this.onChanged,
|
||||
required this.onSyncedEventsChanged,
|
||||
});
|
||||
|
||||
final List<GoogleCalendarConnection> connections;
|
||||
final List<CalendarEvent> cachedEvents;
|
||||
final List<Person> people;
|
||||
final ValueChanged<List<GoogleCalendarConnection>> onChanged;
|
||||
final ValueChanged<List<CalendarEvent>> onSyncedEventsChanged;
|
||||
|
||||
@override
|
||||
State<AccountsPage> createState() => _AccountsPageState();
|
||||
}
|
||||
|
||||
class _AccountsPageState extends State<AccountsPage> {
|
||||
static const calendarScopes = [
|
||||
'https://www.googleapis.com/auth/calendar.readonly',
|
||||
];
|
||||
|
||||
final googleSignIn = GoogleSignIn.instance;
|
||||
List<GoogleCalendarInfo> googleCalendars = [];
|
||||
GoogleSignInAccount? signedInAccount;
|
||||
bool initialized = false;
|
||||
bool loadingCalendars = false;
|
||||
bool syncing = false;
|
||||
String? statusMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeGoogle();
|
||||
}
|
||||
|
||||
GoogleCalendarConnection? get connection =>
|
||||
widget.connections.isEmpty ? null : widget.connections.first;
|
||||
|
||||
Future<void> _initializeGoogle() async {
|
||||
setState(() {
|
||||
loadingCalendars = true;
|
||||
statusMessage = 'Google kapcsolat el\u0151k\u00e9sz\u00edt\u00e9se...';
|
||||
});
|
||||
|
||||
try {
|
||||
await googleSignIn.initialize();
|
||||
initialized = true;
|
||||
final lightweight = googleSignIn.attemptLightweightAuthentication();
|
||||
final account = lightweight == null ? null : await lightweight;
|
||||
if (!mounted) return;
|
||||
if (account != null) {
|
||||
signedInAccount = account;
|
||||
await _loadGoogleCalendars(promptIfNecessary: false);
|
||||
} else {
|
||||
setState(() {
|
||||
loadingCalendars = false;
|
||||
statusMessage =
|
||||
'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rak szinkroniz\u00e1ljanak.';
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
loadingCalendars = false;
|
||||
statusMessage = 'Google inicializ\u00e1l\u00e1s nem siker\u00fclt: $error';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _googleHeaders({
|
||||
bool promptIfNecessary = true,
|
||||
}) async {
|
||||
final account = signedInAccount;
|
||||
final headers = account == null
|
||||
? await googleSignIn.authorizationClient.authorizationHeaders(
|
||||
calendarScopes,
|
||||
promptIfNecessary: promptIfNecessary,
|
||||
)
|
||||
: await account.authorizationClient.authorizationHeaders(
|
||||
calendarScopes,
|
||||
promptIfNecessary: promptIfNecessary,
|
||||
);
|
||||
if (headers == null) {
|
||||
throw Exception('A Google Calendar jogosults\u00e1g nincs megadva.');
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
Future<void> _signInGoogle() async {
|
||||
setState(() {
|
||||
loadingCalendars = true;
|
||||
statusMessage = 'Google bejelentkez\u00e9s...';
|
||||
});
|
||||
|
||||
try {
|
||||
if (!initialized) {
|
||||
await googleSignIn.initialize();
|
||||
initialized = true;
|
||||
}
|
||||
final account = await googleSignIn.authenticate(scopeHint: calendarScopes);
|
||||
signedInAccount = account;
|
||||
await _loadGoogleCalendars(promptIfNecessary: true);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
loadingCalendars = false;
|
||||
statusMessage = 'Google bejelentkez\u00e9s nem siker\u00fclt: $error';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadGoogleCalendars({bool promptIfNecessary = true}) async {
|
||||
setState(() {
|
||||
loadingCalendars = true;
|
||||
statusMessage = 'Google napt\u00e1rak lek\u00e9r\u00e9se...';
|
||||
});
|
||||
|
||||
try {
|
||||
final headers = await _googleHeaders(
|
||||
promptIfNecessary: promptIfNecessary,
|
||||
);
|
||||
final uri = Uri.https(
|
||||
'www.googleapis.com',
|
||||
'/calendar/v3/users/me/calendarList',
|
||||
{'minAccessRole': 'reader', 'showHidden': 'true'},
|
||||
);
|
||||
final response = await http
|
||||
.get(uri, headers: headers)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
'CalendarList API ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
|
||||
final payload = jsonDecode(response.body);
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
throw Exception('Hib\u00e1s Google Calendar v\u00e1lasz.');
|
||||
}
|
||||
final items = payload['items'];
|
||||
final calendars = items is List
|
||||
? items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(GoogleCalendarInfo.fromJson)
|
||||
.toList()
|
||||
: <GoogleCalendarInfo>[];
|
||||
calendars.sort((a, b) {
|
||||
if (a.primary != b.primary) return a.primary ? -1 : 1;
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
|
||||
final account = signedInAccount;
|
||||
final current = connection;
|
||||
if (account != null) {
|
||||
widget.onChanged([
|
||||
GoogleCalendarConnection(
|
||||
id: current?.id ?? uniqueId(),
|
||||
accountEmail: account.email,
|
||||
enabledCalendarIds: current?.enabledCalendarIds ?? const [],
|
||||
calendarPersonIds: current?.calendarPersonIds ?? const {},
|
||||
lastSync: current?.lastSync,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
googleCalendars = calendars;
|
||||
loadingCalendars = false;
|
||||
statusMessage = calendars.isEmpty
|
||||
? 'A Google fi\u00f3kban nem tal\u00e1ltam olvashat\u00f3 napt\u00e1rat.'
|
||||
: '${calendars.length} Google napt\u00e1r el\u00e9rhet\u0151. V\u00e1laszd ki, melyik ker\u00fclj\u00f6n be a csal\u00e1di napt\u00e1rba.';
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
loadingCalendars = false;
|
||||
statusMessage =
|
||||
'Google napt\u00e1rlista lek\u00e9r\u00e9se nem siker\u00fclt: $error';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncSelectedCalendars() async {
|
||||
final current = connection;
|
||||
if (current == null || current.enabledCalendarIds.isEmpty) {
|
||||
setState(
|
||||
() => statusMessage =
|
||||
'V\u00e1lassz ki legal\u00e1bb egy Google napt\u00e1rat.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
syncing = true;
|
||||
statusMessage = 'Google esem\u00e9nyek beolvas\u00e1sa...';
|
||||
});
|
||||
|
||||
try {
|
||||
if (googleCalendars.isEmpty) {
|
||||
await _loadGoogleCalendars(promptIfNecessary: true);
|
||||
}
|
||||
final headers = await _googleHeaders(promptIfNecessary: true);
|
||||
final selectedCalendars = googleCalendars
|
||||
.where((calendar) => current.enabledCalendarIds.contains(calendar.id))
|
||||
.toList();
|
||||
final synced = <CalendarEvent>[];
|
||||
final syncDetails = <String>[];
|
||||
final now = DateTime.now();
|
||||
final start = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).subtract(const Duration(days: 30));
|
||||
final end = start.add(const Duration(days: 180));
|
||||
|
||||
for (final calendar in selectedCalendars) {
|
||||
final uri = Uri.https(
|
||||
'www.googleapis.com',
|
||||
'/calendar/v3/calendars/${Uri.encodeComponent(calendar.id)}/events',
|
||||
{
|
||||
'singleEvents': 'true',
|
||||
'orderBy': 'startTime',
|
||||
'timeMin': start.toUtc().toIso8601String(),
|
||||
'timeMax': end.toUtc().toIso8601String(),
|
||||
'maxResults': '2500',
|
||||
},
|
||||
);
|
||||
final response = await http
|
||||
.get(uri, headers: headers)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('${calendar.name}: Events API ${response.statusCode}');
|
||||
}
|
||||
|
||||
final payload = jsonDecode(response.body);
|
||||
if (payload is! Map<String, dynamic>) continue;
|
||||
final rawEvents = payload['items'];
|
||||
final events = rawEvents is List
|
||||
? rawEvents.whereType<Map<String, dynamic>>().toList()
|
||||
: <Map<String, dynamic>>[];
|
||||
syncDetails.add('${calendar.name}: ${events.length}');
|
||||
for (final event in events) {
|
||||
if (event['status'] == 'cancelled') continue;
|
||||
synced.add(
|
||||
CalendarEvent.fromGoogleEvent(
|
||||
event,
|
||||
calendar,
|
||||
personId: current.calendarPersonIds[calendar.id],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
synced.sort((a, b) {
|
||||
final dateCompare = a.date.compareTo(b.date);
|
||||
return dateCompare != 0 ? dateCompare : a.start.compareTo(b.start);
|
||||
});
|
||||
|
||||
widget.onSyncedEventsChanged(synced);
|
||||
widget.onChanged([current.copyWith(lastSync: DateTime.now())]);
|
||||
setState(() {
|
||||
syncing = false;
|
||||
statusMessage =
|
||||
'${synced.length} Google esem\u00e9ny szinkroniz\u00e1lva \u00e9s JSON cache-be mentve.'
|
||||
'${syncDetails.isEmpty ? '' : ' R\u00e9szletek: ${syncDetails.join('; ')}.'}';
|
||||
});
|
||||
} catch (error) {
|
||||
setState(() {
|
||||
syncing = false;
|
||||
statusMessage = 'A Google szinkron nem siker\u00fclt: $error';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = connection;
|
||||
final hasConnection = current != null && current.accountEmail.isNotEmpty;
|
||||
final cachedUncategorized = widget.cachedEvents
|
||||
.where((event) => event.isUncategorized)
|
||||
.length;
|
||||
|
||||
return PageFrame(
|
||||
eyebrow: 'Google Calendar API szinkron',
|
||||
title: 'Google napt\u00e1rak',
|
||||
action: FilledButton.icon(
|
||||
onPressed: loadingCalendars ? null : _signInGoogle,
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(
|
||||
hasConnection ? 'Fi\u00f3k v\u00e1lt\u00e1sa' : 'Google bel\u00e9p\u00e9s',
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
GoogleSyncHero(
|
||||
accountEmail: current?.accountEmail,
|
||||
enabledCount: current?.enabledCalendarIds.length ?? 0,
|
||||
cachedCount: widget.cachedEvents.length,
|
||||
uncategorizedCount: cachedUncategorized,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
if (!hasConnection)
|
||||
const EmptyGoogleConnectionCard()
|
||||
else ...[
|
||||
const Text(
|
||||
'Google Calendar API-b\u00f3l el\u00e9rhet\u0151 napt\u00e1rak',
|
||||
style: TextStyle(
|
||||
color: ink,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (loadingCalendars)
|
||||
const LinearProgressIndicator()
|
||||
else
|
||||
...googleCalendars.map(
|
||||
(calendar) => GoogleCalendarToggleCard(
|
||||
calendar: calendar,
|
||||
enabled: current.enabledCalendarIds.contains(calendar.id),
|
||||
people: widget.people,
|
||||
selectedPersonId: current.calendarPersonIds[calendar.id],
|
||||
onChanged: (enabled) {
|
||||
final nextIds = [...current.enabledCalendarIds];
|
||||
final nextPersonIds = {...current.calendarPersonIds};
|
||||
if (enabled) {
|
||||
if (!nextIds.contains(calendar.id)) {
|
||||
nextIds.add(calendar.id);
|
||||
}
|
||||
} else {
|
||||
nextIds.remove(calendar.id);
|
||||
nextPersonIds.remove(calendar.id);
|
||||
}
|
||||
widget.onChanged([
|
||||
current.copyWith(
|
||||
enabledCalendarIds: nextIds,
|
||||
calendarPersonIds: nextPersonIds,
|
||||
lastSync: current.lastSync,
|
||||
),
|
||||
]);
|
||||
},
|
||||
onPersonChanged: (personId) {
|
||||
final nextPersonIds = {...current.calendarPersonIds};
|
||||
if (personId == null) {
|
||||
nextPersonIds.remove(calendar.id);
|
||||
} else {
|
||||
nextPersonIds[calendar.id] = personId;
|
||||
}
|
||||
widget.onChanged([
|
||||
current.copyWith(
|
||||
calendarPersonIds: nextPersonIds,
|
||||
lastSync: current.lastSync,
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: syncing ? null : _syncSelectedCalendars,
|
||||
icon: syncing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync),
|
||||
label: Text(
|
||||
syncing
|
||||
? 'Szinkroniz\u00e1l\u00e1s...'
|
||||
: 'Szinkroniz\u00e1l\u00e1s most',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: loadingCalendars
|
||||
? null
|
||||
: () => _loadGoogleCalendars(promptIfNecessary: true),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Google napt\u00e1rlista friss\u00edt\u00e9se'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await googleSignIn.disconnect();
|
||||
widget.onChanged([]);
|
||||
widget.onSyncedEventsChanged([]);
|
||||
setState(() {
|
||||
signedInAccount = null;
|
||||
googleCalendars = [];
|
||||
statusMessage = 'Google kapcsolat lev\u00e1lasztva.';
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.link_off),
|
||||
label: const Text('Google kapcsolat lev\u00e1laszt\u00e1sa'),
|
||||
),
|
||||
],
|
||||
if (statusMessage != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Text(statusMessage!, style: const TextStyle(color: Colors.black54)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: mint,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: sage),
|
||||
SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ez a szinkron egyir\u00e1ny\u00fa: a Google Calendar API-b\u00f3l olvas, az esem\u00e9nyeket JSON cache-be menti, \u00e9s nem \u00edr vissza Google napt\u00e1rba. Offline \u00e1llapotban a kor\u00e1bban beolvasott cache marad l\u00e1that\u00f3.',
|
||||
style: TextStyle(color: ink, height: 1.45),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleSyncHero extends StatelessWidget {
|
||||
const GoogleSyncHero({
|
||||
super.key,
|
||||
required this.accountEmail,
|
||||
required this.enabledCount,
|
||||
required this.cachedCount,
|
||||
required this.uncategorizedCount,
|
||||
});
|
||||
|
||||
final String? accountEmail;
|
||||
final int enabledCount;
|
||||
final int cachedCount;
|
||||
final int uncategorizedCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connected = accountEmail != null && accountEmail!.isNotEmpty;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(22),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF18332D), Color(0xFF2F6B5B)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: sage.withValues(alpha: .22),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.g_mobiledata_rounded,
|
||||
color: Colors.white,
|
||||
size: 44,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
connected
|
||||
? accountEmail!
|
||||
: 'M\u00e9g nincs Google napt\u00e1rkapcsolat',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
connected
|
||||
? '$enabledCount napt\u00e1r bekapcsolva \u00b7 $cachedCount esem\u00e9ny cache-ben \u00b7 $uncategorizedCount kategoriz\u00e1latlan'
|
||||
: 'Jelentkezz be Google fi\u00f3kkal, majd v\u00e1laszd ki, mely napt\u00e1rakb\u00f3l hozzon esem\u00e9nyeket.',
|
||||
style: const TextStyle(color: Colors.white70, height: 1.35),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyGoogleConnectionCard extends StatelessWidget {
|
||||
const EmptyGoogleConnectionCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.calendar_month_outlined, color: sage),
|
||||
SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Google bel\u00e9p\u00e9s ut\u00e1n az app k\u00f6zvetlen\u00fcl a Google Calendar API-b\u00f3l k\u00e9ri le a napt\u00e1rakat. Android rendszer-napt\u00e1r szinkron m\u00e1r nem kell hozz\u00e1.',
|
||||
style: TextStyle(color: ink, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleCalendarToggleCard extends StatelessWidget {
|
||||
const GoogleCalendarToggleCard({
|
||||
super.key,
|
||||
required this.calendar,
|
||||
required this.enabled,
|
||||
required this.people,
|
||||
required this.selectedPersonId,
|
||||
required this.onChanged,
|
||||
required this.onPersonChanged,
|
||||
});
|
||||
|
||||
final GoogleCalendarInfo calendar;
|
||||
final bool enabled;
|
||||
final List<Person> people;
|
||||
final String? selectedPersonId;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final ValueChanged<String?> onPersonChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
value: enabled,
|
||||
activeThumbColor: calendar.color,
|
||||
onChanged: onChanged,
|
||||
secondary: CircleAvatar(
|
||||
backgroundColor: calendar.color.withValues(alpha: .14),
|
||||
child: Icon(
|
||||
calendar.primary ? Icons.star_rounded : Icons.calendar_today,
|
||||
color: calendar.color,
|
||||
size: 19,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
calendar.name,
|
||||
style: const TextStyle(color: ink, fontWeight: FontWeight.w800),
|
||||
),
|
||||
subtitle: Text(
|
||||
calendar.description.isEmpty
|
||||
? calendar.id
|
||||
: '${calendar.description} \u00b7 ${calendar.id}',
|
||||
),
|
||||
),
|
||||
if (enabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 0),
|
||||
child: DropdownButtonFormField<String?>(
|
||||
initialValue: selectedPersonId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kihez tartozzanak az esem\u00e9nyek?',
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('Kategoriz\u00e1latlan'),
|
||||
),
|
||||
const DropdownMenuItem<String?>(
|
||||
value: everyonePersonId,
|
||||
child: Text('Mindenki'),
|
||||
),
|
||||
...people.map(
|
||||
(person) => DropdownMenuItem<String?>(
|
||||
value: person.id,
|
||||
child: Text('${person.avatar} ${person.name}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: onPersonChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user