react-native-platform-components 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +20 -0
  2. package/PlatformComponents.podspec +20 -0
  3. package/README.md +233 -0
  4. package/android/build.gradle +78 -0
  5. package/android/gradle.properties +5 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/platformcomponents/DateConstraints.kt +83 -0
  8. package/android/src/main/java/com/platformcomponents/Helper.kt +27 -0
  9. package/android/src/main/java/com/platformcomponents/PCDatePickerView.kt +684 -0
  10. package/android/src/main/java/com/platformcomponents/PCDatePickerViewManager.kt +149 -0
  11. package/android/src/main/java/com/platformcomponents/PCMaterialMode.kt +16 -0
  12. package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +410 -0
  13. package/android/src/main/java/com/platformcomponents/PCSelectionMenuViewManager.kt +114 -0
  14. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +22 -0
  15. package/ios/PCDatePicker.h +11 -0
  16. package/ios/PCDatePicker.mm +248 -0
  17. package/ios/PCDatePickerView.swift +405 -0
  18. package/ios/PCSelectionMenu.h +10 -0
  19. package/ios/PCSelectionMenu.mm +182 -0
  20. package/ios/PCSelectionMenu.swift +434 -0
  21. package/lib/module/DatePicker.js +74 -0
  22. package/lib/module/DatePicker.js.map +1 -0
  23. package/lib/module/DatePickerNativeComponent.ts +68 -0
  24. package/lib/module/SelectionMenu.js +79 -0
  25. package/lib/module/SelectionMenu.js.map +1 -0
  26. package/lib/module/SelectionMenu.web.js +57 -0
  27. package/lib/module/SelectionMenu.web.js.map +1 -0
  28. package/lib/module/SelectionMenuNativeComponent.ts +106 -0
  29. package/lib/module/index.js +6 -0
  30. package/lib/module/index.js.map +1 -0
  31. package/lib/module/package.json +1 -0
  32. package/lib/module/sharedTypes.js +4 -0
  33. package/lib/module/sharedTypes.js.map +1 -0
  34. package/lib/typescript/package.json +1 -0
  35. package/lib/typescript/src/DatePicker.d.ts +38 -0
  36. package/lib/typescript/src/DatePicker.d.ts.map +1 -0
  37. package/lib/typescript/src/DatePickerNativeComponent.d.ts +53 -0
  38. package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +1 -0
  39. package/lib/typescript/src/SelectionMenu.d.ts +50 -0
  40. package/lib/typescript/src/SelectionMenu.d.ts.map +1 -0
  41. package/lib/typescript/src/SelectionMenu.web.d.ts +19 -0
  42. package/lib/typescript/src/SelectionMenu.web.d.ts.map +1 -0
  43. package/lib/typescript/src/SelectionMenuNativeComponent.d.ts +85 -0
  44. package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +1 -0
  45. package/lib/typescript/src/index.d.ts +4 -0
  46. package/lib/typescript/src/index.d.ts.map +1 -0
  47. package/lib/typescript/src/sharedTypes.d.ts +10 -0
  48. package/lib/typescript/src/sharedTypes.d.ts.map +1 -0
  49. package/package.json +178 -0
  50. package/shared/PCDatePickerComponentDescriptors-custom.h +52 -0
  51. package/shared/PCDatePickerShadowNode-custom.cpp +1 -0
  52. package/shared/PCDatePickerShadowNode-custom.h +27 -0
  53. package/shared/PCDatePickerState-custom.h +13 -0
  54. package/shared/PCSelectionMenuComponentDescriptors-custom.h +25 -0
  55. package/shared/PCSelectionMenuShadowNode-custom.cpp +36 -0
  56. package/shared/PCSelectionMenuShadowNode-custom.h +46 -0
  57. package/src/DatePicker.tsx +146 -0
  58. package/src/DatePickerNativeComponent.ts +68 -0
  59. package/src/SelectionMenu.tsx +170 -0
  60. package/src/SelectionMenu.web.tsx +93 -0
  61. package/src/SelectionMenuNativeComponent.ts +106 -0
  62. package/src/index.tsx +3 -0
  63. package/src/sharedTypes.ts +14 -0
