Skip to content

Commit f93ab6a

Browse files
event creation and retrieval now handles url on android (#86)
1 parent 5b7717a commit f93ab6a

File tree

7 files changed

+234
-18
lines changed

7 files changed

+234
-18
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 1.0.2
2+
* **Android URL Support:** Added comprehensive URL handling for event creation and retrieval. Android Calendar API lacks native URL field support, so implemented intelligent description/URL merging system that preserves both fields while maintaining compatibility with existing calendar apps
3+
* **Enhanced Native-Only Example App:** Added prefill parameters toggle to demonstrate `createEventThroughNativePlatform()` flexibility
4+
* New configuration switch to enable/disable parameter pre-population
5+
* Demonstrates both empty form (no parameters) and prefilled form usage patterns
6+
* Dynamic UI that adapts button text and success messages based on toggle state
7+
* Improved user experience for testing different API usage scenarios
8+
19
## 1.0.1
210
* **Restored example app** as it was not appearing on pub.dev. Put the more complex examples in new `example/more-complex/` folder.
311

android/src/main/kotlin/sncf/connect/tech/eventide/CalendarImplem.kt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,12 @@ class CalendarImplem(
280280
CoroutineScope(Dispatchers.IO).launch {
281281
try {
282282
if (isCalendarWritable(calendarId)) {
283+
val mergedDescription = DescriptionUrlHelper.mergeDescriptionAndUrl(description, url)
284+
283285
val eventValues = ContentValues().apply {
284286
put(CalendarContract.Events.CALENDAR_ID, calendarId)
285287
put(CalendarContract.Events.TITLE, title)
286-
put(CalendarContract.Events.DESCRIPTION, description)
288+
put(CalendarContract.Events.DESCRIPTION, mergedDescription)
287289
put(CalendarContract.Events.DTSTART, startDate)
288290
put(CalendarContract.Events.DTEND, endDate)
289291
put(CalendarContract.Events.EVENT_TIMEZONE, "UTC")
@@ -316,6 +318,7 @@ class CalendarImplem(
316318
endDate = endDate,
317319
calendarId = calendarId,
318320
description = description,
321+
url = url,
319322
isAllDay = isAllDay,
320323
reminders = reminders ?: emptyList(),
321324
attendees = emptyList(),
@@ -380,13 +383,14 @@ class CalendarImplem(
380383
reminders: List<Long>?,
381384
callback: (Result<Unit>) -> Unit
382385
) {
386+
val mergedDescription = DescriptionUrlHelper.mergeDescriptionAndUrl(description, url)
383387
activityManager.startCreateEventActivity(
384388
eventContentUri = eventContentUri,
385389
title = title,
386390
startDate = startDate,
387391
endDate = endDate,
388392
isAllDay = isAllDay,
389-
description = description,
393+
description = mergedDescription,
390394
)
391395
callback(Result.success(Unit))
392396
}
@@ -402,13 +406,14 @@ class CalendarImplem(
402406
callback: (Result<Unit>) -> Unit
403407
) {
404408
try {
409+
val mergedDescription = DescriptionUrlHelper.mergeDescriptionAndUrl(description, url)
405410
activityManager.startCreateEventActivity(
406411
eventContentUri = eventContentUri,
407412
title = title,
408413
startDate = startDate,
409414
endDate = endDate,
410415
isAllDay = isAllDay,
411-
description = description,
416+
description = mergedDescription,
412417
)
413418
callback(Result.success(Unit))
414419
} catch (e: Exception) {
@@ -463,8 +468,9 @@ class CalendarImplem(
463468
while (c.moveToNext()) {
464469
val id = c.getString(c.getColumnIndexOrThrow(CalendarContract.Events._ID))
465470
val title = c.getString(c.getColumnIndexOrThrow(CalendarContract.Events.TITLE))
466-
val description =
471+
val storedDescription =
467472
c.getString(c.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION))
473+
val (parsedDescription, parsedUrl) = DescriptionUrlHelper.splitDescriptionAndUrl(storedDescription)
468474
val start = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTSTART))
469475
val end = c.getLong(c.getColumnIndexOrThrow(CalendarContract.Events.DTEND))
470476
val isAllDay = c.getInt(c.getColumnIndexOrThrow(CalendarContract.Events.ALL_DAY)).toBoolean()
@@ -503,7 +509,8 @@ class CalendarImplem(
503509
startDate = start,
504510
endDate = end,
505511
calendarId = calendarId,
506-
description = description,
512+
description = parsedDescription,
513+
url = parsedUrl,
507514
isAllDay = isAllDay,
508515
reminders = reminders,
509516
attendees = attendees
@@ -862,7 +869,8 @@ class CalendarImplem(
862869
if (it.moveToNext()) {
863870
val id = it.getString(it.getColumnIndexOrThrow(CalendarContract.Events._ID))
864871
val title = it.getString(it.getColumnIndexOrThrow(CalendarContract.Events.TITLE))
865-
val description = it.getString(it.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION))
872+
val storedDescription = it.getString(it.getColumnIndexOrThrow(CalendarContract.Events.DESCRIPTION))
873+
val (parsedDescription, parsedUrl) = DescriptionUrlHelper.splitDescriptionAndUrl(storedDescription)
866874
val isAllDay = it.getInt(it.getColumnIndexOrThrow(CalendarContract.Events.ALL_DAY)).toBoolean()
867875
val startDate = it.getLong(it.getColumnIndexOrThrow(CalendarContract.Events.DTSTART))
868876
val endDate = it.getLong(it.getColumnIndexOrThrow(CalendarContract.Events.DTEND))
@@ -901,7 +909,8 @@ class CalendarImplem(
901909
startDate = startDate,
902910
endDate = endDate,
903911
calendarId = calendarId,
904-
description = description,
912+
description = parsedDescription,
913+
url = parsedUrl,
905914
isAllDay = isAllDay,
906915
reminders = reminders,
907916
attendees = attendees
@@ -1020,3 +1029,4 @@ class CalendarImplem(
10201029
private fun Boolean.toInt() = if (this) 1 else 0
10211030

10221031
private fun Int.toBoolean() = this != 0
1032+
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package sncf.connect.tech.eventide
2+
3+
object DescriptionUrlHelper {
4+
fun mergeDescriptionAndUrl(description: String?, url: String?): String? {
5+
val desc = description?.trim().takeIf { !it.isNullOrBlank() }
6+
val u = url?.trim().takeIf { !it.isNullOrBlank() }
7+
return when {
8+
desc == null && u == null -> null
9+
desc == null -> u
10+
u == null -> desc
11+
else -> "${desc}\n\n${u}"
12+
}
13+
}
14+
15+
fun splitDescriptionAndUrl(stored: String?): Pair<String?, String?> {
16+
if (stored.isNullOrBlank()) return Pair(null, null)
17+
18+
val trimmedStored = stored.trimEnd()
19+
val urlPattern = Regex("^(https?://|mailto:|www\\.)\\S+$", RegexOption.IGNORE_CASE)
20+
21+
// 1) If there is a double newline, treat the part after it as potential URL
22+
val parts = trimmedStored.split(Regex("\\r?\\n\\s*\\r?\\n"), limit = 2)
23+
if (parts.size == 2) {
24+
val candidate = parts[1].trim()
25+
if (urlPattern.matches(candidate)) {
26+
val cleaned = stripTrailingPunctuation(candidate)
27+
val desc = parts[0].trim().ifBlank { null }
28+
return Pair(desc, cleaned)
29+
}
30+
}
31+
32+
// 2) Fallback: check last non-empty line
33+
val lines = trimmedStored.lines()
34+
val lastNonEmpty = lines.asReversed().firstOrNull { it.isNotBlank() }?.trim()
35+
if (lastNonEmpty != null && urlPattern.matches(lastNonEmpty)) {
36+
val cleaned = stripTrailingPunctuation(lastNonEmpty)
37+
val idx = trimmedStored.lastIndexOf(lastNonEmpty)
38+
val descPart = trimmedStored.substring(0, idx).trim().ifBlank { null }
39+
return Pair(descPart, cleaned)
40+
}
41+
42+
// 3) Nothing recognized as URL
43+
return Pair(trimmedStored.ifBlank { null }, null)
44+
}
45+
46+
// Remove common trailing punctuation that is likely not part of the URL when text is copied from a sentence.
47+
// Keeps characters that are commonly part of URLs (like '/', '?', '=', '&', '%', '#', '-')
48+
private fun stripTrailingPunctuation(candidate: String): String {
49+
val toStrip = setOf('.', ',', ';', ':', '!', '?', ')', ']', '}', '"', '\'')
50+
var cleaned = candidate
51+
while (cleaned.isNotEmpty() && cleaned.last() in toStrip) {
52+
cleaned = cleaned.dropLast(1)
53+
}
54+
return if (cleaned.isNotEmpty()) cleaned else candidate
55+
}
56+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package sncf.connect.tech.eventide
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertNull
6+
7+
class DescriptionUrlHelperTest {
8+
9+
@Test
10+
fun merge_bothProvided_returnsDescriptionAndUrlSeparated() {
11+
val desc = "Team meeting"
12+
val url = "https://meet.example/abc"
13+
val merged = DescriptionUrlHelper.mergeDescriptionAndUrl(desc, url)
14+
assertEquals("Team meeting\n\nhttps://meet.example/abc", merged)
15+
}
16+
17+
@Test
18+
fun merge_descriptionNull_returnsUrl() {
19+
val merged = DescriptionUrlHelper.mergeDescriptionAndUrl(null, "https://example.com")
20+
assertEquals("https://example.com", merged)
21+
}
22+
23+
@Test
24+
fun merge_urlNull_returnsDescription() {
25+
val merged = DescriptionUrlHelper.mergeDescriptionAndUrl("Simple text", null)
26+
assertEquals("Simple text", merged)
27+
}
28+
29+
@Test
30+
fun merge_bothBlank_returnsNull() {
31+
val merged = DescriptionUrlHelper.mergeDescriptionAndUrl(" ", " ")
32+
assertNull(merged)
33+
}
34+
35+
@Test
36+
fun split_doubleNewline_extractsUrl() {
37+
val stored = "Event details\n\nhttps://example.com/path"
38+
val (desc, url) = DescriptionUrlHelper.splitDescriptionAndUrl(stored)
39+
assertEquals("Event details", desc)
40+
assertEquals("https://example.com/path", url)
41+
}
42+
43+
@Test
44+
fun split_urlOnly_returnsUrlAndNullDescription() {
45+
val stored = "https://only.example"
46+
val (desc, url) = DescriptionUrlHelper.splitDescriptionAndUrl(stored)
47+
assertNull(desc)
48+
assertEquals("https://only.example", url)
49+
}
50+
51+
@Test
52+
fun split_lastLineUrl_withoutDoubleNewline_extractsUrl() {
53+
val stored = "First line\nhttps://last.example"
54+
val (desc, url) = DescriptionUrlHelper.splitDescriptionAndUrl(stored)
55+
assertEquals("First line", desc)
56+
assertEquals("https://last.example", url)
57+
}
58+
59+
@Test
60+
fun split_urlEmbeddedInText_doesNotExtract() {
61+
val stored = "Visit https://example.com for more information"
62+
val (desc, url) = DescriptionUrlHelper.splitDescriptionAndUrl(stored)
63+
assertEquals("Visit https://example.com for more information", desc)
64+
assertNull(url)
65+
}
66+
67+
@Test
68+
fun split_emptyOrBlank_returnsNulls() {
69+
val (d1, u1) = DescriptionUrlHelper.splitDescriptionAndUrl(null)
70+
assertNull(d1)
71+
assertNull(u1)
72+
73+
val (d2, u2) = DescriptionUrlHelper.splitDescriptionAndUrl(" ")
74+
assertNull(d2)
75+
assertNull(u2)
76+
}
77+
78+
@Test
79+
fun split_urlWithTrailingPunctuation_stripsPunctuation() {
80+
val stored = "Note\n\nhttps://example.com/page."
81+
val (desc, url) = DescriptionUrlHelper.splitDescriptionAndUrl(stored)
82+
assertEquals("Note", desc)
83+
// trailing '.' should be removed by the helper
84+
assertEquals("https://example.com/page", url)
85+
}
86+
}

android/src/test/kotlin/sncf/connect/tech/eventide/EventTests.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ class EventTests {
485485
startDate = startMilli,
486486
endDate = endMilli,
487487
isAllDay = false,
488-
description = "Test Description"
488+
description = "Test Description\n\nhttps://example.com"
489489
)
490490
}
491491

example/more-complex/native-only/lib/main.dart

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// ignore_for_file: deprecated_member_use
12
import 'package:eventide/eventide.dart';
23
import 'package:flutter/material.dart';
34

@@ -22,10 +23,16 @@ final class MyApp extends StatelessWidget {
2223
}
2324
}
2425

25-
class NativeOnlyDemoPage extends StatelessWidget {
26-
final Eventide eventide = Eventide();
26+
class NativeOnlyDemoPage extends StatefulWidget {
27+
const NativeOnlyDemoPage({super.key});
2728

28-
NativeOnlyDemoPage({super.key});
29+
@override
30+
State<NativeOnlyDemoPage> createState() => _NativeOnlyDemoPageState();
31+
}
32+
33+
class _NativeOnlyDemoPageState extends State<NativeOnlyDemoPage> {
34+
final Eventide eventide = Eventide();
35+
bool _prefillParameters = false;
2936

3037
@override
3138
Widget build(BuildContext context) {
@@ -118,16 +125,46 @@ class NativeOnlyDemoPage extends StatelessWidget {
118125
),
119126
),
120127
const SizedBox(height: 24),
128+
Card(
129+
child: Padding(
130+
padding: const EdgeInsets.all(16.0),
131+
child: Column(
132+
crossAxisAlignment: CrossAxisAlignment.start,
133+
children: [
134+
Text(
135+
'Configuration:',
136+
style: Theme.of(context).textTheme.titleMedium?.copyWith(
137+
fontWeight: FontWeight.bold,
138+
),
139+
),
140+
const SizedBox(height: 12),
141+
SwitchListTile(
142+
title: const Text('Prefill Parameters'),
143+
subtitle: const Text('Pre-populate the form with sample values'),
144+
value: _prefillParameters,
145+
onChanged: (bool value) {
146+
setState(() {
147+
_prefillParameters = value;
148+
});
149+
},
150+
activeColor: Colors.purple,
151+
),
152+
],
153+
),
154+
),
155+
),
156+
const SizedBox(height: 24),
121157
Center(
122158
child: SizedBox(
123159
width: double.infinity,
124160
height: 80,
125161
child: ElevatedButton.icon(
126162
onPressed: () => _createEventThroughNativeUI(context),
127163
icon: const Icon(Icons.smartphone, size: 28),
128-
label: const Text(
129-
'Open Native Event Creator',
130-
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
164+
label: Text(
165+
_prefillParameters ? 'Open with Prefilled Parameters' : 'Open Native Event Creator',
166+
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
167+
textAlign: TextAlign.center,
131168
),
132169
style: ElevatedButton.styleFrom(
133170
backgroundColor: Colors.purple,
@@ -160,6 +197,7 @@ class NativeOnlyDemoPage extends StatelessWidget {
160197
_buildSimpleFeature('✅ Works on both iOS and Android'),
161198
_buildSimpleFeature('🎨 Matches system UI design'),
162199
_buildSimpleFeature('⚡ Instant access, no setup required'),
200+
_buildSimpleFeature('⚙️ Optional parameters: empty form or prefilled'),
163201
],
164202
),
165203
),
@@ -209,12 +247,30 @@ class NativeOnlyDemoPage extends StatelessWidget {
209247

210248
void _createEventThroughNativeUI(BuildContext context) async {
211249
try {
212-
await eventide.createEventThroughNativePlatform();
250+
if (_prefillParameters) {
251+
final now = DateTime.now();
252+
await eventide.createEventThroughNativePlatform(
253+
title: 'Sample Event',
254+
startDate: now.add(const Duration(hours: 1)),
255+
endDate: now.add(const Duration(hours: 2)),
256+
isAllDay: false,
257+
description: 'This is an event created from the Eventide app with prefilled parameters.',
258+
url: 'https://example.com',
259+
reminders: [
260+
const Duration(minutes: 15),
261+
const Duration(minutes: 5),
262+
],
263+
);
264+
} else {
265+
await eventide.createEventThroughNativePlatform();
266+
}
213267

214268
if (context.mounted) {
215269
ScaffoldMessenger.of(context).showSnackBar(
216-
const SnackBar(
217-
content: Text('Event created successfully through native UI!'),
270+
SnackBar(
271+
content: Text(_prefillParameters
272+
? 'Event with prefilled parameters created successfully!'
273+
: 'Native interface opened with empty form!'),
218274
backgroundColor: Colors.green,
219275
behavior: SnackBarBehavior.floating,
220276
),

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: eventide
22
description: "Provides a easy-to-use flutter interface to access & modify native device calendars (iOS & Android)"
3-
version: 1.0.1
3+
version: 1.0.2
44
repository: https://github.com/sncf-connect-tech/eventide
55
topics:
66
- flutter

0 commit comments

Comments
 (0)