diff --git a/src/ComposeNet.Compose/Composables.cs b/src/ComposeNet.Compose/Composables.cs index ee7eca5b..48e1c938 100644 --- a/src/ComposeNet.Compose/Composables.cs +++ b/src/ComposeNet.Compose/Composables.cs @@ -1,9 +1,9 @@ using System.Collections; using System.Collections.Generic; +using Android.Runtime; using AndroidX.Compose.Foundation.Layout; using AndroidX.Compose.Material3; using AndroidX.Compose.Runtime; - namespace ComposeNet; // ---- Leaf: Text ---- @@ -609,3 +609,341 @@ internal override void Render(IComposer composer) ComposeBridges.NavigationRailItem(_selected, click, icon, label, defaults, composer); } } +// ---- ModalBottomSheet ---- + +/// +/// Material 3 ModalBottomSheet. Opens a modal sheet anchored to +/// the bottom of the screen. +/// +/// The SheetState is created inside via +/// — that +/// builder is NOT stripped from the binding, so we call it directly on +/// the bound C# method instead of going through JNI. Use the visibility +/// pattern from : gate the entire instance on +/// a of . +/// +/// +/// var show = Remember(() => new MutableState<bool>(false)); +/// show.Value +/// ? new ModalBottomSheet(onDismissRequest: () => show.Value = false) +/// { +/// new Column { new Text("Sheet contents") }, +/// } +/// : null +/// +/// +public sealed class ModalBottomSheet : ComposableContainer +{ + readonly System.Action _onDismissRequest; + public ModalBottomSheet(System.Action onDismissRequest) => _onDismissRequest = onDismissRequest; + + /// Optional drag handle drawn at the top of the sheet. + public ComposableNode? DragHandle { get; set; } + + internal override void Render(IComposer composer) + { + // Bound C# call — RememberModalBottomSheetState is NOT stripped. + // p3 is the (renamed) Composer parameter; _changed = 3 means bits + // 0 and 1 (skipPartiallyExpanded, confirmValueChange) are + // defaulted by Compose. + var sheetState = ModalBottomSheetKt.RememberModalBottomSheetState( + skipPartiallyExpanded: false, + confirmValueChange: null, + _composer: composer, + p3: 0, + _changed: 3); + + var onDismiss = new ComposableLambda0(_onDismissRequest); + var content = new ComposableLambda3(c => RenderChildren(c)); + ComposableLambda2? dragHandle = DragHandle is null ? null + : new ComposableLambda2(c => DragHandle.Render(c)); + + int defaults = (int)ModalBottomSheetDefault.All; + if (dragHandle is not null) defaults &= ~(int)ModalBottomSheetDefault.DragHandle; + + ComposeBridges.ModalBottomSheet( + onDismissRequest: onDismiss, + sheetState: ((Java.Lang.Object)sheetState).Handle, + dragHandle: dragHandle, + content: content, + defaults: defaults, + composer: composer); + } +} + +// ---- BottomSheetScaffold ---- + +/// +/// Material 3 BottomSheetScaffold. Hosts a persistent bottom +/// sheet alongside a primary content area, plus optional top bar and +/// snackbar slots. +/// +/// +/// is called directly on the C# binding (NOT stripped), threading the +/// composer through. +/// +public sealed class BottomSheetScaffold : ComposableContainer +{ + /// Required: the persistent bottom-sheet content. + public ComposableNode? SheetContent { get; set; } + + /// Optional: the sheet's drag handle. + public ComposableNode? SheetDragHandle { get; set; } + + /// Optional: persistent top bar above the main content. + public ComposableNode? TopBar { get; set; } + + internal override void Render(IComposer composer) + { + if (SheetContent is null) + throw new System.InvalidOperationException( + "BottomSheetScaffold.SheetContent is required."); + + // Bound C# call — RememberBottomSheetScaffoldState is NOT stripped. + var scaffoldState = BottomSheetScaffoldKt.RememberBottomSheetScaffoldState( + bottomSheetState: null, + snackbarHostState: null, + _composer: composer, + p3: 0, + _changed: 3); + + var sheet = new ComposableLambda3(c => SheetContent.Render(c)); + var content = new ComposableLambda3(c => RenderChildren(c)); + + ComposableLambda2? dragHandle = SheetDragHandle is null ? null + : new ComposableLambda2(c => SheetDragHandle.Render(c)); + ComposableLambda2? topBar = TopBar is null ? null + : new ComposableLambda2(c => TopBar.Render(c)); + + int defaults = (int)BottomSheetScaffoldDefault.All; + if (dragHandle is not null) defaults &= ~(int)BottomSheetScaffoldDefault.SheetDragHandle; + if (topBar is not null) defaults &= ~(int)BottomSheetScaffoldDefault.TopBar; + + ComposeBridges.BottomSheetScaffold( + sheetContent: sheet, + scaffoldState: ((Java.Lang.Object)scaffoldState).Handle, + sheetDragHandle: dragHandle, + topBar: topBar, + snackbarHost: null, + content: content, + defaults: defaults, + composer: composer); + } +} + +// ---- DatePickerDialog ---- + +/// +/// Material 3 DatePickerDialog. The DatePickerState +/// builder (rememberDatePickerState-EU0dCGE) is mangled and +/// stripped from the binding, so we resolve it through JNI inside the +/// facade. Place a +/// (or any composable) inside . +/// +public sealed class DatePickerDialog : ComposableNode +{ + readonly System.Action _onDismissRequest; + public DatePickerDialog(System.Action onDismissRequest) => _onDismissRequest = onDismissRequest; + + /// Required: the affirmative button (no Kotlin default). + public ComposableNode? ConfirmButton { get; set; } + + /// Optional: secondary button. + public ComposableNode? DismissButton { get; set; } + + /// Required: dialog body — typically a . + public ComposableNode? Body { get; set; } + + internal override void Render(IComposer composer) + { + if (ConfirmButton is null) + throw new System.InvalidOperationException( + "DatePickerDialog.ConfirmButton is required."); + if (Body is null) + throw new System.InvalidOperationException( + "DatePickerDialog.Body is required (the dialog's content slot)."); + + var onDismiss = new ComposableLambda0(_onDismissRequest); + var confirm = new ComposableLambda2(c => ConfirmButton.Render(c)); + var content = new ComposableLambda3(c => Body.Render(c)); + ComposableLambda2? dismiss = DismissButton is null ? null + : new ComposableLambda2(c => DismissButton.Render(c)); + + int defaults = (int)DatePickerDialogDefault.All; + if (dismiss is not null) defaults &= ~(int)DatePickerDialogDefault.DismissButton; + + ComposeBridges.DatePickerDialog( + onDismissRequest: onDismiss, + confirmButton: confirm, + dismissButton: dismiss, + content: content, + defaults: defaults, + composer: composer); + } +} + +// ---- DatePicker (the inline picker control used inside DatePickerDialog) ---- + +/// +/// Material 3 DatePicker. The Kotlin DatePickerKt.DatePicker +/// composable IS exposed by the binding, but its $default bitmask +/// param isn't user-visible (the binding drops the trailing +/// $default parameter on @Composable functions), so we go through +/// raw JNI to set it. Place inside 's body. +/// Pass an explicit to read the +/// selection from a button callback; if none is supplied a fresh state +/// is created internally and the selection is unobservable. +/// +public sealed class DatePicker : ComposableNode +{ + readonly DatePickerState? _state; + public DatePicker(DatePickerState? state = null) => _state = state; + + internal override void Render(IComposer composer) + { + var stateHandle = ComposeBridges.RememberDatePickerState(composer); + if (_state is not null && _state.Jvm is null) + _state.Jvm = Java.Lang.Object.GetObject(stateHandle, JniHandleOwnership.DoNotTransfer)!; + ComposeBridges.DatePicker(stateHandle, (int)DatePickerDefault.All, composer); + } +} + +// ---- TimePicker ---- + +/// +/// Material 3 TimePicker. Resolves TimePickerState via +/// raw JNI (rememberTimePickerState takes a +/// so it requires the composer-aware bridge). Pass an explicit +/// to read the picked +/// hour/minute from a button callback. +/// +public sealed class TimePicker : ComposableNode +{ + readonly TimePickerState _state; + public TimePicker(TimePickerState? state = null) => _state = state ?? new TimePickerState(); + + internal override void Render(IComposer composer) + { + var stateHandle = ComposeBridges.RememberTimePickerState(_state.InitialHour, _state.InitialMinute, _state.InitialIs24Hour, composer); + if (_state.Jvm is null) + _state.Jvm = Java.Lang.Object.GetObject(stateHandle, JniHandleOwnership.DoNotTransfer)!; + ComposeBridges.TimePicker(stateHandle, (int)TimePickerDefault.All, composer); + } +} + +// ---- TimePickerDialog ---- + +/// +/// Material 3 TimePickerDialog. Both ConfirmButton and +/// DismissButton are required by Compose; +/// and are optional. Place a +/// in the body. +/// +public sealed class TimePickerDialog : ComposableNode +{ + readonly System.Action _onDismissRequest; + public TimePickerDialog(System.Action onDismissRequest) => _onDismissRequest = onDismissRequest; + + public ComposableNode? ConfirmButton { get; set; } + public ComposableNode? DismissButton { get; set; } + public ComposableNode? Title { get; set; } + public ComposableNode? ModeToggleButton { get; set; } + /// Required: dialog body — typically a . + public ComposableNode? Body { get; set; } + + internal override void Render(IComposer composer) + { + if (ConfirmButton is null || DismissButton is null) + throw new System.InvalidOperationException( + "TimePickerDialog.ConfirmButton and DismissButton are both required."); + if (Body is null) + throw new System.InvalidOperationException( + "TimePickerDialog.Body is required (the dialog's content slot)."); + + var onDismiss = new ComposableLambda0(_onDismissRequest); + var confirm = new ComposableLambda2(c => ConfirmButton.Render(c)); + var dismiss = new ComposableLambda2(c => DismissButton.Render(c)); + var content = new ComposableLambda3(c => Body.Render(c)); + ComposableLambda2? title = Title is null ? null + : new ComposableLambda2(c => Title.Render(c)); + ComposableLambda2? toggle = ModeToggleButton is null ? null + : new ComposableLambda2(c => ModeToggleButton.Render(c)); + + int defaults = (int)TimePickerDialogDefault.All; + if (title is not null) defaults &= ~(int)TimePickerDialogDefault.Title; + if (toggle is not null) defaults &= ~(int)TimePickerDialogDefault.ModeToggleButton; + + ComposeBridges.TimePickerDialog( + onDismissRequest: onDismiss, + confirmButton: confirm, + dismissButton: dismiss, + title: title, + modeToggleButton: toggle, + content: content, + defaults: defaults, + composer: composer); + } +} + +// ---- Tooltip (TooltipBox + plain styled tooltip) ---- + +/// +/// Material 3 TooltipBox with a plain tooltip popup. The popup +/// position provider comes from TooltipDefaults.rememberPlainTooltipPositionProvider; +/// TooltipState from rememberTooltipState. Both are +/// resolved via JNI inside . +/// +/// Use named-property syntax: is the popup body shown +/// on long-press / hover, is the always-visible +/// content the popup attaches to. +/// +public sealed class Tooltip : ComposableNode +{ + readonly bool _isPersistent; + public Tooltip(bool isPersistent = false) => _isPersistent = isPersistent; + + /// Required: the popup body shown on long-press / hover. + public ComposableNode? Tip { get; set; } + + /// Required: the always-visible anchor the tooltip attaches to. + public ComposableNode? Anchor { get; set; } + + internal override void Render(IComposer composer) + { + if (Tip is null || Anchor is null) + throw new System.InvalidOperationException( + "Tooltip requires both Tip (popup body) and Anchor (visible content)."); + + var positionProvider = ComposeBridges.RememberPlainTooltipPositionProvider(composer); + var stateHandle = ComposeBridges.RememberTooltipState(_isPersistent, composer); + + var tooltip = new ComposableLambda3(c => Tip.Render(c)); + var anchor = new ComposableLambda2(c => Anchor.Render(c)); + + ComposeBridges.TooltipBox( + positionProvider: positionProvider, + tooltip: tooltip, + state: stateHandle, + content: anchor, + defaults: (int)TooltipBoxDefault.All, + composer: composer); + } +} + +// ---- (BasicTooltip intentionally not bound) ---- +// +// `androidx.compose.material3.internal.BasicTooltipKt.BasicTooltipBox` +// is the foundation primitive that the public `TooltipBox` is built on +// top of. There's no user-facing scenario where it's preferable to +// `Tooltip` (the public wrapper we already bind), so it's skipped on +// purpose for the same reason as `BasicEdgeToEdgeDialog`. + +// ---- (BasicEdgeToEdgeDialog intentionally not bound) ---- +// +// `androidx.compose.material3.internal.BasicEdgeToEdgeDialog` is an +// internal implementation primitive that upstream `Dialog`, +// `AlertDialog`, `DatePickerDialog`, and `TimePickerDialog` are built +// on top of. It draws content edge-to-edge with no scrim, no insets, +// and no built-in dismiss handling — there is no user-facing scenario +// where it's preferable to one of the public dialog wrappers we +// already bind. Skipped on purpose. \ No newline at end of file diff --git a/src/ComposeNet.Compose/ComposeBridges.cs b/src/ComposeNet.Compose/ComposeBridges.cs index c23dcc60..3ce13dd0 100644 --- a/src/ComposeNet.Compose/ComposeBridges.cs +++ b/src/ComposeNet.Compose/ComposeBridges.cs @@ -373,6 +373,551 @@ public static unsafe void AlertDialog( } } + // androidx.compose.material3.ModalBottomSheet_androidKt.ModalBottomSheet-dYc4hso( + // onDismissRequest, modifier, sheetState, sheetMaxWidth, shape, + // containerColor, contentColor, tonalElevation, scrimColor, dragHandle, + // windowInsets, properties, content, composer, $changed, $changed1, $default) + // + // 13 user params; bits 0 (onDismissRequest), 2 (sheetState), 12 (content) + // are always provided. dragHandle (bit 9) is the only optional slot. + const string ModalBottomSheetSig = + "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/SheetState;FLandroidx/compose/ui/graphics/Shape;JJFJ" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/ModalBottomSheetProperties;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V"; + + static IntPtr s_modalBottomSheetClass; + static IntPtr s_modalBottomSheetMethod; + + public static unsafe void ModalBottomSheet( + IFunction0 onDismissRequest, + IntPtr sheetState, + IFunction2? dragHandle, + IFunction3 content, + int defaults, + IComposer composer) + { + if (s_modalBottomSheetClass == IntPtr.Zero) + { + s_modalBottomSheetClass = JNIEnv.FindClass("androidx/compose/material3/ModalBottomSheet_androidKt"); + s_modalBottomSheetMethod = JNIEnv.GetStaticMethodID(s_modalBottomSheetClass, "ModalBottomSheet-dYc4hso", ModalBottomSheetSig); + } + + JValue* args = stackalloc JValue[17]; + args[0] = new JValue(((Java.Lang.Object)onDismissRequest).Handle); + args[1] = new JValue(IntPtr.Zero); // modifier + args[2] = new JValue(sheetState); + args[3] = new JValue(0f); // sheetMaxWidth + args[4] = new JValue(IntPtr.Zero); // shape + args[5] = new JValue(0L); // containerColor + args[6] = new JValue(0L); // contentColor + args[7] = new JValue(0f); // tonalElevation + args[8] = new JValue(0L); // scrimColor + args[9] = new JValue(dragHandle is null ? IntPtr.Zero : ((Java.Lang.Object)dragHandle).Handle); + args[10] = new JValue(IntPtr.Zero); // windowInsets + args[11] = new JValue(IntPtr.Zero); // properties + args[12] = new JValue(((Java.Lang.Object)content).Handle); + args[13] = new JValue(((Java.Lang.Object)composer).Handle); + args[14] = new JValue(0); // $changed + args[15] = new JValue(0); // $changed1 + args[16] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_modalBottomSheetClass, s_modalBottomSheetMethod, args); + } + finally + { + GC.KeepAlive(onDismissRequest); + GC.KeepAlive(dragHandle); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.BottomSheetScaffoldKt.BottomSheetScaffold-sdMYb0k( + // sheetContent, modifier, scaffoldState, sheetPeekHeight, sheetMaxWidth, + // sheetShape, sheetContainerColor, sheetContentColor, sheetTonalElevation, + // sheetShadowElevation, sheetDragHandle, sheetSwipeEnabled, topBar, + // snackbarHost, containerColor, contentColor, content, composer, + // $changed, $changed1, $default) + // + // 17 user params; provided bits: 0 (sheetContent), 2 (scaffoldState), + // 16 (content). Optional slots: 10 (sheetDragHandle), 12 (topBar), + // 13 (snackbarHost). + const string BottomSheetScaffoldSig = + "(Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/BottomSheetScaffoldState;FF" + + "Landroidx/compose/ui/graphics/Shape;JJFF" + + "Lkotlin/jvm/functions/Function2;Z" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;JJ" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V"; + + static IntPtr s_bottomSheetScaffoldClass; + static IntPtr s_bottomSheetScaffoldMethod; + + public static unsafe void BottomSheetScaffold( + IFunction3 sheetContent, + IntPtr scaffoldState, + IFunction2? sheetDragHandle, + IFunction2? topBar, + IFunction3? snackbarHost, + IFunction3 content, + int defaults, + IComposer composer) + { + if (s_bottomSheetScaffoldClass == IntPtr.Zero) + { + s_bottomSheetScaffoldClass = JNIEnv.FindClass("androidx/compose/material3/BottomSheetScaffoldKt"); + s_bottomSheetScaffoldMethod = JNIEnv.GetStaticMethodID(s_bottomSheetScaffoldClass, "BottomSheetScaffold-sdMYb0k", BottomSheetScaffoldSig); + } + + JValue* args = stackalloc JValue[21]; + args[0] = new JValue(((Java.Lang.Object)sheetContent).Handle); + args[1] = new JValue(IntPtr.Zero); // modifier + args[2] = new JValue(scaffoldState); + args[3] = new JValue(0f); // sheetPeekHeight + args[4] = new JValue(0f); // sheetMaxWidth + args[5] = new JValue(IntPtr.Zero); // sheetShape + args[6] = new JValue(0L); // sheetContainerColor + args[7] = new JValue(0L); // sheetContentColor + args[8] = new JValue(0f); // sheetTonalElevation + args[9] = new JValue(0f); // sheetShadowElevation + args[10] = new JValue(sheetDragHandle is null ? IntPtr.Zero : ((Java.Lang.Object)sheetDragHandle).Handle); + args[11] = new JValue(true); // sheetSwipeEnabled + args[12] = new JValue(topBar is null ? IntPtr.Zero : ((Java.Lang.Object)topBar).Handle); + args[13] = new JValue(snackbarHost is null ? IntPtr.Zero : ((Java.Lang.Object)snackbarHost).Handle); + args[14] = new JValue(0L); // containerColor + args[15] = new JValue(0L); // contentColor + args[16] = new JValue(((Java.Lang.Object)content).Handle); + args[17] = new JValue(((Java.Lang.Object)composer).Handle); + args[18] = new JValue(0); // $changed + args[19] = new JValue(0); // $changed1 + args[20] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_bottomSheetScaffoldClass, s_bottomSheetScaffoldMethod, args); + } + finally + { + GC.KeepAlive(sheetContent); + GC.KeepAlive(sheetDragHandle); + GC.KeepAlive(topBar); + GC.KeepAlive(snackbarHost); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.DatePickerDialog_androidKt.DatePickerDialog-GmEhDVc( + // onDismissRequest, confirmButton, modifier, dismissButton, shape, + // tonalElevation, colors, properties, content, composer, $changed, $default) + // + // 9 user params; bits 0 (onDismissRequest), 1 (confirmButton), + // 8 (content) always provided. dismissButton (bit 3) is the only + // optional Function2 slot. + const string DatePickerDialogSig = + "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;F" + + "Landroidx/compose/material3/DatePickerColors;" + + "Landroidx/compose/ui/window/DialogProperties;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_datePickerDialogClass; + static IntPtr s_datePickerDialogMethod; + + public static unsafe void DatePickerDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IFunction2? dismissButton, + IFunction3 content, + int defaults, + IComposer composer) + { + if (s_datePickerDialogClass == IntPtr.Zero) + { + s_datePickerDialogClass = JNIEnv.FindClass("androidx/compose/material3/DatePickerDialog_androidKt"); + s_datePickerDialogMethod = JNIEnv.GetStaticMethodID(s_datePickerDialogClass, "DatePickerDialog-GmEhDVc", DatePickerDialogSig); + } + + JValue* args = stackalloc JValue[12]; + args[0] = new JValue(((Java.Lang.Object)onDismissRequest).Handle); + args[1] = new JValue(((Java.Lang.Object)confirmButton).Handle); + args[2] = new JValue(IntPtr.Zero); // modifier + args[3] = new JValue(dismissButton is null ? IntPtr.Zero : ((Java.Lang.Object)dismissButton).Handle); + args[4] = new JValue(IntPtr.Zero); // shape + args[5] = new JValue(0f); // tonalElevation + args[6] = new JValue(IntPtr.Zero); // colors + args[7] = new JValue(IntPtr.Zero); // properties + args[8] = new JValue(((Java.Lang.Object)content).Handle); + args[9] = new JValue(((Java.Lang.Object)composer).Handle); + args[10] = new JValue(0); // $changed + args[11] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_datePickerDialogClass, s_datePickerDialogMethod, args); + } + finally + { + GC.KeepAlive(onDismissRequest); + GC.KeepAlive(confirmButton); + GC.KeepAlive(dismissButton); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TimePickerKt.TimePicker-mT9BvqQ( + // state, modifier, colors, layoutType, composer, $changed, $default) + // + // 4 user params; bit 0 (state) always provided. + const string TimePickerSig = + "(Landroidx/compose/material3/TimePickerState;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/TimePickerColors;ILandroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_timePickerClass; + static IntPtr s_timePickerMethod; + + public static unsafe void TimePicker(IntPtr state, int defaults, IComposer composer) + { + if (s_timePickerClass == IntPtr.Zero) + { + s_timePickerClass = JNIEnv.FindClass("androidx/compose/material3/TimePickerKt"); + s_timePickerMethod = JNIEnv.GetStaticMethodID(s_timePickerClass, "TimePicker-mT9BvqQ", TimePickerSig); + } + + JValue* args = stackalloc JValue[7]; + args[0] = new JValue(state); + args[1] = new JValue(IntPtr.Zero); // modifier + args[2] = new JValue(IntPtr.Zero); // colors + args[3] = new JValue(0); // layoutType + args[4] = new JValue(((Java.Lang.Object)composer).Handle); + args[5] = new JValue(0); // $changed + args[6] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_timePickerClass, s_timePickerMethod, args); + } + finally + { + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TimePickerDialogKt.TimePickerDialog-FItCLgY( + // onDismissRequest, confirmButton, dismissButton, modifier, properties, + // title, modeToggleButton, shape, containerColor, content, composer, + // $changed, $default) + // + // 10 user params. confirmButton (bit 1), dismissButton (bit 2), and + // content (bit 9) are required slots. title (bit 5) and + // modeToggleButton (bit 6) are optional. + const string TimePickerDialogSig = + "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/window/DialogProperties;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;J" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_timePickerDialogClass; + static IntPtr s_timePickerDialogMethod; + + public static unsafe void TimePickerDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IFunction2 dismissButton, + IFunction2? title, + IFunction2? modeToggleButton, + IFunction3 content, + int defaults, + IComposer composer) + { + if (s_timePickerDialogClass == IntPtr.Zero) + { + s_timePickerDialogClass = JNIEnv.FindClass("androidx/compose/material3/TimePickerDialogKt"); + s_timePickerDialogMethod = JNIEnv.GetStaticMethodID(s_timePickerDialogClass, "TimePickerDialog-FItCLgY", TimePickerDialogSig); + } + + JValue* args = stackalloc JValue[13]; + args[0] = new JValue(((Java.Lang.Object)onDismissRequest).Handle); + args[1] = new JValue(((Java.Lang.Object)confirmButton).Handle); + args[2] = new JValue(((Java.Lang.Object)dismissButton).Handle); + args[3] = new JValue(IntPtr.Zero); // modifier + args[4] = new JValue(IntPtr.Zero); // properties + args[5] = new JValue(title is null ? IntPtr.Zero : ((Java.Lang.Object)title).Handle); + args[6] = new JValue(modeToggleButton is null ? IntPtr.Zero : ((Java.Lang.Object)modeToggleButton).Handle); + args[7] = new JValue(IntPtr.Zero); // shape + args[8] = new JValue(0L); // containerColor + args[9] = new JValue(((Java.Lang.Object)content).Handle); + args[10] = new JValue(((Java.Lang.Object)composer).Handle); + args[11] = new JValue(0); // $changed + args[12] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_timePickerDialogClass, s_timePickerDialogMethod, args); + } + finally + { + GC.KeepAlive(onDismissRequest); + GC.KeepAlive(confirmButton); + GC.KeepAlive(dismissButton); + GC.KeepAlive(title); + GC.KeepAlive(modeToggleButton); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TooltipKt.TooltipBox (7-user-param overload): + // (positionProvider, tooltip, state, modifier, focusable, enableUserInput, + // content, composer, $changed, $default) + // + // Bits 0 (positionProvider), 1 (tooltip), 2 (state), 6 (content) provided. + const string TooltipBoxSig = + "(Landroidx/compose/ui/window/PopupPositionProvider;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/material3/TooltipState;" + + "Landroidx/compose/ui/Modifier;ZZ" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_tooltipBoxClass; + static IntPtr s_tooltipBoxMethod; + + public static unsafe void TooltipBox( + IntPtr positionProvider, + IFunction3 tooltip, + IntPtr state, + IFunction2 content, + int defaults, + IComposer composer) + { + if (s_tooltipBoxClass == IntPtr.Zero) + { + s_tooltipBoxClass = JNIEnv.FindClass("androidx/compose/material3/TooltipKt"); + s_tooltipBoxMethod = JNIEnv.GetStaticMethodID(s_tooltipBoxClass, "TooltipBox", TooltipBoxSig); + } + + JValue* args = stackalloc JValue[10]; + args[0] = new JValue(positionProvider); + args[1] = new JValue(((Java.Lang.Object)tooltip).Handle); + args[2] = new JValue(state); + args[3] = new JValue(IntPtr.Zero); // modifier + args[4] = new JValue(true); // focusable + args[5] = new JValue(true); // enableUserInput + args[6] = new JValue(((Java.Lang.Object)content).Handle); + args[7] = new JValue(((Java.Lang.Object)composer).Handle); + args[8] = new JValue(0); // $changed + args[9] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_tooltipBoxClass, s_tooltipBoxMethod, args); + } + finally + { + GC.KeepAlive(tooltip); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.DatePickerKt.DatePicker( + // state, modifier, dateFormatter, colors, title, headline, + // showModeToggle, requestFocus, composer, $changed, $default) + // + // 8 user params; bit 0 (state) provided. requestFocus is the + // optional focus requester (defaultable). + const string DatePickerSig = + "(Landroidx/compose/material3/DatePickerState;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/DatePickerFormatter;" + + "Landroidx/compose/material3/DatePickerColors;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/ui/focus/FocusRequester;" + + "Landroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_datePickerClass; + static IntPtr s_datePickerMethod; + + public static unsafe void DatePicker(IntPtr state, int defaults, IComposer composer) + { + if (s_datePickerClass == IntPtr.Zero) + { + s_datePickerClass = JNIEnv.FindClass("androidx/compose/material3/DatePickerKt"); + s_datePickerMethod = JNIEnv.GetStaticMethodID(s_datePickerClass, "DatePicker", DatePickerSig); + } + + JValue* args = stackalloc JValue[11]; + args[0] = new JValue(state); + args[1] = new JValue(IntPtr.Zero); // modifier + args[2] = new JValue(IntPtr.Zero); // dateFormatter + args[3] = new JValue(IntPtr.Zero); // colors + args[4] = new JValue(IntPtr.Zero); // title + args[5] = new JValue(IntPtr.Zero); // headline + args[6] = new JValue(true); // showModeToggle + args[7] = new JValue(IntPtr.Zero); // requestFocus + args[8] = new JValue(((Java.Lang.Object)composer).Handle); + args[9] = new JValue(0); // $changed + args[10] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_datePickerClass, s_datePickerMethod, args); + } + finally + { + GC.KeepAlive(composer); + } + } + + // ---- State-holder bridges ---- + // + // Every `remember*State` builder is itself @Composable so it takes a + // trailing Composer + $changed + $default. The dotnet/android-libraries + // binding generator strips the mangled overloads (DatePickerState's + // `-EU0dCGE`), so we go through raw JNI for those. We always return + // the IntPtr — the caller threads it back into a composable bridge + // as a JValue argument without ever materialising a managed wrapper. + + // androidx.compose.material3.DatePickerKt.rememberDatePickerState-EU0dCGE( + // initialSelectedDateMillis, initialDisplayedMonthMillis, yearRange, + // initialDisplayMode, selectableDates, composer, $changed, $default) + const string RememberDatePickerStateSig = + "(Ljava/lang/Long;Ljava/lang/Long;Lkotlin/ranges/IntRange;I" + + "Landroidx/compose/material3/SelectableDates;" + + "Landroidx/compose/runtime/Composer;II)Landroidx/compose/material3/DatePickerState;"; + + static IntPtr s_rememberDatePickerStateClass; + static IntPtr s_rememberDatePickerStateMethod; + + public static unsafe IntPtr RememberDatePickerState(IComposer composer) + { + if (s_rememberDatePickerStateClass == IntPtr.Zero) + { + s_rememberDatePickerStateClass = JNIEnv.FindClass("androidx/compose/material3/DatePickerKt"); + s_rememberDatePickerStateMethod = JNIEnv.GetStaticMethodID(s_rememberDatePickerStateClass, "rememberDatePickerState-EU0dCGE", RememberDatePickerStateSig); + } + + JValue* args = stackalloc JValue[8]; + args[0] = new JValue(IntPtr.Zero); // initialSelectedDateMillis + args[1] = new JValue(IntPtr.Zero); // initialDisplayedMonthMillis + args[2] = new JValue(IntPtr.Zero); // yearRange + args[3] = new JValue(0); // initialDisplayMode + args[4] = new JValue(IntPtr.Zero); // selectableDates + args[5] = new JValue(((Java.Lang.Object)composer).Handle); + args[6] = new JValue(0); // $changed + args[7] = new JValue(0b11111); // $default — all 5 user params defaulted + try + { + return JNIEnv.CallStaticObjectMethod(s_rememberDatePickerStateClass, s_rememberDatePickerStateMethod, args); + } + finally + { + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TimePickerKt.rememberTimePickerState( + // initialHour, initialMinute, is24Hour, composer, $changed, $default) + const string RememberTimePickerStateSig = + "(IIZLandroidx/compose/runtime/Composer;II)Landroidx/compose/material3/TimePickerState;"; + + static IntPtr s_rememberTimePickerStateClass; + static IntPtr s_rememberTimePickerStateMethod; + + public static unsafe IntPtr RememberTimePickerState(int initialHour, int initialMinute, bool is24Hour, IComposer composer) + { + if (s_rememberTimePickerStateClass == IntPtr.Zero) + { + s_rememberTimePickerStateClass = JNIEnv.FindClass("androidx/compose/material3/TimePickerKt"); + s_rememberTimePickerStateMethod = JNIEnv.GetStaticMethodID(s_rememberTimePickerStateClass, "rememberTimePickerState", RememberTimePickerStateSig); + } + + JValue* args = stackalloc JValue[6]; + args[0] = new JValue(initialHour); + args[1] = new JValue(initialMinute); + args[2] = new JValue(is24Hour); + args[3] = new JValue(((Java.Lang.Object)composer).Handle); + args[4] = new JValue(0); // $changed + args[5] = new JValue(0); // $default — all 3 provided + try + { + return JNIEnv.CallStaticObjectMethod(s_rememberTimePickerStateClass, s_rememberTimePickerStateMethod, args); + } + finally + { + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TooltipKt.rememberTooltipState( + // initialIsVisible, isPersistent, mutatorMutex, composer, $changed, $default) + const string RememberTooltipStateSig = + "(ZZLandroidx/compose/foundation/MutatorMutex;Landroidx/compose/runtime/Composer;II)Landroidx/compose/material3/TooltipState;"; + + static IntPtr s_rememberTooltipStateClass; + static IntPtr s_rememberTooltipStateMethod; + + public static unsafe IntPtr RememberTooltipState(bool isPersistent, IComposer composer) + { + if (s_rememberTooltipStateClass == IntPtr.Zero) + { + s_rememberTooltipStateClass = JNIEnv.FindClass("androidx/compose/material3/TooltipKt"); + s_rememberTooltipStateMethod = JNIEnv.GetStaticMethodID(s_rememberTooltipStateClass, "rememberTooltipState", RememberTooltipStateSig); + } + + JValue* args = stackalloc JValue[6]; + args[0] = new JValue(false); // initialIsVisible + args[1] = new JValue(isPersistent); + args[2] = new JValue(IntPtr.Zero); // mutatorMutex + args[3] = new JValue(((Java.Lang.Object)composer).Handle); + args[4] = new JValue(0); // $changed + args[5] = new JValue(0b101); // $default — bits 0 and 2 (initialIsVisible, mutatorMutex) + try + { + return JNIEnv.CallStaticObjectMethod(s_rememberTooltipStateClass, s_rememberTooltipStateMethod, args); + } + finally + { + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TooltipDefaults.INSTANCE.rememberPlainTooltipPositionProvider-kHDZbjc( + // spacingBetweenTooltipAndAnchor, composer, $changed, $default) + // + // Instance method on the Kotlin object singleton. We resolve INSTANCE + // once and reuse it across calls. + const string RememberPlainTooltipPositionProviderSig = + "(FLandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/window/PopupPositionProvider;"; + + static IntPtr s_tooltipDefaultsInstance; + static IntPtr s_rememberPlainTooltipPositionProviderMethod; + + public static unsafe IntPtr RememberPlainTooltipPositionProvider(IComposer composer) + { + if (s_tooltipDefaultsInstance == IntPtr.Zero) + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/material3/TooltipDefaults"); + IntPtr instanceFid = JNIEnv.GetStaticFieldID(cls, "INSTANCE", "Landroidx/compose/material3/TooltipDefaults;"); + s_tooltipDefaultsInstance = JNIEnv.NewGlobalRef(JNIEnv.GetStaticObjectField(cls, instanceFid)); + s_rememberPlainTooltipPositionProviderMethod = JNIEnv.GetMethodID(cls, "rememberPlainTooltipPositionProvider-kHDZbjc", RememberPlainTooltipPositionProviderSig); + } + + JValue* args = stackalloc JValue[4]; + args[0] = new JValue(0f); // spacing + args[1] = new JValue(((Java.Lang.Object)composer).Handle); + args[2] = new JValue(0); + args[3] = new JValue(1); // $default — spacing defaulted + try + { + return JNIEnv.CallObjectMethod(s_tooltipDefaultsInstance, s_rememberPlainTooltipPositionProviderMethod, args); + } + finally + { + GC.KeepAlive(composer); + } + } + static unsafe void InvokeTextField(IntPtr cls, IntPtr method, string value, IFunction1 onValueChange, IComposer composer, int defaults) { IntPtr valueRef = JNIEnv.NewString(value); diff --git a/src/ComposeNet.Compose/ComposeDefaults.cs b/src/ComposeNet.Compose/ComposeDefaults.cs index 8a396381..297babd3 100644 --- a/src/ComposeNet.Compose/ComposeDefaults.cs +++ b/src/ComposeNet.Compose/ComposeDefaults.cs @@ -74,6 +74,52 @@ "icon", "title", "text", "shape", "containerColor", "iconContentColor", "titleContentColor", "textContentColor", "tonalElevation", "properties")] +// androidx.compose.material3.ModalBottomSheet_androidKt.ModalBottomSheet-dYc4hso: +// 13 user params; bits 0 (onDismissRequest), 2 (sheetState), 12 (content) always provided. +[assembly: ComposeDefaults("ModalBottomSheetDefault", + "!onDismissRequest", "modifier", "!sheetState", "sheetMaxWidth", "shape", + "containerColor", "contentColor", "tonalElevation", "scrimColor", "dragHandle", + "windowInsets", "properties", "!content")] + +// androidx.compose.material3.BottomSheetScaffoldKt.BottomSheetScaffold-sdMYb0k: +// 17 user params; bits 0 (sheetContent), 2 (scaffoldState), 16 (content) always provided. +[assembly: ComposeDefaults("BottomSheetScaffoldDefault", + "!sheetContent", "modifier", "!scaffoldState", "sheetPeekHeight", "sheetMaxWidth", + "sheetShape", "sheetContainerColor", "sheetContentColor", "sheetTonalElevation", + "sheetShadowElevation", "sheetDragHandle", "sheetSwipeEnabled", "topBar", + "snackbarHost", "containerColor", "contentColor", "!content")] + +// androidx.compose.material3.DatePickerDialog_androidKt.DatePickerDialog-GmEhDVc: +// 9 user params; bits 0 (onDismissRequest), 1 (confirmButton), 8 (content) always provided. +[assembly: ComposeDefaults("DatePickerDialogDefault", + "!onDismissRequest", "!confirmButton", "modifier", "dismissButton", "shape", + "tonalElevation", "colors", "properties", "!content")] + +// androidx.compose.material3.DatePickerKt.DatePicker: +// 8 user params; bit 0 (state) always provided. +[assembly: ComposeDefaults("DatePickerDefault", + "!state", "modifier", "dateFormatter", "colors", "title", + "headline", "showModeToggle", "requestFocus")] + +// androidx.compose.material3.TimePickerKt.TimePicker-mT9BvqQ: +// 4 user params; bit 0 (state) always provided. +[assembly: ComposeDefaults("TimePickerDefault", + "!state", "modifier", "colors", "layoutType")] + +// androidx.compose.material3.TimePickerDialogKt.TimePickerDialog-FItCLgY: +// 10 user params; bits 0 (onDismissRequest), 1 (confirmButton), +// 2 (dismissButton), 9 (content) always provided. +[assembly: ComposeDefaults("TimePickerDialogDefault", + "!onDismissRequest", "!confirmButton", "!dismissButton", "modifier", "properties", + "title", "modeToggleButton", "shape", "containerColor", "!content")] + +// androidx.compose.material3.TooltipKt.TooltipBox (7-param overload): +// 7 user params; bits 0 (positionProvider), 1 (tooltip), 2 (state), +// 6 (content) always provided. +[assembly: ComposeDefaults("TooltipBoxDefault", + "!positionProvider", "!tooltip", "!state", "modifier", "focusable", + "enableUserInput", "!content")] + // androidx.compose.material3.TextFieldKt.TextField (String overload) AND // OutlinedTextFieldKt.OutlinedTextField (String overload): 23 user params, // bit 0 = value, bit 1 = onValueChange (both provided). diff --git a/src/ComposeNet.Compose/PickerState.cs b/src/ComposeNet.Compose/PickerState.cs new file mode 100644 index 00000000..f4b59c68 --- /dev/null +++ b/src/ComposeNet.Compose/PickerState.cs @@ -0,0 +1,130 @@ +using Android.Runtime; +using AndroidX.Compose.Material3; + +namespace ComposeNet; + +// ---- DatePickerState ---- + +/// +/// Caller-supplied state holder for / +/// . The underlying JVM +/// androidx.compose.material3.DatePickerState is created lazily +/// the first time a bound to this state is +/// rendered; reads/writes to before +/// that point are no-ops/fallbacks. +/// +/// +/// Typical usage — Remember a state instance, pass it to a +/// , and read the picked value from the +/// dialog's ConfirmButton.OnClick: +/// +/// var pickerState = Remember(() => new DatePickerState()); +/// +/// new DatePickerDialog(onDismissRequest: ...) +/// { +/// ConfirmButton = new Button(onClick: () => +/// { +/// var ms = pickerState.SelectedDateMillis; +/// // ms is the picked date as Unix epoch milliseconds (UTC). +/// }) +/// { new Text("OK") }, +/// Body = new DatePicker(pickerState), +/// } +/// +/// +public sealed class DatePickerState +{ + internal IDatePickerState? Jvm; + + /// + /// The currently selected date as Unix epoch milliseconds (UTC), or + /// null if no date is selected. Mirrors Kotlin's + /// DatePickerState.selectedDateMillis: Long?. Returns + /// null until the first render binds + /// this state to the JVM picker. + /// + public long? SelectedDateMillis + { + get => Jvm?.SelectedDateMillis?.LongValue(); + set + { + if (Jvm is not null) + Jvm.SelectedDateMillis = value is long ms ? Java.Lang.Long.ValueOf(ms) : null; + } + } + + /// + /// First-of-month milliseconds for the month currently shown by the + /// picker. Mirrors Kotlin's DatePickerState.displayedMonthMillis. + /// Returns 0 until the state is bound. + /// + public long DisplayedMonthMillis + { + get => Jvm?.DisplayedMonthMillis ?? 0L; + set { if (Jvm is not null) Jvm.DisplayedMonthMillis = value; } + } +} + +// ---- TimePickerState ---- + +/// +/// Caller-supplied state holder for / +/// . Mirrors Kotlin's +/// TimePickerState: / +/// expose the live values; reflects the format. +/// +/// +/// Typical usage — Remember a state instance with the desired +/// initial values, pass it to a , and read the +/// picked time from the dialog's ConfirmButton.OnClick: +/// +/// var pickerState = Remember(() => new TimePickerState(initialHour: 9, initialMinute: 30)); +/// +/// new TimePickerDialog(onDismissRequest: ...) +/// { +/// ConfirmButton = new Button(onClick: () => +/// { +/// var h = pickerState.Hour; +/// var m = pickerState.Minute; +/// }) +/// { new Text("OK") }, +/// Body = new TimePicker(pickerState), +/// } +/// +/// +public sealed class TimePickerState +{ + internal ITimePickerState? Jvm; + + internal int InitialHour { get; } + internal int InitialMinute { get; } + internal bool InitialIs24Hour { get; } + + public TimePickerState(int initialHour = 12, int initialMinute = 0, bool is24Hour = true) + { + InitialHour = initialHour; + InitialMinute = initialMinute; + InitialIs24Hour = is24Hour; + } + + /// Currently displayed hour (0–23). Falls back to the + /// constructor's initialHour until bound. + public int Hour + { + get => Jvm?.Hour ?? InitialHour; + set { if (Jvm is not null) Jvm.Hour = value; } + } + + /// Currently displayed minute (0–59). Falls back to the + /// constructor's initialMinute until bound. + public int Minute + { + get => Jvm?.Minute ?? InitialMinute; + set { if (Jvm is not null) Jvm.Minute = value; } + } + + /// Whether the picker is in 24-hour mode (vs. 12-hour with + /// AM/PM). Falls back to the constructor's is24Hour until + /// bound. + public bool Is24Hour => Jvm?.Is24hour() ?? InitialIs24Hour; +} diff --git a/src/ComposeNet.Sample/MainActivity.cs b/src/ComposeNet.Sample/MainActivity.cs index c226b22b..10bd4a52 100644 --- a/src/ComposeNet.Sample/MainActivity.cs +++ b/src/ComposeNet.Sample/MainActivity.cs @@ -1,4 +1,5 @@ using Android.OS; +using AndroidX.Compose.Material3; using ComposeNet; namespace ComposeNet.Sample; @@ -11,81 +12,165 @@ protected override void OnCreate(Bundle? savedInstanceState) base.OnCreate(savedInstanceState); SetContent(() => { - var count = Remember(() => new MutableNumberState(0)); - var name = Remember(() => new MutableState("")); - var showDlg = Remember(() => new MutableState(false)); - var liked = Remember(() => new MutableState(false)); - var tab = Remember(() => new MutableNumberState(0)); + var count = Remember(() => new MutableNumberState(0)); + var name = Remember(() => new MutableState("")); + var liked = Remember(() => new MutableState(false)); + var tab = Remember(() => new MutableNumberState(0)); + var showAlert = Remember(() => new MutableState(false)); + var showSheet = Remember(() => new MutableState(false)); + var showDate = Remember(() => new MutableState(false)); + var showTime = Remember(() => new MutableState(false)); + var pickedDate = Remember(() => new MutableState("(none)")); + var pickedTime = Remember(() => new MutableState("(none)")); + var dateState = Remember(() => new DatePickerState()); + var timeState = Remember(() => new TimePickerState(initialHour: 9, initialMinute: 30)); + + // Per-tab content. Only the current tab's column is added to + // the screen — keeps the sample short enough to fit on one + // phone-sized scroll area. + ComposableNode tabContent = tab.Value switch + { + 0 => new Column + { + new Text("Hello from .NET"), + new Text($"Count: {count}"), + new Button(onClick: () => count++) { new Text("Tap to increment") }, + new IconButton(onClick: () => count--) { new Text("−") }, + new OutlinedTextField(name), + new Text($"Hi {(string.IsNullOrEmpty(name.Value) ? "stranger" : name.Value)}"), + new Card + { + new Text("Inside a Card"), + new Text($"Counter snapshot: {count}"), + }, + }, + 1 => new Column + { + new Text("Chips, FAB, tooltip"), + new AssistChip(onClick: () => count++) + { + Label = new Text("Assist (+1)"), + }, + new FilterChip(selected: liked.Value, onClick: () => liked.Value = !liked.Value) + { + Label = new Text(liked.Value ? "Liked" : "Like"), + }, + new SuggestionChip(onClick: () => count.Value = 0) + { + Label = new Text("Reset"), + }, + new Tooltip + { + Tip = new Surface { new Text("Helpful hint") }, + Anchor = new Button(onClick: () => count++) { new Text("Long-press me") }, + }, + new FloatingActionButton(onClick: () => showAlert.Value = true) + { + new Text("✕"), + }, + }, + _ => new Column + { + new Text("Dialogs and sheets"), + new Button(onClick: () => showSheet.Value = true) { new Text("Modal bottom sheet") }, + new Button(onClick: () => showDate.Value = true) { new Text("Date picker dialog") }, + new Button(onClick: () => showTime.Value = true) { new Text("Time picker dialog") }, + new Text($"Picked date: {pickedDate}"), + new Text($"Picked time: {pickedTime}"), + }, + }; + return new MaterialTheme { new Surface { new Column { - new Text("Hello from .NET"), - new Text($"Count: {count}"), - new Button(onClick: () => count++) - { - new Text("Tap to increment"), - }, - new IconButton(onClick: () => count--) - { - new Text("−"), - }, - new OutlinedTextField(name), - new Text($"Hi {(string.IsNullOrEmpty(name.Value) ? "stranger" : name.Value)}"), - new Card - { - new Text("Inside a Card"), - new Text($"Counter snapshot: {count}"), - }, - new AssistChip(onClick: () => count++) - { - Label = new Text("Assist (+1)"), - }, - new FilterChip(selected: liked.Value, onClick: () => liked.Value = !liked.Value) - { - Label = new Text(liked.Value ? "Liked" : "Like"), - }, - new SuggestionChip(onClick: () => count.Value = 0) - { - Label = new Text("Reset"), - }, + tabContent, + + // Bottom navigation switches between the three tabs above. new NavigationBar { new NavigationBarItem(selected: tab.Value == 0, onClick: () => tab.Value = 0) { - Icon = new Text("🏠"), - Label = new Text("Home"), + Icon = new Text("🔢"), + Label = new Text("Basics"), }, new NavigationBarItem(selected: tab.Value == 1, onClick: () => tab.Value = 1) { - Icon = new Text("⚙"), - Label = new Text("Settings"), + Icon = new Text("👍"), + Label = new Text("Buttons"), + }, + new NavigationBarItem(selected: tab.Value == 2, onClick: () => tab.Value = 2) + { + Icon = new Text("📅"), + Label = new Text("Pickers"), }, }, - new FloatingActionButton(onClick: () => showDlg.Value = true) - { - new Text("✕"), - }, - showDlg.Value - ? new AlertDialog(onDismissRequest: () => showDlg.Value = false) + + // --- Overlays: rendered at the root regardless of tab, + // so a dialog opened on Tab 1 still works after switching. --- + showAlert.Value + ? new AlertDialog(onDismissRequest: () => showAlert.Value = false) { Title = new Text("Reset counter?"), Text = new Text("This will set the counter back to zero."), - ConfirmButton = new Button(onClick: () => { count.Value = 0; showDlg.Value = false; }) + ConfirmButton = new Button(onClick: () => { count.Value = 0; showAlert.Value = false; }) { new Text("Reset"), }, - DismissButton = new Button(onClick: () => showDlg.Value = false) + DismissButton = new Button(onClick: () => showAlert.Value = false) { new Text("Cancel"), }, } : null, + + // ModalBottomSheet — SheetState comes from the bound C# + // RememberModalBottomSheetState (NOT stripped, no JNI). + showSheet.Value + ? new ModalBottomSheet(onDismissRequest: () => showSheet.Value = false) + { + new Column + { + new Text("Modal bottom sheet"), + new Text("Drag down or tap outside to dismiss."), + new Button(onClick: () => showSheet.Value = false) { new Text("Hide") }, + }, + } + : null, + + showDate.Value + ? new DatePickerDialog(onDismissRequest: () => showDate.Value = false) + { + ConfirmButton = new Button(onClick: () => + { + pickedDate.Value = dateState.SelectedDateMillis is long ms ? ms.ToString() : "(none)"; + showDate.Value = false; + }) + { new Text("OK") }, + DismissButton = new Button(onClick: () => showDate.Value = false) { new Text("Cancel") }, + Body = new DatePicker(dateState), + } + : null, + + showTime.Value + ? new TimePickerDialog(onDismissRequest: () => showTime.Value = false) + { + Title = new Text("Pick a time"), + ConfirmButton = new Button(onClick: () => + { + pickedTime.Value = $"{timeState.Hour:D2}:{timeState.Minute:D2}"; + showTime.Value = false; + }) + { new Text("OK") }, + DismissButton = new Button(onClick: () => showTime.Value = false) { new Text("Cancel") }, + Body = new TimePicker(timeState), + } + : null, }, }, }; }); } -} +} \ No newline at end of file