@@ -0,0 +1,684 @@
1
+ package com.platformcomponents
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.ContextWrapper
6
+ import android.content.DialogInterface
7
+ import android.os.Build
8
+ import android.view.Gravity
9
+ import android.view.ViewGroup
10
+ import android.widget.DatePicker
11
+ import android.widget.FrameLayout
12
+ import android.widget.LinearLayout
13
+ import android.widget.TimePicker
14
+ import androidx.appcompat.app.AlertDialog
15
+ import androidx.fragment.app.FragmentActivity
16
+ import com.facebook.react.uimanager.ThemedReactContext
17
+ import com.google.android.material.datepicker.CalendarConstraints
18
+ import com.google.android.material.datepicker.MaterialDatePicker
19
+ import com.google.android.material.timepicker.MaterialTimePicker
20
+ import com.google.android.material.timepicker.TimeFormat
21
+ import java.util.Calendar
22
+ import java.util.Locale
23
+ import java.util.TimeZone
24
+ import kotlin.math.max
25
+ import kotlin.math.min
26
+
27
+ class PCDatePickerView(context: Context) : FrameLayout(context) {
28
+
29
+ // --- Public props (set by manager) ---
30
+ private var mode: String = "date" // "date" | "time" | "dateAndTime"
31
+ private var presentation: String = "modal" // "inline" | "modal" | "popover" | "sheet" | "auto" (we treat non-inline as modal-ish)
32
+ private var visible: String = "closed" // "open" | "closed" (only for non-inline)
33
+ private var locale: Locale? = null
34
+ private var timeZone: TimeZone = TimeZone.getDefault()
35
+
36
+ private var dateMs: Long? = null
37
+ private var minDateMs: Long? = null
38
+ private var maxDateMs: Long? = null
39
+
40
+ // --- Android config from nested `android` prop ---
41
+ private var androidFirstDayOfWeek: Int? = null
42
+ private var androidMaterialMode: PCMaterialMode = PCMaterialMode.SYSTEM // SYSTEM | M3
43
+ private var androidDialogTitle: String? = null
44
+ private var androidPositiveTitle: String? = null
45
+ private var androidNegativeTitle: String? = null
46
+
47
+ // --- Events (wired by manager) ---
48
+ var onConfirm: ((Long) -> Unit)? = null
49
+ var onCancel: (() -> Unit)? = null
50
+
51
+ // --- Inline UI ---
52
+ private var inlineContainer: LinearLayout? = null
53
+ private var inlineDatePicker: DatePicker? = null
54
+ private var inlineTimePicker: TimePicker? = null
55
+ private var suppressInlineCallbacks = false
56
+
57
+ // --- Modal state ---
58
+ private var showingModal = false
59
+
60
+ init {
61
+ minimumHeight = 0
62
+ minimumWidth = 0
63
+ rebuildUI()
64
+ }
65
+
66
+ // Headless layout when not inline
67
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
68
+ if (!isInline()) {
69
+ setMeasuredDimension(0, 0)
70
+ return
71
+ }
72
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
73
+ }
74
+
75
+ // -----------------------------
76
+ // Manager-facing apply* methods
77
+ // -----------------------------
78
+
79
+ fun applyMode(value: String?) {
80
+ mode = when (value) {
81
+ "date", "time", "dateAndTime" -> value
82
+ else -> "date"
83
+ }
84
+ rebuildUI()
85
+ }
86
+
87
+ fun applyPresentation(value: String?) {
88
+ presentation = value ?: "modal"
89
+ rebuildUI()
90
+ // If we were showing and presentation changed, we’ll let JS drive visible again.
91
+ }
92
+
93
+ fun applyVisible(value: String?) {
94
+ visible = when (value) {
95
+ "open", "closed" -> value
96
+ else -> "closed"
97
+ }
98
+ if (isInline()) return
99
+
100
+ if (visible == "open") presentIfNeeded() else dismissIfNeeded()
101
+ }
102
+
103
+ fun applyLocale(value: String?) {
104
+ locale =
105
+ try {
106
+ if (value.isNullOrBlank()) null else Locale.forLanguageTag(value)
107
+ } catch (_: Throwable) {
108
+ null
109
+ }
110
+ // Inline pickers don’t render strings; no-op other than storing.
111
+ }
112
+
113
+ fun applyTimeZoneName(value: String?) {
114
+ timeZone =
115
+ try {
116
+ if (value.isNullOrBlank()) TimeZone.getDefault() else TimeZone.getTimeZone(value)
117
+ } catch (_: Throwable) {
118
+ TimeZone.getDefault()
119
+ }
120
+ // Update inline display
121
+ syncInlineFromState()
122
+ }
123
+
124
+ fun applyDateMs(value: Long?) {
125
+ dateMs = value
126
+ syncInlineFromState()
127
+ }
128
+
129
+ fun applyMinDateMs(value: Long?) {
130
+ minDateMs = value
131
+ // clamp if needed
132
+ dateMs = clamp(dateMs ?: System.currentTimeMillis())
133
+ syncInlineFromState()
134
+ }
135
+
136
+ fun applyMaxDateMs(value: Long?) {
137
+ maxDateMs = value
138
+ // clamp if needed
139
+ dateMs = clamp(dateMs ?: System.currentTimeMillis())
140
+ syncInlineFromState()
141
+ }
142
+
143
+ /**
144
+ * REQUIRED by your manager (nested `android` object).
145
+ * Only supports material: "system" | "m3"
146
+ */
147
+ fun applyAndroidConfig(
148
+ firstDayOfWeek: Int?,
149
+ material: String?,
150
+ dialogTitle: String?,
151
+ positiveButtonTitle: String?,
152
+ negativeButtonTitle: String?
153
+ ) {
154
+ androidFirstDayOfWeek = firstDayOfWeek
155
+
156
+ androidMaterialMode = when (material) {
157
+ "m3" -> PCMaterialMode.M3
158
+ "system", null -> PCMaterialMode.SYSTEM
159
+ else -> PCMaterialMode.SYSTEM
160
+ }
161
+
162
+ androidDialogTitle = dialogTitle
163
+ androidPositiveTitle = positiveButtonTitle
164
+ androidNegativeTitle = negativeButtonTitle
165
+
166
+ // Inline date picker can use firstDayOfWeek in its internal Calendar calculations
167
+ syncInlineFromState()
168
+ }
169
+
170
+ // -----------------------------
171
+ // UI construction
172
+ // -----------------------------
173
+
174
+ private fun isInline(): Boolean = presentation == "inline"
175
+
176
+ private fun rebuildUI() {
177
+ removeAllViews()
178
+ inlineContainer = null
179
+ inlineDatePicker = null
180
+ inlineTimePicker = null
181
+
182
+ if (!isInline()) {
183
+ requestLayout()
184
+ return
185
+ }
186
+
187
+ val container = LinearLayout(context).apply {
188
+ layoutParams = LayoutParams(
189
+ ViewGroup.LayoutParams.MATCH_PARENT,
190
+ ViewGroup.LayoutParams.WRAP_CONTENT
191
+ )
192
+ orientation = LinearLayout.VERTICAL
193
+ gravity = Gravity.CENTER_VERTICAL
194
+ }
195
+
196
+ // date and/or time
197
+ if (mode == "date" || mode == "dateAndTime") {
198
+ val dp = DatePicker(context).apply {
199
+ layoutParams = LinearLayout.LayoutParams(
200
+ ViewGroup.LayoutParams.MATCH_PARENT,
201
+ ViewGroup.LayoutParams.WRAP_CONTENT
202
+ )
203
+ calendarViewShown = true
204
+ spinnersShown = false
205
+ }
206
+ container.addView(dp)
207
+ inlineDatePicker = dp
208
+
209
+ dp.setOnDateChangedListener { _, year, month, day ->
210
+ if (suppressInlineCallbacks) return@setOnDateChangedListener
211
+ onInlineDateChanged(year, month, day)
212
+ }
213
+ }
214
+
215
+ if (mode == "time" || mode == "dateAndTime") {
216
+ val tp = TimePicker(context).apply {
217
+ layoutParams = LinearLayout.LayoutParams(
218
+ ViewGroup.LayoutParams.MATCH_PARENT,
219
+ ViewGroup.LayoutParams.WRAP_CONTENT
220
+ )
221
+ val is24 = android.text.format.DateFormat.is24HourFormat(context)
222
+ setIs24HourView(is24)
223
+ }
224
+ container.addView(tp)
225
+ inlineTimePicker = tp
226
+
227
+ tp.setOnTimeChangedListener { _, hour, minute ->
228
+ if (suppressInlineCallbacks) return@setOnTimeChangedListener
229
+ onInlineTimeChanged(hour, minute)
230
+ }
231
+ }
232
+
233
+ addView(container)
234
+ inlineContainer = container
235
+
236
+ syncInlineFromState()
237
+ requestLayout()
238
+ }
239
+
240
+ private fun syncInlineFromState() {
241
+ if (!isInline()) return
242
+
243
+ val ts = clamp(dateMs ?: System.currentTimeMillis())
244
+ dateMs = ts
245
+
246
+ val cal = calendarFor(ts)
247
+
248
+ suppressInlineCallbacks = true
249
+ try {
250
+ inlineDatePicker?.let { dp ->
251
+ // Apply min/max bounds on the widget itself where possible
252
+ minDateMs?.let { dp.minDate = it }
253
+ maxDateMs?.let { dp.maxDate = it }
254
+
255
+ val y = cal.get(Calendar.YEAR)
256
+ val m = cal.get(Calendar.MONTH)
257
+ val d = cal.get(Calendar.DAY_OF_MONTH)
258
+ dp.updateDate(y, m, d)
259
+ }
260
+
261
+ inlineTimePicker?.let { tp ->
262
+ val hour = cal.get(Calendar.HOUR_OF_DAY)
263
+ val minute = cal.get(Calendar.MINUTE)
264
+ if (Build.VERSION.SDK_INT >= 23) {
265
+ if (tp.hour != hour) tp.hour = hour
266
+ if (tp.minute != minute) tp.minute = minute
267
+ } else {
268
+ @Suppress("DEPRECATION")
269
+ if (tp.currentHour != hour) tp.currentHour = hour
270
+ @Suppress("DEPRECATION")
271
+ if (tp.currentMinute != minute) tp.currentMinute = minute
272
+ }
273
+ }
274
+ } finally {
275
+ suppressInlineCallbacks = false
276
+ }
277
+ }
278
+
279
+ // -----------------------------
280
+ // Inline change handlers
281
+ // -----------------------------
282
+
283
+ private fun onInlineDateChanged(year: Int, month: Int, day: Int) {
284
+ val base = clamp(dateMs ?: System.currentTimeMillis())
285
+ val cal = calendarFor(base)
286
+
287
+ cal.set(Calendar.YEAR, year)
288
+ cal.set(Calendar.MONTH, month)
289
+ cal.set(Calendar.DAY_OF_MONTH, day)
290
+ cal.set(Calendar.SECOND, 0)
291
+ cal.set(Calendar.MILLISECOND, 0)
292
+
293
+ dateMs = clamp(cal.timeInMillis)
294
+ // Inline = no confirm/cancel; treat as immediate confirm (same as your old behavior)
295
+ onConfirm?.invoke(dateMs!!)
296
+ }
297
+
298
+ private fun onInlineTimeChanged(hour: Int, minute: Int) {
299
+ val base = clamp(dateMs ?: System.currentTimeMillis())
300
+ val cal = calendarFor(base)
301
+
302
+ cal.set(Calendar.HOUR_OF_DAY, hour)
303
+ cal.set(Calendar.MINUTE, minute)
304
+ cal.set(Calendar.SECOND, 0)
305
+ cal.set(Calendar.MILLISECOND, 0)
306
+
307
+ dateMs = clamp(cal.timeInMillis)
308
+ onConfirm?.invoke(dateMs!!)
309
+ }
310
+
311
+ // -----------------------------
312
+ // Headless modal presentation
313
+ // -----------------------------
314
+
315
+ private fun presentIfNeeded() {
316
+ if (showingModal) return
317
+ val act = findFragmentActivity() ?: run {
318
+ // If we cannot present, treat as cancel
319
+ onCancel?.invoke()
320
+ showingModal = false
321
+ return
322
+ }
323
+
324
+ showingModal = true
325
+
326
+ when (mode) {
327
+ "time" -> presentTime(act)
328
+ "dateAndTime" -> presentDateThenTime(act)
329
+ else -> presentDate(act)
330
+ }
331
+ }
332
+
333
+ private fun dismissIfNeeded() {
334
+ // We don’t retain dialog instances here; JS will close by dismissing itself or user action.
335
+ // This keeps parity with Fabric headless patterns.
336
+ showingModal = false
337
+ }
338
+
339
+ private fun presentDate(act: FragmentActivity) {
340
+ if (androidMaterialMode == PCMaterialMode.M3) presentM3Date(act) else presentSystemDate(act)
341
+ }
342
+
343
+ private fun presentTime(act: FragmentActivity) {
344
+ if (androidMaterialMode == PCMaterialMode.M3) presentM3Time(act) else presentSystemTime(act)
345
+ }
346
+
347
+ private fun presentDateThenTime(act: FragmentActivity) {
348
+ if (androidMaterialMode == PCMaterialMode.M3) {
349
+ presentM3DateThenTime(act)
350
+ } else {
351
+ presentSystemDateThenTime(act)
352
+ }
353
+ }
354
+
355
+ // -----------------------------
356
+ // SYSTEM dialogs (AlertDialog host for full control)
357
+ // -----------------------------
358
+
359
+ private fun presentSystemDate(act: FragmentActivity) {
360
+ val ts = clamp(dateMs ?: System.currentTimeMillis())
361
+ val cal = calendarFor(ts)
362
+
363
+ val picker = DatePicker(act).apply {
364
+ calendarViewShown = true
365
+ spinnersShown = false
366
+ minDateMs?.let { minDate = it }
367
+ maxDateMs?.let { maxDate = it }
368
+ updateDate(
369
+ cal.get(Calendar.YEAR),
370
+ cal.get(Calendar.MONTH),
371
+ cal.get(Calendar.DAY_OF_MONTH)
372
+ )
373
+ }
374
+
375
+ val dlg = AlertDialog.Builder(act)
376
+ .setTitle(androidDialogTitle ?: "")
377
+ .setView(picker)
378
+ .setPositiveButton(androidPositiveTitle ?: "OK") { _, _ ->
379
+ val c = calendarFor(ts)
380
+ c.set(Calendar.YEAR, picker.year)
381
+ c.set(Calendar.MONTH, picker.month)
382
+ c.set(Calendar.DAY_OF_MONTH, picker.dayOfMonth)
383
+ c.set(Calendar.SECOND, 0)
384
+ c.set(Calendar.MILLISECOND, 0)
385
+
386
+ dateMs = clamp(c.timeInMillis)
387
+ onConfirm?.invoke(dateMs!!)
388
+ onCancelOrClose()
389
+ }
390
+ .setNegativeButton(androidNegativeTitle ?: "Cancel") { _, _ ->
391
+ onCancel?.invoke()
392
+ onCancelOrClose()
393
+ }
394
+ .setOnCancelListener {
395
+ onCancel?.invoke()
396
+ onCancelOrClose()
397
+ }
398
+ .create()
399
+
400
+ dlg.show()
401
+ }
402
+
403
+ private fun presentSystemTime(act: FragmentActivity) {
404
+ val ts = clamp(dateMs ?: System.currentTimeMillis())
405
+ val cal = calendarFor(ts)
406
+
407
+ val picker = TimePicker(act).apply {
408
+ val is24 = android.text.format.DateFormat.is24HourFormat(act)
409
+ setIs24HourView(is24)
410
+
411
+ val hour = cal.get(Calendar.HOUR_OF_DAY)
412
+ val minute = cal.get(Calendar.MINUTE)
413
+
414
+ if (Build.VERSION.SDK_INT >= 23) {
415
+ this.hour = hour
416
+ this.minute = minute
417
+ } else {
418
+ @Suppress("DEPRECATION") this.currentHour = hour
419
+ @Suppress("DEPRECATION") this.currentMinute = minute
420
+ }
421
+ }
422
+
423
+ val dlg = AlertDialog.Builder(act)
424
+ .setTitle(androidDialogTitle ?: "")
425
+ .setView(picker)
426
+ .setPositiveButton(androidPositiveTitle ?: "OK") { _, _ ->
427
+ val h: Int
428
+ val m: Int
429
+ if (Build.VERSION.SDK_INT >= 23) {
430
+ h = picker.hour
431
+ m = picker.minute
432
+ } else {
433
+ @Suppress("DEPRECATION") h = picker.currentHour
434
+ @Suppress("DEPRECATION") m = picker.currentMinute
435
+ }
436
+
437
+ val c = calendarFor(ts)
438
+ c.set(Calendar.HOUR_OF_DAY, h)
439
+ c.set(Calendar.MINUTE, m)
440
+ c.set(Calendar.SECOND, 0)
441
+ c.set(Calendar.MILLISECOND, 0)
442
+
443
+ dateMs = clamp(c.timeInMillis)
444
+ onConfirm?.invoke(dateMs!!)
445
+ onCancelOrClose()
446
+ }
447
+ .setNegativeButton(androidNegativeTitle ?: "Cancel") { _, _ ->
448
+ onCancel?.invoke()
449
+ onCancelOrClose()
450
+ }
451
+ .setOnCancelListener {
452
+ onCancel?.invoke()
453
+ onCancelOrClose()
454
+ }
455
+ .create()
456
+
457
+ dlg.show()
458
+ }
459
+
460
+ private fun presentSystemDateThenTime(act: FragmentActivity) {
461
+ // date first
462
+ val ts = clamp(dateMs ?: System.currentTimeMillis())
463
+ val cal = calendarFor(ts)
464
+
465
+ val picker = DatePicker(act).apply {
466
+ calendarViewShown = true
467
+ spinnersShown = false
468
+ minDateMs?.let { minDate = it }
469
+ maxDateMs?.let { maxDate = it }
470
+ updateDate(
471
+ cal.get(Calendar.YEAR),
472
+ cal.get(Calendar.MONTH),
473
+ cal.get(Calendar.DAY_OF_MONTH)
474
+ )
475
+ }
476
+
477
+ val dlg = AlertDialog.Builder(act)
478
+ .setTitle(androidDialogTitle ?: "")
479
+ .setView(picker)
480
+ .setPositiveButton(androidPositiveTitle ?: "Next") { _, _ ->
481
+ val c = calendarFor(ts)
482
+ c.set(Calendar.YEAR, picker.year)
483
+ c.set(Calendar.MONTH, picker.month)
484
+ c.set(Calendar.DAY_OF_MONTH, picker.dayOfMonth)
485
+ c.set(Calendar.SECOND, 0)
486
+ c.set(Calendar.MILLISECOND, 0)
487
+
488
+ dateMs = clamp(c.timeInMillis)
489
+ // then time dialog (same mode)
490
+ presentSystemTime(act)
491
+ }
492
+ .setNegativeButton(androidNegativeTitle ?: "Cancel") { _, _ ->
493
+ onCancel?.invoke()
494
+ onCancelOrClose()
495
+ }
496
+ .setOnCancelListener {
497
+ onCancel?.invoke()
498
+ onCancelOrClose()
499
+ }
500
+ .create()
501
+
502
+ dlg.show()
503
+ }
504
+
505
+ // -----------------------------
506
+ // M3 dialogs
507
+ // -----------------------------
508
+
509
+ private fun presentM3Date(act: FragmentActivity) {
510
+ val ts = clamp(dateMs ?: System.currentTimeMillis())
511
+
512
+ val builder = MaterialDatePicker.Builder.datePicker()
513
+ .setSelection(ts)
514
+
515
+ androidDialogTitle?.let { builder.setTitleText(it) }
516
+ androidPositiveTitle?.let { builder.setPositiveButtonText(it) }
517
+ androidNegativeTitle?.let { builder.setNegativeButtonText(it) }
518
+
519
+ val constraints = buildM3CalendarConstraints()
520
+ if (constraints != null) builder.setCalendarConstraints(constraints)
521
+
522
+ val picker = builder.build()
523
+
524
+ picker.addOnPositiveButtonClickListener { selection ->
525
+ val sel = (selection ?: ts)
526
+ // Selection is date-based; merge with existing time-of-day
527
+ val base = calendarFor(ts)
528
+ val selUtc = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { timeInMillis = sel }
529
+ base.set(Calendar.YEAR, selUtc.get(Calendar.YEAR))
530
+ base.set(Calendar.MONTH, selUtc.get(Calendar.MONTH))
531
+ base.set(Calendar.DAY_OF_MONTH, selUtc.get(Calendar.DAY_OF_MONTH))
532
+ base.set(Calendar.SECOND, 0)
533
+ base.set(Calendar.MILLISECOND, 0)
534
+
535
+ dateMs = clamp(base.timeInMillis)
536
+ onConfirm?.invoke(dateMs!!)
537
+ onCancelOrClose()
538
+ }
539
+
540
+ picker.addOnDismissListener {
541
+ // If dismissed without confirm, treat as cancel
542
+ if (showingModal) {
543
+ onCancel?.invoke()
544
+ onCancelOrClose()
545
+ }
546
+ }
547
+
548
+ picker.show(act.supportFragmentManager, "PCDatePicker_M3_DATE")
549
+ }
550
+
551
+ private fun presentM3Time(act: FragmentActivity) {
552
+ val ts = clamp(dateMs ?: System.currentTimeMillis())
553
+ val cal = calendarFor(ts)
554
+
555
+ val is24 = android.text.format.DateFormat.is24HourFormat(act)
556
+ val builder = MaterialTimePicker.Builder()
557
+ .setTimeFormat(if (is24) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H)
558
+ .setHour(cal.get(Calendar.HOUR_OF_DAY))
559
+ .setMinute(cal.get(Calendar.MINUTE))
560
+
561
+ androidDialogTitle?.let { builder.setTitleText(it) }
562
+ // These exist in recent Material; if you’re on an older one, you’ll get compile errors.
563
+ androidPositiveTitle?.let { builder.setPositiveButtonText(it) }
564
+ androidNegativeTitle?.let { builder.setNegativeButtonText(it) }
565
+
566
+ val picker = builder.build()
567
+
568
+ picker.addOnPositiveButtonClickListener {
569
+ val c = calendarFor(ts)
570
+ c.set(Calendar.HOUR_OF_DAY, picker.hour)
571
+ c.set(Calendar.MINUTE, picker.minute)
572
+ c.set(Calendar.SECOND, 0)
573
+ c.set(Calendar.MILLISECOND, 0)
574
+
575
+ dateMs = clamp(c.timeInMillis)
576
+ onConfirm?.invoke(dateMs!!)
577
+ onCancelOrClose()
578
+ }
579
+
580
+ picker.addOnDismissListener {
581
+ if (showingModal) {
582
+ onCancel?.invoke()
583
+ onCancelOrClose()
584
+ }
585
+ }
586
+
587
+ picker.show(act.supportFragmentManager, "PCDatePicker_M3_TIME")
588
+ }
589
+
590
+ private fun presentM3DateThenTime(act: FragmentActivity) {
591
+ val ts = clamp(dateMs ?: System.currentTimeMillis())
592
+
593
+ val builder = MaterialDatePicker.Builder.datePicker()
594
+ .setSelection(ts)
595
+
596
+ androidDialogTitle?.let { builder.setTitleText(it) }
597
+ androidPositiveTitle?.let { builder.setPositiveButtonText(it) }
598
+ androidNegativeTitle?.let { builder.setNegativeButtonText(it) }
599
+
600
+ val constraints = buildM3CalendarConstraints()
601
+ if (constraints != null) builder.setCalendarConstraints(constraints)
602
+
603
+ val picker = builder.build()
604
+
605
+ picker.addOnPositiveButtonClickListener { selection ->
606
+ val sel = (selection ?: ts)
607
+ val base = calendarFor(ts)
608
+ val selUtc = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { timeInMillis = sel }
609
+ base.set(Calendar.YEAR, selUtc.get(Calendar.YEAR))
610
+ base.set(Calendar.MONTH, selUtc.get(Calendar.MONTH))
611
+ base.set(Calendar.DAY_OF_MONTH, selUtc.get(Calendar.DAY_OF_MONTH))
612
+ base.set(Calendar.SECOND, 0)
613
+ base.set(Calendar.MILLISECOND, 0)
614
+
615
+ dateMs = clamp(base.timeInMillis)
616
+ // then time
617
+ presentM3Time(act)
618
+ }
619
+
620
+ picker.addOnDismissListener {
621
+ if (showingModal) {
622
+ onCancel?.invoke()
623
+ onCancelOrClose()
624
+ }
625
+ }
626
+
627
+ picker.show(act.supportFragmentManager, "PCDatePicker_M3_DATE_THEN_TIME")
628
+ }
629
+
630
+ private fun buildM3CalendarConstraints(): CalendarConstraints? {
631
+ val min = minDateMs
632
+ val max = maxDateMs
633
+ if (min == null && max == null) return null
634
+
635
+ val b = CalendarConstraints.Builder()
636
+ min?.let { b.setStart(it) }
637
+ max?.let { b.setEnd(it) }
638
+ return b.build()
639
+ }
640
+
641
+ // -----------------------------
642
+ // Utility
643
+ // -----------------------------
644
+
645
+ private fun onCancelOrClose() {
646
+ showingModal = false
647
+ // JS typically sets visible="closed" in response to onCancel/onConfirm,
648
+ // but we defensively mark ourselves closed.
649
+ }
650
+
651
+ private fun clamp(valueMs: Long): Long {
652
+ var v = valueMs
653
+ minDateMs?.let { v = max(v, it) }
654
+ maxDateMs?.let { v = min(v, it) }
655
+ return v
656
+ }
657
+
658
+ private fun calendarFor(ts: Long): Calendar {
659
+ val cal = Calendar.getInstance(timeZone, locale ?: Locale.getDefault())
660
+ androidFirstDayOfWeek?.let { cal.firstDayOfWeek = it }
661
+ cal.timeInMillis = ts
662
+ return cal
663
+ }
664
+
665
+ private fun findFragmentActivity(): FragmentActivity? {
666
+ val trc = context as? ThemedReactContext
667
+ val a1 = trc?.currentActivity
668
+ if (a1 is FragmentActivity) return a1
669
+
670
+ var c: Context? = context
671
+ while (c is ContextWrapper) {
672
+ if (c is FragmentActivity) return c
673
+ val base = (c as ContextWrapper).baseContext
674
+ if (base == c) break
675
+ c = base
676
+ }
677
+
678
+ val a2 = (context as? Activity)
679
+ return a2 as? FragmentActivity
680
+ }
681
+
682
+ // Minimal enum for material mode
683
+ private enum class PCMaterialMode { SYSTEM, M3 }
684
+ }