Migrate adaptive UI to native iOS glass (#622)

* Migrate adaptive UI to native iOS glass

* fix(sidebar): unify chat and folder gutters

The alignment regression persisted because nested folder rows combined their hierarchy depth with an additional fixed outer inset. Share the tile surface geometry, keep only the tree-depth offset, and lock the header, root-row, tint, and nested-row coordinates with widget tests.

* fix: harden adaptive iOS UI migration

* address greptile review feedback (greploop iteration 1)

* address review feedback (greploop iteration 2)

* address review feedback (greploop iteration 3)

* address review feedback (greploop iteration 4)

* address greptile review feedback (greploop iteration 1)

* address greptile review feedback (greploop iteration 2)

* address greptile review feedback (greploop iteration 3)

* refine iOS native chrome and composer
This commit is contained in:
cogwheel 2026-08-09 12:05:01 +05:30 committed by GitHub
parent 13f6f1d2ba
commit af7bb8a655
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 7129 additions and 1709 deletions

View file

@ -1,11 +1,15 @@
PODS:
- adaptive_platform_ui (0.1.0):
- Flutter
- audio_session (0.0.1):
- Flutter
- CocoaLumberjack (3.9.1):
- CocoaLumberjack/Core (= 3.9.1)
- CocoaLumberjack/Core (3.9.1)
- connectivity_plus (0.0.1):
- Flutter
- CryptoSwift (1.8.4)
- cupertino_native_better (0.0.1):
- Flutter
- SVGKit (~> 3.0)
- file_picker (0.0.1):
- Flutter
- FlutterMacOS
@ -86,6 +90,8 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SVGKit (3.0.0):
- CocoaLumberjack (~> 3.0)
- swift-collections (1.1.1):
- swift-collections/BitCollections (= 1.1.1)
- swift-collections/DequeModule (= 1.1.1)
@ -114,9 +120,9 @@ PODS:
- FlutterMacOS
DEPENDENCIES:
- adaptive_platform_ui (from `.symlinks/plugins/adaptive_platform_ui/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- cupertino_native_better (from `.symlinks/plugins/cupertino_native_better/ios`)
- file_picker (from `.symlinks/plugins/file_picker/darwin`)
- Flutter (from `Flutter`)
- flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`)
@ -145,6 +151,7 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- CocoaLumberjack
- CryptoSwift
- InternalCollectionsUtilities
- libwebp
@ -152,15 +159,16 @@ SPEC REPOS:
- onnxruntime-objc
- SDWebImage
- SDWebImageWebPCoder
- SVGKit
- swift-collections
EXTERNAL SOURCES:
adaptive_platform_ui:
:path: ".symlinks/plugins/adaptive_platform_ui/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
cupertino_native_better:
:path: ".symlinks/plugins/cupertino_native_better/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/darwin"
Flutter:
@ -213,10 +221,11 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
adaptive_platform_ui: cd3c5a2997126f138a1e7f12e8376d3ca3fdbfad
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
CocoaLumberjack: e4ba3b414dfca8c1916c6303d37f63b3a95134c6
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90
cupertino_native_better: 48a6a9cd45efc9ea9e1eec30755b5a8ebe92f1f2
file_picker: 70164d9778c42c47218d6cd79ce435de0856b11a
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_callkit_incoming: a143995975818a3717524decd3566d5ca7a8c608
@ -244,6 +253,7 @@ SPEC CHECKSUMS:
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SVGKit: 1ad7513f8c74d9652f94ed64ddecda1a23864dea
swift-collections: ba248ecaf44ef500b27d5cf94d304218c89892ff
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vad: 7934867589afe53567f492df66fb1615f2185822

View file

@ -2243,316 +2243,6 @@ private func cookieIsPreferred(
return candidate.value < current.value
}
private final class ConduitNativeModelSelectorButtonFactory:
NSObject, FlutterPlatformViewFactory
{
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
ConduitNativeModelSelectorButtonView(
frame: frame,
viewId: viewId,
arguments: args,
messenger: messenger
)
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
deinit {}
}
private final class ConduitNativeModelSelectorButtonView:
NSObject, FlutterPlatformView
{
private let container: UIView
private let button: UIButton
private let channel: FlutterMethodChannel
init(
frame: CGRect,
viewId: Int64,
arguments: Any?,
messenger: FlutterBinaryMessenger
) {
container = UIView(frame: frame)
button = UIButton(type: .system)
channel = FlutterMethodChannel(
name: "app.cogwheel.conduit/native_model_selector_button_\(viewId)",
binaryMessenger: messenger
)
super.init()
let values = arguments as? [String: Any]
let label = values?["label"] as? String ?? ""
let symbolName = values?["symbolName"] as? String
let symbolSize = (values?["symbolSize"] as? NSNumber)?.doubleValue ?? 13
let symbolPadding =
(values?["symbolPadding"] as? NSNumber)?.doubleValue ?? 6
let titleFontSize =
(values?["titleFontSize"] as? NSNumber)?.doubleValue ?? 17
let enabled = (values?["enabled"] as? NSNumber)?.boolValue ?? true
let foreground = Self.color(
fromARGB: values?["foregroundColor"] as? NSNumber
) ?? .label
container.backgroundColor = .clear
button.translatesAutoresizingMaskIntoConstraints = false
button.isEnabled = enabled
button.isAccessibilityElement = false
var configuration: UIButton.Configuration
if #available(iOS 26.0, *) {
configuration = .glass()
} else {
configuration = .plain()
}
configuration.cornerStyle = .capsule
configuration.baseForegroundColor = foreground
configuration.contentInsets = NSDirectionalEdgeInsets(
top: 8,
// The downward chevron's trailing side bearing reads wider than its
// leading edge. Shift the content group one point right optically while
// preserving the 32-point inset budget used by Dart's width resolver.
leading: 17,
bottom: 8,
trailing: 15
)
configuration.imagePlacement = .trailing
configuration.imagePadding = symbolPadding
var attributedTitle = AttributedString(label)
attributedTitle.font = .systemFont(
ofSize: titleFontSize,
weight: .semibold
)
configuration.attributedTitle = attributedTitle
if let symbolName,
let image = UIImage(systemName: symbolName)?.applyingSymbolConfiguration(
UIImage.SymbolConfiguration(pointSize: symbolSize)
) {
configuration.image = image
}
button.configuration = configuration
button.addTarget(self, action: #selector(handlePress), for: .touchUpInside)
container.addSubview(button)
NSLayoutConstraint.activate([
button.leadingAnchor.constraint(equalTo: container.leadingAnchor),
button.trailingAnchor.constraint(equalTo: container.trailingAnchor),
button.topAnchor.constraint(equalTo: container.topAnchor),
button.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
}
func view() -> UIView { container }
@objc private func handlePress() {
channel.invokeMethod("pressed", arguments: nil)
}
private static func color(fromARGB number: NSNumber?) -> UIColor? {
guard let number else { return nil }
let value = number.uint32Value
return UIColor(
red: CGFloat((value >> 16) & 0xff) / 255,
green: CGFloat((value >> 8) & 0xff) / 255,
blue: CGFloat(value & 0xff) / 255,
alpha: CGFloat((value >> 24) & 0xff) / 255
)
}
deinit {}
}
private final class ConduitNativeToolbarActionGroupFactory:
NSObject, FlutterPlatformViewFactory
{
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
ConduitNativeToolbarActionGroupView(
frame: frame,
viewId: viewId,
arguments: args,
messenger: messenger
)
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
deinit {}
}
private final class ConduitNativeToolbarActionGroupView:
NSObject, FlutterPlatformView
{
private let container: UIView
private let toolbar: UIToolbar
private let channel: FlutterMethodChannel
init(
frame: CGRect,
viewId: Int64,
arguments: Any?,
messenger: FlutterBinaryMessenger
) {
container = UIView(frame: frame)
toolbar = UIToolbar(frame: frame)
channel = FlutterMethodChannel(
name: "app.cogwheel.conduit/native_toolbar_action_group_\(viewId)",
binaryMessenger: messenger
)
super.init()
container.backgroundColor = .clear
container.clipsToBounds = false
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.clipsToBounds = false
let appearance = UIToolbarAppearance()
appearance.configureWithTransparentBackground()
toolbar.standardAppearance = appearance
if #available(iOS 15.0, *) {
toolbar.scrollEdgeAppearance = appearance
}
let values = arguments as? [String: Any]
let actions = values?["actions"] as? [[String: Any]] ?? []
let symbolSize =
(values?["symbolSize"] as? NSNumber)?.doubleValue ?? 20
let items = actions.enumerated().compactMap { index, action in
Self.makeBarButtonItem(
action,
actionIndex: index,
symbolSize: symbolSize,
channel: channel
)
}
toolbar.items = [.flexibleSpace()] + items + [.flexibleSpace()]
container.addSubview(toolbar)
NSLayoutConstraint.activate([
toolbar.leadingAnchor.constraint(equalTo: container.leadingAnchor),
toolbar.trailingAnchor.constraint(equalTo: container.trailingAnchor),
toolbar.topAnchor.constraint(equalTo: container.topAnchor),
toolbar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
}
func view() -> UIView { container }
private static func makeBarButtonItem(
_ action: [String: Any],
actionIndex: Int,
symbolSize: Double,
channel: FlutterMethodChannel
) -> UIBarButtonItem? {
let actionSymbolSize =
(action["symbolSize"] as? NSNumber)?.doubleValue ?? symbolSize
guard let symbolName = action["iosSymbol"] as? String,
let image = UIImage(systemName: symbolName)?
.applyingSymbolConfiguration(
UIImage.SymbolConfiguration(
pointSize: actionSymbolSize,
weight: .regular
)
) else {
return nil
}
let menuValues = action["menuItems"] as? [[String: Any]] ?? []
let menu = menuValues.isEmpty ? nil : UIMenu(
children: menuValues.enumerated().map { itemIndex, item in
var attributes: UIMenuElement.Attributes = []
if item["isDestructive"] as? Bool == true {
attributes.insert(.destructive)
}
if item["enabled"] as? Bool == false {
attributes.insert(.disabled)
}
let state: UIMenuElement.State =
item["isChecked"] as? Bool == true ? .on : .off
let itemImage = (item["iosSymbol"] as? String).flatMap {
UIImage(systemName: $0)
}
return UIAction(
title: item["label"] as? String ?? "",
image: itemImage,
attributes: attributes,
state: state
) { _ in
channel.invokeMethod(
"menuItemSelected",
arguments: [
"actionIndex": actionIndex,
"itemIndex": itemIndex,
]
)
}
}
)
let primaryAction: UIAction? = menu == nil
? UIAction { _ in
channel.invokeMethod(
"actionTapped",
arguments: ["actionIndex": actionIndex]
)
}
: nil
let item = UIBarButtonItem(
image: image,
primaryAction: primaryAction,
menu: menu
)
item.accessibilityLabel = action["accessibilityLabel"] as? String
item.isEnabled = action["enabled"] as? Bool ?? true
item.tintColor = color(fromARGB: action["tintColor"] as? NSNumber)
if #available(iOS 26.0, *) {
item.sharesBackground = true
}
return item
}
private static func color(fromARGB number: NSNumber?) -> UIColor? {
guard let number else { return nil }
let value = number.uint32Value
return UIColor(
red: CGFloat((value >> 16) & 0xff) / 255,
green: CGFloat((value >> 8) & 0xff) / 255,
blue: CGFloat(value & 0xff) / 255,
alpha: CGFloat((value >> 24) & 0xff) / 255
)
}
deinit {}
}
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var backgroundStreamingHandler: BackgroundStreamingHandler?
@ -2670,9 +2360,6 @@ private final class ConduitNativeToolbarActionGroupView:
guard sharedFlutterEngine == nil else { return }
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
registerConduitNativeToolbarPlatformViews { pluginName in
engineBridge.pluginRegistry.registrar(forPlugin: pluginName)
}
configureApplicationFlutterChannels(
messenger: engineBridge.applicationRegistrar.messenger()
)
@ -2724,38 +2411,10 @@ private final class ConduitNativeToolbarActionGroupView:
guard !didConfigureSharedFlutterEngine else { return }
GeneratedPluginRegistrant.register(with: engine)
registerConduitNativeToolbarPlatformViews { pluginName in
engine.registrar(forPlugin: pluginName)
}
configureApplicationFlutterChannels(messenger: engine.binaryMessenger)
didConfigureSharedFlutterEngine = true
}
private func registerConduitNativeToolbarPlatformViews(
registrarForPlugin: (String) -> FlutterPluginRegistrar?
) {
if let registrar = registrarForPlugin(
"ConduitNativeModelSelectorButton"
) {
registrar.register(
ConduitNativeModelSelectorButtonFactory(
messenger: registrar.messenger()
),
withId: "app.cogwheel.conduit/native_model_selector_button"
)
}
if let registrar = registrarForPlugin(
"ConduitNativeToolbarActionGroup"
) {
registrar.register(
ConduitNativeToolbarActionGroupFactory(
messenger: registrar.messenger()
),
withId: "app.cogwheel.conduit/native_toolbar_action_group"
)
}
}
private func configureApplicationFlutterChannels(
messenger: FlutterBinaryMessenger
) {

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

View file

@ -27,6 +27,7 @@ import '../../shared/widgets/drawer_shell_page.dart';
import '../../features/navigation/views/splash_launcher_page.dart';
import '../../features/notes/views/notes_list_page.dart';
import '../../shared/widgets/adaptive_route_shell.dart';
import '../../shared/widgets/platform_ui/platform_ui.dart';
import '../../features/channels/views/channel_page.dart';
import '../../features/notes/views/note_editor_page.dart';
import '../../features/profile/views/about_page.dart';
@ -686,7 +687,10 @@ final goRouterProvider = Provider<GoRouter>((ref) {
refreshListenable: notifier,
redirect: notifier.redirect,
routes: appRoutes,
observers: [NavigationLoggingObserver()],
observers: [
NavigationLoggingObserver(),
if (PlatformUiCapabilities.usesNativeIOS26) CNTabBarRouteObserver(),
],
errorBuilder: (context, state) {
final l10n = AppLocalizations.of(context);
final message =

View file

@ -1,6 +1,6 @@
import 'dart:math' as math;
import 'dart:ui' show FlutterView;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:conduit/core/services/haptic_service.dart';

View file

@ -67,6 +67,23 @@ class NativeSheetAvatarBytesHydrator {
static const int _maxAvatarBytes = 1024 * 1024;
static const Duration _defaultHydrationBudget = Duration(milliseconds: 750);
/// Loads one native-control avatar through the same session-scoped,
/// bounded cache used by native sheets.
Future<Uint8List?> loadAvatarBytes({
required ApiService api,
required String avatarUrl,
Duration? maxWait,
}) async {
final normalizedUrl = avatarUrl.trim();
if (normalizedUrl.isEmpty || normalizedUrl.startsWith('data:image')) {
return null;
}
final load = _loadAvatarBytes(api, normalizedUrl);
return maxWait == null
? load
: load.timeout(maxWait, onTimeout: () => null);
}
/// Removes same-server URLs that native URLSession cannot load with Dart's
/// custom TLS identity/trust configuration. The original options remain the
/// source for Dart-side hydration; native receives initials until bytes are

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:conduit/l10n/app_localizations.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../shared/theme/theme_extensions.dart';

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting;
import 'package:flutter/material.dart';

View file

@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:io' show File, HandshakeException, HttpException, SocketException;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting;

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting;
import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

View file

@ -3,7 +3,7 @@ import 'dart:developer' as developer;
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -363,7 +363,6 @@ class _ChannelPageState extends ConsumerState<ChannelPage> {
/// with file, photo, and camera actions.
Widget _buildAttachmentButton(double size, {String? parentMessageId}) {
final l10n = AppLocalizations.of(context);
final theme = context.conduitTheme;
return AdaptivePopupMenuButton.widget<String>(
items: [
@ -387,18 +386,14 @@ class _ChannelPageState extends ConsumerState<ChannelPage> {
entry.value as String,
parentMessageId: parentMessageId,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.surfaceContainerHighest,
border: Border.all(color: theme.cardBorder, width: BorderWidth.thin),
),
child: SizedBox.square(
dimension: size,
child: Icon(
Platform.isIOS ? CupertinoIcons.add : Icons.add,
size: IconSize.large,
color: theme.textPrimary.withValues(alpha: Alpha.strong),
color: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
),
),
),
);
@ -1129,7 +1124,7 @@ class _ChannelPageState extends ConsumerState<ChannelPage> {
required double maxWidth,
}) {
final label = channel?.name ?? '';
final textStyle = conduitAdaptiveToolbarPillTextStyle(context);
final textStyle = conduitAdaptiveToolbarLeadingTitleTextStyle(context);
final controlExtent = conduitScaledControlExtent(context);
final iconExtent = conduitScaledIconExtent(context, IconSize.appBar);
final leadingIcon = channel?.isPrivate == true
@ -1240,9 +1235,6 @@ class _ChannelPageState extends ConsumerState<ChannelPage> {
Channel? channel,
AppLocalizations? l10n,
) {
final textScaler = MediaQuery.textScalerOf(context);
final controlExtent = conduitScaledControlExtent(context);
final toolbarHeight = conduitAdaptiveToolbarHeightOf(context);
final memberCount = channel?.userCount;
final maxTitleWidth = resolveConduitAdaptiveLeadingPillWidth(
context,
@ -1250,18 +1242,6 @@ class _ChannelPageState extends ConsumerState<ChannelPage> {
maxWidth: kConduitAdaptiveToolbarMaxPillWidth,
);
final tintColor = theme.textPrimary;
const leadingGap = kConduitAdaptiveToolbarLeadingGap;
final leading = buildConduitAdaptiveToolbarLeadingRow(
children: [
ConduitAdaptiveAppBarIconButton(
icon: Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu,
onPressed: _toggleDrawer,
iconColor: tintColor,
),
const SizedBox(width: leadingGap),
_buildChannelTitlePill(context, channel, maxWidth: maxTitleWidth),
],
);
final menuItems = _buildChannelToolbarMenuItems(l10n);
void onMenuSelected(String action) =>
_handleChannelToolbarSelection(action, channel);
@ -1276,7 +1256,6 @@ class _ChannelPageState extends ConsumerState<ChannelPage> {
accessibilityLabel:
l10n?.more ?? MaterialLocalizations.of(context).moreButtonTooltip,
tintColor: tintColor,
symbolSize: kConduitNativeToolbarSymbolExtent,
items: menuItems,
onSelected: onMenuSelected,
);
@ -1292,52 +1271,25 @@ class _ChannelPageState extends ConsumerState<ChannelPage> {
l10n?.channelMembersTitle(memberCount) ??
'Members ($memberCount)',
tintColor: tintColor,
symbolSize: kConduitNativeToolbarSymbolExtent,
onPressed: _showMemberList,
),
?nativeMenuAction,
];
final overlayStyle = Theme.of(context).appBarTheme.systemOverlayStyle;
final scaledLeading = ConduitSystemTextScaling(
textScaler: textScaler,
child: leading,
);
final scaledActions = [
for (final action in actions)
ConduitSystemTextScaling(textScaler: textScaler, child: action),
];
return AdaptiveAppBar(
useNativeToolbar: false,
return buildConduitCenteredAdaptiveAppBar(
context: context,
tintColor: tintColor,
cupertinoNavigationBar: ConduitAdaptiveCupertinoNavigationBar(
textScaler: textScaler,
leading: leading,
trailing: useNativeActionGroup
? ConduitNativeToolbarActionGroup(actions: nativeActions)
: Row(mainAxisSize: MainAxisSize.min, children: actions),
systemOverlayStyle: overlayStyle,
),
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: Elevation.none,
scrolledUnderElevation: Elevation.none,
toolbarHeight: toolbarHeight,
systemOverlayStyle: overlayStyle,
centerTitle: false,
titleSpacing: Spacing.sm,
leadingWidth: resolveConduitAdaptiveToolbarLeadingWidth(
pillWidth: maxTitleWidth,
leadingGap: leadingGap,
controlExtent: controlExtent,
),
leading: scaledLeading,
actions: scaledActions,
leading: ConduitAdaptiveAppBarIconButton(
icon: Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu,
iosSymbol: 'line.3.horizontal',
onPressed: _toggleDrawer,
iconColor: tintColor,
),
title: _buildChannelTitlePill(context, channel, maxWidth: maxTitleWidth),
actions: actions,
cupertinoTrailing: useNativeActionGroup
? ConduitNativeToolbarActionGroup(actions: nativeActions)
: Row(mainAxisSize: MainAxisSize.min, children: actions),
centerTitle: false,
);
}

View file

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../../../core/widgets/error_boundary.dart';
import '../../../shared/theme/conduit_input_styles.dart';
@ -2326,9 +2326,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
? CupertinoIcons.chevron_down
: Icons.keyboard_arrow_down;
const buttonSize = 40.0;
final iconSize = conduitSupportsNativeGlass()
? kConduitNativeUtilitySymbolExtent
: IconSize.medium;
const iconSize = IconSize.large;
final theme = context.conduitTheme;
final usesOpaqueFallback = conduitUsesOpaqueGlassFallback();
final style = usesOpaqueFallback
@ -2338,11 +2336,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
if (conduitSupportsNativeGlass()) {
return AdaptiveButton.sfSymbol(
onPressed: _userScrollToBottom,
sfSymbol: SFSymbol(
'chevron.down',
size: iconSize,
color: theme.textPrimary,
),
sfSymbol: SFSymbol('chevron.down', color: theme.textPrimary),
style: style,
size: AdaptiveButtonSize.medium,
minSize: const Size.square(buttonSize),
@ -3529,20 +3523,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
required bool isLoadingConversation,
required String modelLabel,
}) {
final textScaler = MediaQuery.textScalerOf(context);
final controlExtent = conduitScaledControlExtent(context);
final toolbarHeight = conduitAdaptiveToolbarHeightOf(context);
final activeConversation = ref.watch(activeConversationProvider);
final isTemporary = ref.watch(temporaryChatEnabledProvider);
final hasMessages = ref.watch(hasChatMessagesProvider);
final showNewChatAction = activeConversation != null || hasMessages;
final tintColor = context.conduitTheme.textPrimary;
const leadingGap = kConduitAdaptiveToolbarLeadingGap;
final trailingActionCount = (showNewChatAction ? 1 : 0) + 1;
final maxModelWidth = resolveConduitAdaptiveLeadingPillWidth(
context,
trailingActionCount: trailingActionCount,
maxWidth: kConduitAdaptiveToolbarMaxPillWidth,
maxWidth: kConduitAdaptiveToolbarMaxModelSelectorWidth,
);
// Hide the picker only for a true single-agent Hermes-only install. Mixed
// setups must retain a way to switch back to an OpenWebUI model.
@ -3551,11 +3541,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
selectedModel: selectedModel,
isHermesOnly: ref.watch(hermesOnlyModeProvider),
);
final leading = _buildNativeToolbarLeading(
final title = _buildChatToolbarTitle(
context: context,
isLoadingConversation: isLoadingConversation,
modelLabel: modelLabel,
leadingGap: leadingGap,
maxModelWidth: maxModelWidth,
showModelDropdown: showModelDropdown,
);
@ -3572,7 +3561,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final useNativeActionGroup =
Platform.isIOS &&
conduitSupportsNativeGlass() &&
actionDescriptors.length == 2;
actionDescriptors.isNotEmpty &&
actionDescriptors.length <= 3;
final cupertinoTrailing = useNativeActionGroup
? ConduitNativeToolbarActionGroup(
actions: [
@ -3580,75 +3570,36 @@ class _ChatPageState extends ConsumerState<ChatPage> {
],
)
: Row(mainAxisSize: MainAxisSize.min, children: actionWidgets);
final leadingWidth = resolveConduitAdaptiveToolbarLeadingWidth(
pillWidth: maxModelWidth,
leadingGap: leadingGap,
controlExtent: controlExtent,
);
final overlayStyle = Theme.of(context).appBarTheme.systemOverlayStyle;
final scaledLeading = ConduitSystemTextScaling(
textScaler: textScaler,
child: leading,
);
final scaledActions = [
for (final action in actionWidgets)
ConduitSystemTextScaling(textScaler: textScaler, child: action),
];
return AdaptiveAppBar(
useNativeToolbar: false,
return buildConduitCenteredAdaptiveAppBar(
context: context,
tintColor: tintColor,
cupertinoNavigationBar: ConduitAdaptiveCupertinoNavigationBar(
textScaler: textScaler,
leading: leading,
trailing: cupertinoTrailing,
systemOverlayStyle: overlayStyle,
),
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: Elevation.none,
scrolledUnderElevation: Elevation.none,
toolbarHeight: toolbarHeight,
systemOverlayStyle: overlayStyle,
centerTitle: false,
titleSpacing: Spacing.sm,
leadingWidth: leadingWidth,
leading: scaledLeading,
actions: scaledActions,
leading: ConduitAdaptiveAppBarIconButton(
key: const ValueKey('chat-sidebar-toggle'),
icon: Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu,
iosSymbol: 'line.3.horizontal',
onPressed: () => _toggleResponsiveDrawer(context),
iconColor: tintColor,
),
title: title,
actions: actionWidgets,
cupertinoTrailing: cupertinoTrailing,
centerTitle: false,
);
}
Widget _buildNativeToolbarLeading({
Widget _buildChatToolbarTitle({
required BuildContext context,
required bool isLoadingConversation,
required String modelLabel,
required double leadingGap,
required double maxModelWidth,
required bool showModelDropdown,
}) {
return buildConduitAdaptiveToolbarLeadingRow(
children: [
ConduitAdaptiveAppBarIconButton(
key: const ValueKey('chat-sidebar-toggle'),
icon: Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu,
iosSymbol: 'line.3.horizontal',
iosSymbolSize: kConduitNativeSidebarSymbolExtent,
onPressed: () => _toggleResponsiveDrawer(context),
iconColor: context.conduitTheme.textPrimary,
),
SizedBox(width: leadingGap),
ConduitAdaptiveAppBarModelSelector(
label: modelLabel,
maxWidth: maxModelWidth,
isLoading: isLoadingConversation,
showChevron: showModelDropdown,
onPressed: () => _openModelSelector(context),
),
],
return ConduitAdaptiveAppBarModelSelector(
label: modelLabel,
maxWidth: maxModelWidth,
isLoading: isLoadingConversation,
showChevron: showModelDropdown,
onPressed: () => _openModelSelector(context),
);
}
@ -3701,7 +3652,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
required String accessibilityLabel,
required Color tintColor,
required VoidCallback? onPressed,
double iosSymbolSize = kConduitNativeToolbarSymbolExtent,
}) {
final iosSymbol = conduitToolbarSfSymbolForIcon(icon);
assert(iosSymbol != null);
@ -3709,7 +3659,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
widget: ConduitAdaptiveAppBarIconButton(
icon: icon,
iosSymbol: iosSymbol,
iosSymbolSize: iosSymbolSize,
iconColor: tintColor,
onPressed: onPressed,
),
@ -3717,7 +3666,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
iosSymbol: iosSymbol!,
accessibilityLabel: accessibilityLabel,
tintColor: tintColor,
symbolSize: iosSymbolSize,
enabled: onPressed != null,
onPressed: onPressed,
),
@ -3752,7 +3700,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
: (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility_outlined),
accessibilityLabel: AppLocalizations.of(context)!.temporaryChat,
tintColor: isTemporary ? Colors.blue : tintColor,
iosSymbolSize: kConduitNativeVisibilitySymbolExtent,
onPressed: () {
ConduitHaptics.selectionClick();
final current = ref.read(temporaryChatEnabledProvider);

View file

@ -1,6 +1,6 @@
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -2277,7 +2277,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
}
}
},
buttonStyle: PopupButtonStyle.plain,
child: AdaptiveTooltip(
message: l10n.more,
waitDuration: const Duration(milliseconds: 600),

View file

@ -1,6 +1,6 @@
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import '../../../shared/theme/theme_extensions.dart';
import 'package:flutter/cupertino.dart';

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:collection';
import 'dart:io' show HttpClient, HttpHeaders, Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';

View file

@ -1,7 +1,7 @@
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/core/services/haptic_service.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'package:flutter/cupertino.dart';

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -142,21 +142,15 @@ class _FolderPageState extends ConsumerState<FolderPage> {
Folder? folder,
) {
final tintColor = context.conduitTheme.textPrimary;
final textScaler = MediaQuery.textScalerOf(context);
final controlExtent = conduitScaledControlExtent(context);
final toolbarHeight = conduitAdaptiveToolbarHeightOf(context);
final hasOverflowMenu = folder != null;
const leadingGap = kConduitAdaptiveToolbarLeadingGap;
final maxModelWidth = resolveConduitAdaptiveLeadingPillWidth(
context,
trailingActionCount: hasOverflowMenu ? 3 : 2,
maxWidth: kConduitAdaptiveToolbarMaxPillWidth,
maxWidth: kConduitAdaptiveToolbarMaxModelSelectorWidth,
);
final leading = _buildFolderToolbarLeading(
final title = _buildFolderToolbarTitle(
context: context,
l10n: l10n,
tintColor: tintColor,
leadingGap: leadingGap,
maxModelWidth: maxModelWidth,
);
final isTemporary = ref.watch(temporaryChatEnabledProvider);
@ -182,7 +176,6 @@ class _FolderPageState extends ConsumerState<FolderPage> {
iosSymbol: 'ellipsis',
accessibilityLabel: l10n.more,
tintColor: tintColor,
symbolSize: kConduitNativeToolbarSymbolExtent,
items: menuItems,
onSelected: onMenuSelected,
);
@ -191,14 +184,12 @@ class _FolderPageState extends ConsumerState<FolderPage> {
iosSymbol: isTemporary ? 'eye.slash' : 'eye',
accessibilityLabel: l10n.temporaryChat,
tintColor: isTemporary ? Colors.blue : tintColor,
symbolSize: kConduitNativeVisibilitySymbolExtent,
onPressed: _toggleTemporaryChat,
),
ConduitNativeToolbarAction(
iosSymbol: 'square.and.pencil',
accessibilityLabel: l10n.newChat,
tintColor: tintColor,
symbolSize: kConduitNativeToolbarSymbolExtent,
onPressed: _handleNewChat,
),
?nativeMenuAction,
@ -207,77 +198,39 @@ class _FolderPageState extends ConsumerState<FolderPage> {
Platform.isIOS &&
conduitSupportsNativeGlass() &&
(folder == null || nativeMenuAction != null);
final leadingWidth = resolveConduitAdaptiveToolbarLeadingWidth(
pillWidth: maxModelWidth,
leadingGap: leadingGap,
controlExtent: controlExtent,
);
final overlayStyle = Theme.of(context).appBarTheme.systemOverlayStyle;
final scaledLeading = ConduitSystemTextScaling(
textScaler: textScaler,
child: leading,
);
final scaledActions = [
for (final action in actions)
ConduitSystemTextScaling(textScaler: textScaler, child: action),
];
return AdaptiveAppBar(
useNativeToolbar: false,
return buildConduitCenteredAdaptiveAppBar(
context: context,
tintColor: tintColor,
cupertinoNavigationBar: ConduitAdaptiveCupertinoNavigationBar(
textScaler: textScaler,
leading: leading,
trailing: useNativeActionGroup
? ConduitNativeToolbarActionGroup(actions: nativeActions)
: Row(mainAxisSize: MainAxisSize.min, children: actions),
systemOverlayStyle: overlayStyle,
),
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: Elevation.none,
scrolledUnderElevation: Elevation.none,
toolbarHeight: toolbarHeight,
systemOverlayStyle: overlayStyle,
centerTitle: false,
titleSpacing: Spacing.sm,
leadingWidth: leadingWidth,
leading: scaledLeading,
actions: scaledActions,
leading: ConduitAdaptiveAppBarIconButton(
key: const ValueKey<String>('folder-page-drawer-button'),
icon: Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu,
iosSymbol: 'line.3.horizontal',
onPressed: _toggleDrawer,
iconColor: tintColor,
),
title: title,
actions: actions,
cupertinoTrailing: useNativeActionGroup
? ConduitNativeToolbarActionGroup(actions: nativeActions)
: Row(mainAxisSize: MainAxisSize.min, children: actions),
centerTitle: false,
);
}
Widget _buildFolderToolbarLeading({
Widget _buildFolderToolbarTitle({
required BuildContext context,
required AppLocalizations l10n,
required Color tintColor,
required double leadingGap,
required double maxModelWidth,
}) {
final label = _formatModelDisplayName(
ref.watch(selectedModelProvider)?.name ?? l10n.chooseModel,
);
return buildConduitAdaptiveToolbarLeadingRow(
children: [
ConduitAdaptiveAppBarIconButton(
key: const ValueKey<String>('folder-page-drawer-button'),
icon: Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu,
onPressed: _toggleDrawer,
iconColor: tintColor,
),
SizedBox(width: leadingGap),
ConduitAdaptiveAppBarModelSelector(
key: const ValueKey<String>('folder-page-model-selector'),
label: label,
maxWidth: maxModelWidth,
onPressed: _showModelSelector,
),
],
return ConduitAdaptiveAppBarModelSelector(
key: const ValueKey<String>('folder-page-model-selector'),
label: label,
maxWidth: maxModelWidth,
onPressed: _showModelSelector,
);
}

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:conduit/core/services/haptic_service.dart';
@ -434,9 +434,16 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
void _onSearchChanged() {
_debounce?.cancel();
final query = _sidebarSearchController.text.trim();
if (query.isEmpty) {
if (mounted && _query.isNotEmpty) {
setState(() => _query = '');
}
return;
}
_debounce = Timer(const Duration(milliseconds: 250), () {
if (!mounted) return;
setState(() => _query = _sidebarSearchController.text.trim());
setState(() => _query = query);
});
}
@ -604,20 +611,16 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
...ancestorHasMoreSiblings,
hasMoreSiblings,
];
final slivers = <Widget>[
SliverPadding(
padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md),
sliver: SliverToBoxAdapter(
child: _buildFolderHeader(
folder: folder,
allFolders: allFolders,
depth: depth,
hasMoreSiblings: hasMoreSiblings,
ancestorHasMoreSiblings: ancestorHasMoreSiblings,
),
),
final folderHeaderSliver = SliverToBoxAdapter(
child: _buildFolderHeader(
folder: folder,
allFolders: allFolders,
depth: depth,
hasMoreSiblings: hasMoreSiblings,
ancestorHasMoreSiblings: ancestorHasMoreSiblings,
),
];
);
final slivers = <Widget>[folderHeaderSliver];
final hasExpandableContent =
childFolders.isNotEmpty || conversations.isNotEmpty || isFolderLoading;
@ -654,12 +657,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
if (childFolders.isNotEmpty && hasTrailingChildContent) {
slivers.add(
SliverPadding(
padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md),
sliver: SliverToBoxAdapter(
child: FolderTreeIntergroupGap(
ancestorHasMoreSiblings: nextAncestorHasMoreSiblings,
),
SliverToBoxAdapter(
child: FolderTreeIntergroupGap(
ancestorHasMoreSiblings: nextAncestorHasMoreSiblings,
),
),
);
@ -668,22 +668,19 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
if (isFolderLoading && conversations.isEmpty) {
slivers.add(
SliverPadding(
padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md),
sliver: SliverPadding(
padding: EdgeInsets.only(
left:
(nextAncestorHasMoreSiblings.length + 1) *
FolderTreeHierarchyNode.segmentWidth,
),
sliver: const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: Spacing.sm),
child: Center(
child: SizedBox(
width: IconSize.sm,
height: IconSize.sm,
child: CircularProgressIndicator(strokeWidth: 2),
),
padding: EdgeInsets.only(
left:
(nextAncestorHasMoreSiblings.length + 1) *
FolderTreeHierarchyNode.segmentWidth,
),
sliver: const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: Spacing.sm),
child: Center(
child: SizedBox(
width: IconSize.sm,
height: IconSize.sm,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
@ -692,14 +689,11 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
);
} else if (conversations.isNotEmpty) {
slivers.add(
SliverPadding(
padding: const EdgeInsets.only(left: Spacing.md, right: Spacing.md),
sliver: _conversationsSliver(
conversations,
ancestorHasMoreSiblings: nextAncestorHasMoreSiblings,
foldersEnabled: true,
folders: allFolders,
),
_conversationsSliver(
conversations,
ancestorHasMoreSiblings: nextAncestorHasMoreSiblings,
foldersEnabled: true,
folders: allFolders,
),
);
if (!suppressTrailingConversationGap) {
@ -1217,6 +1211,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
mainAxisSize: MainAxisSize.min,
children: [
Icon(
key: const ValueKey<String>('folders-section-leading'),
_chatsDrawerDisclosureIcon(isExpanded),
color: theme.iconSecondary,
size: IconSize.listItem,
@ -1236,13 +1231,20 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
),
const Spacer(),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: AppLocalizations.of(context)!.newFolder,
constraints: const BoxConstraints.tightFor(
width: TouchTarget.minimum,
height: TouchTarget.minimum,
),
padding: const EdgeInsets.all(
(TouchTarget.minimum - IconSize.md) / 2,
),
icon: Icon(
Platform.isIOS
? CupertinoIcons.folder_badge_plus
: Icons.create_new_folder_outlined,
color: theme.iconPrimary,
size: IconSize.md,
),
onPressed: () =>
CreateFolderDialog.show(context, ref, onError: _showDrawerError),
@ -1269,12 +1271,6 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
final expandedMap = ref.watch(expandedFoldersProvider);
final isExpanded = expandedMap[folderId] ?? folder.isExpanded;
final isCurrentFolder = NavigationService.currentFolderId == folderId;
final baseColor = isCurrentFolder
? theme.navigationSelectedBackground
: theme.surfaceContainer;
final borderColor = isCurrentFolder
? theme.navigationSelected.withValues(alpha: 0.7)
: theme.surfaceContainerHighest.withValues(alpha: 0.40);
final rowContent = GestureDetector(
behavior: HitTestBehavior.opaque,
@ -1282,66 +1278,79 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer>
onTap: () => _openFolderPage(folderId),
onLongPress: null, // Handled by ConduitContextMenu
child: Container(
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(color: borderColor, width: BorderWidth.thin),
),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: TouchTarget.listItem,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
key: ValueKey<String>('folder-surface-$folderId'),
margin: kConversationTileMargin,
child: ConversationTileSurface(
theme: theme,
selected: isCurrentFolder,
tintKey: ValueKey<String>('folder-active-tint-$folderId'),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: TouchTarget.listItem,
),
child: Row(
children: [
FolderIconGlyph(
iconAlias: folder.meta?['icon']?.toString(),
isOpen: isExpanded,
size: IconSize.listItem,
color: theme.iconPrimary,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.sidebarTitleStyle.copyWith(
color: theme.textPrimary,
fontWeight: isCurrentFolder
? FontWeight.w600
: FontWeight.w400,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xxs,
),
child: Row(
children: [
FolderIconGlyph(
key: ValueKey<String>('folder-icon-$folderId'),
iconAlias: folder.meta?['icon']?.toString(),
isOpen: isExpanded,
size: IconSize.listItem,
color: isCurrentFolder
? theme.iconPrimary
: theme.iconSecondary,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.sidebarTitleStyle.copyWith(
color: isCurrentFolder
? theme.textPrimary
: theme.textSecondary,
fontWeight: isCurrentFolder
? FontWeight.w600
: FontWeight.w400,
height: 1.4,
),
),
),
),
const SizedBox(width: Spacing.sm),
SizedBox(
width: 22,
height: 22,
child: IconButton(
key: ValueKey<String>('folder-expand-$folderId'),
iconSize: IconSize.xs,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
style: IconButton.styleFrom(
shape: const CircleBorder(),
const SizedBox(width: Spacing.sm),
SizedBox(
width: TouchTarget.minimum,
height: TouchTarget.minimum,
child: IconButton(
key: ValueKey<String>('folder-expand-$folderId'),
iconSize: IconSize.sm,
padding: const EdgeInsets.all(
(TouchTarget.minimum - IconSize.sm) / 2,
),
constraints: const BoxConstraints.tightFor(
width: TouchTarget.minimum,
height: TouchTarget.minimum,
),
style: IconButton.styleFrom(
shape: const CircleBorder(),
),
icon: Icon(
_chatsDrawerDisclosureIcon(isExpanded),
color: theme.iconSecondary,
size: IconSize.sm,
),
onPressed: () {
ConduitHaptics.selectionClick();
_setFolderExpanded(folderId, !isExpanded);
},
),
icon: Icon(
_chatsDrawerDisclosureIcon(isExpanded),
color: theme.iconSecondary,
size: IconSize.listItem,
),
onPressed: () {
ConduitHaptics.selectionClick();
_setFolderExpanded(folderId, !isExpanded);
},
),
),
],
],
),
),
),
),

View file

@ -5,6 +5,69 @@ import 'package:flutter/material.dart';
import '../../../shared/theme/theme_extensions.dart';
const EdgeInsets kConversationTileMargin = EdgeInsets.only(
right: Spacing.xs,
top: Spacing.xxs,
bottom: Spacing.xxs,
);
const double kConversationTileTintInset = Spacing.sm;
BoxDecoration conduitConversationTileDecoration(
ConduitThemeExtension theme, {
required bool selected,
}) {
final background = selected
? Color.alphaBlend(
theme.buttonPrimary.withValues(alpha: 0.1),
theme.surfaceBackground,
)
: theme.surfaceBackground;
return BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
);
}
class ConversationTileSurface extends StatelessWidget {
const ConversationTileSurface({
super.key,
required this.theme,
required this.selected,
required this.child,
this.tintKey,
});
final ConduitThemeExtension theme;
final bool selected;
final Widget child;
final Key? tintKey;
@override
Widget build(BuildContext context) {
return Stack(
children: [
if (selected)
Positioned.fill(
left: kConversationTileTintInset,
// The outer tile preserves its historical 4pt trailing margin.
// Compensate here so the painted tint has the same physical inset
// on both sides without moving the row contents.
right: kConversationTileTintInset - kConversationTileMargin.right,
child: DecoratedBox(
key: tintKey,
decoration: conduitConversationTileDecoration(
theme,
selected: true,
),
),
),
child,
],
);
}
}
/// Drag feedback widget shown while dragging a conversation tile.
class ConversationDragFeedback extends StatelessWidget {
/// The conversation title.
@ -251,51 +314,37 @@ class ConversationTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final borderRadius = BorderRadius.circular(AppBorderRadius.card);
// Match the chats drawer scroll surface (surfaceBackground), not
// sidebarTheme.background, so tiles align in light and dark.
final Color baseBackground = theme.surfaceBackground;
final Color background = selected
? Color.alphaBlend(
theme.buttonPrimary.withValues(alpha: 0.1),
baseBackground,
)
: baseBackground;
return Semantics(
selected: selected,
button: true,
child: Container(
margin: const EdgeInsets.only(
left: 0,
right: Spacing.xs,
top: Spacing.xxs,
bottom: Spacing.xxs,
),
decoration: BoxDecoration(
color: background,
borderRadius: borderRadius,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: isLoading ? null : onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: TouchTarget.listItem),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
margin: kConversationTileMargin,
child: ConversationTileSurface(
theme: theme,
selected: selected,
tintKey: const ValueKey<String>('conversation-tile-active-tint'),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: isLoading ? null : onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: TouchTarget.listItem,
),
child: ConversationTileContent(
title: title,
pinned: pinned,
selected: selected,
unread: unread,
isLoading: isLoading,
isGenerating: isGenerating,
badge: badge,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.sm,
),
child: ConversationTileContent(
title: title,
pinned: pinned,
selected: selected,
unread: unread,
isLoading: isLoading,
isGenerating: isGenerating,
badge: badge,
),
),
),
),

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -29,11 +29,9 @@ import 'sidebar_user_pill.dart';
/// Compact bottom bar height on Material (default M3 bar is ~80 logical px).
const double _kSidebarNavigationBarHeight = 56;
const double _kSidebarNavigationBarIconSize = 22;
const double _kSidebarSearchCloseActionReserve = 64;
const double _kSidebarSearchFieldReserve = 96;
const double _kSidebarNativeLeadingVerticalOffset = 3;
// Mirrors adaptive_platform_ui's iPadOS window-control reservation.
// Mirrors Conduit platform UI's iPadOS window-control reservation.
const double _kSidebarWindowedLeadingInset = 62;
const double _kSidebarNativeBottomBarContentHeight = 50;
@ -174,10 +172,7 @@ class _HermesTabImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const ImageIcon(
kHermesTabIcon,
size: _kSidebarNavigationBarIconSize,
);
return const ImageIcon(kHermesTabIcon, size: IconSize.tabBar);
}
}
@ -228,7 +223,7 @@ class _SidebarMaterialBottomNavigationBar extends StatelessWidget {
color: selected
? conduitTheme.buttonPrimary
: conduitTheme.textSecondary,
size: _kSidebarNavigationBarIconSize,
size: IconSize.tabBar,
);
}),
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle?>((states) {
@ -655,13 +650,7 @@ class _SidebarPageState extends ConsumerState<SidebarPage> {
final adaptiveAppBarLeading = useNativeIos26Chrome
? Padding(
padding: EdgeInsets.only(left: windowedLeadingInset),
child: Transform.translate(
offset: const Offset(
0,
_kSidebarNativeLeadingVerticalOffset,
),
child: appBarLeading,
),
child: appBarLeading,
)
: appBarLeading;
@ -679,14 +668,12 @@ class _SidebarPageState extends ConsumerState<SidebarPage> {
bottomNavigationBar: bottomNavigationBar,
leading: adaptiveAppBarLeading,
actions: appBarActions,
minimizeBehavior: TabBarMinimizeBehavior.never,
showNativeView: composeNativeIos26Chrome,
body: sidebarBodyWithSyncProgress,
);
}
return AdaptiveScaffold(
minimizeBehavior: TabBarMinimizeBehavior.never,
appBar: AdaptiveAppBar(
useNativeToolbar: true,
leading: adaptiveAppBarLeading,

View file

@ -1,19 +1,24 @@
import 'dart:convert';
import 'dart:collection';
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/models/user.dart';
import '../../../core/network/image_header_utils.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/providers/backend_mode_providers.dart';
import '../../../core/services/api_service.dart';
import '../../../core/services/native_sheet_bridge.dart';
import '../../../core/services/native_sheet_hydration_service.dart';
import '../../../core/services/navigation_service.dart';
import '../../../core/services/settings_service.dart';
import '../../../core/utils/debug_logger.dart';
@ -30,9 +35,119 @@ import '../../terminal/providers/terminal_providers.dart';
import '../../workspace/providers/workspace_capabilities_provider.dart';
import '../providers/sidebar_providers.dart';
part 'sidebar_user_pill.g.dart';
typedef SidebarNativeProfilePresenter =
Future<bool> Function(NativeProfileSheetConfig config);
const double _sidebarProfileAvatarSize = 36;
const double _sidebarNativeProfileAvatarSize = 28;
const int _sidebarAvatarMaximumSourceDimension = 8192;
const int _sidebarAvatarMaximumSourcePixels = 32 * 1024 * 1024;
const double _sidebarAvatarMaximumAspectRatio = 64;
const int _sidebarAvatarMaximumDecodeDimension = 512;
const int _sidebarAvatarMaximumEncodedBytes = 2 * 1024 * 1024;
@visibleForTesting
Future<Uint8List?> rasterizeSidebarNativeAvatar(
Uint8List bytes, {
required double devicePixelRatio,
}) async {
if (bytes.isEmpty) return null;
final scale = devicePixelRatio.isFinite
? devicePixelRatio.clamp(1.0, 4.0)
: 1.0;
final canvasPixels = (TouchTarget.minimum * scale).round();
final avatarPixels = (_sidebarNativeProfileAvatarSize * scale).round();
ui.ImmutableBuffer? buffer;
ui.ImageDescriptor? descriptor;
ui.Codec? codec;
ui.Image? source;
ui.Image? output;
try {
buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
descriptor = await ui.ImageDescriptor.encoded(buffer);
final sourceWidth = descriptor.width;
final sourceHeight = descriptor.height;
final shortestSide = sourceWidth < sourceHeight
? sourceWidth
: sourceHeight;
final longestSide = sourceWidth > sourceHeight ? sourceWidth : sourceHeight;
final sourcePixels = sourceWidth * sourceHeight;
if (shortestSide <= 0 ||
longestSide > _sidebarAvatarMaximumSourceDimension ||
sourcePixels > _sidebarAvatarMaximumSourcePixels ||
longestSide / shortestSide > _sidebarAvatarMaximumAspectRatio) {
return null;
}
final decodeScale = longestSide > _sidebarAvatarMaximumDecodeDimension
? _sidebarAvatarMaximumDecodeDimension / longestSide
: 1.0;
final targetWidth = (sourceWidth * decodeScale).round().clamp(
1,
sourceWidth,
);
final targetHeight = (sourceHeight * decodeScale).round().clamp(
1,
sourceHeight,
);
codec = await descriptor.instantiateCodec(
targetWidth: targetWidth,
targetHeight: targetHeight,
);
source = (await codec.getNextFrame()).image;
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
final sourceSide = source.width < source.height
? source.width.toDouble()
: source.height.toDouble();
final sourceRect = ui.Rect.fromLTWH(
(source.width - sourceSide) / 2,
(source.height - sourceSide) / 2,
sourceSide,
sourceSide,
);
final inset = (canvasPixels - avatarPixels) / 2;
final destinationRect = ui.Rect.fromLTWH(
inset,
inset,
avatarPixels.toDouble(),
avatarPixels.toDouble(),
);
canvas.drawImageRect(
source,
sourceRect,
destinationRect,
ui.Paint()..filterQuality = ui.FilterQuality.high,
);
final picture = recorder.endRecording();
try {
output = await picture.toImage(canvasPixels, canvasPixels);
} finally {
picture.dispose();
}
final data = await output.toByteData(format: ui.ImageByteFormat.png);
if (data == null) return null;
return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
} catch (error, stackTrace) {
DebugLogger.error(
'Failed to rasterize native sidebar avatar',
error: error,
stackTrace: stackTrace,
scope: 'navigation/sidebar/native-avatar',
);
return null;
} finally {
output?.dispose();
source?.dispose();
codec?.dispose();
descriptor?.dispose();
buffer?.dispose();
}
}
@visibleForTesting
NativeSheetItemConfig buildDirectConnectionsNativeSheetItem({
required String title,
@ -55,6 +170,52 @@ final sidebarNativeProfilePresenterProvider =
return NativeSheetBridge.instance.presentProfileMenu;
});
typedef SidebarNativeAvatarRequest = ({ApiService api, String avatarUrl});
@immutable
class SidebarNativeAvatarRasterRequest {
const SidebarNativeAvatarRasterRequest({
required this.cacheKey,
required this.bytes,
required this.devicePixelRatio,
});
final String cacheKey;
final Uint8List bytes;
final double devicePixelRatio;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidebarNativeAvatarRasterRequest &&
cacheKey == other.cacheKey &&
devicePixelRatio == other.devicePixelRatio;
@override
int get hashCode => Object.hash(cacheKey, devicePixelRatio);
}
@riverpod
Future<Uint8List?> sidebarNativeAvatarBytes(
Ref ref,
SidebarNativeAvatarRequest request,
) => ref
.watch(nativeSheetAvatarBytesHydratorProvider)
.loadAvatarBytes(api: request.api, avatarUrl: request.avatarUrl);
@riverpod
Future<Uint8List?> sidebarNativeAvatarRaster(
Ref ref,
SidebarNativeAvatarRasterRequest request,
) => rasterizeSidebarNativeAvatar(
request.bytes,
devicePixelRatio: request.devicePixelRatio,
);
final _sidebarHermesAvatarBytesProvider = FutureProvider<Uint8List?>((ref) {
return _loadHermesAvatarBytes();
});
/// Cached bytes of the Hermes agent icon, used as the native profile-sheet
/// avatar in Hermes-only mode (loaded once, then reused).
Uint8List? _hermesAvatarBytesCache;
@ -131,31 +292,52 @@ String sidebarSearchHintForActiveTab(WidgetRef ref, AppLocalizations l10n) {
return l10n.searchConversations;
}
/// Builds a compact profile avatar without an iOS 26 child platform view.
/// Builds one stable native glass avatar button on iOS 26 and Flutter
/// platform-family fallbacks everywhere else.
@visibleForTesting
Widget buildSidebarProfileButton({
required bool supportsNativeGlass,
required VoidCallback onPressed,
required AdaptiveButtonStyle fallbackStyle,
Color? fallbackColor,
Uint8List? nativeAvatarBytes,
required Widget child,
}) {
const buttonKey = ValueKey<String>('sidebar-profile-button');
if (supportsNativeGlass) {
// AdaptiveButton.child expands to the native toolbar's full leading slot
// on iOS 26. Keep the custom avatar Flutter-owned so it remains a compact
// tap target and does not add another persistent platform glass surface.
return SizedBox.square(
dimension: TouchTarget.minimum,
child: CupertinoButton(
key: buttonKey,
onPressed: onPressed,
final avatarBytes = nativeAvatarBytes;
final nativeButton = CNButton.icon(
key: avatarBytes == null
? const ValueKey<String>('sidebar-profile-native-placeholder')
: ObjectKey(avatarBytes),
icon: avatarBytes == null
? CNSymbol(
'person.crop.circle.fill',
size: IconSize.large,
color: fallbackColor,
)
: null,
imageAsset: avatarBytes == null
? null
: CNImageAsset(
'',
imageData: avatarBytes,
size: _sidebarNativeProfileAvatarSize,
),
onPressed: onPressed,
config: const CNButtonConfig(
padding: EdgeInsets.zero,
minimumSize: const Size.square(TouchTarget.minimum),
child: child,
minHeight: TouchTarget.minimum,
width: TouchTarget.minimum,
style: CNButtonStyle.glass,
),
);
return SizedBox.square(
key: buttonKey,
dimension: TouchTarget.minimum,
child: nativeButton,
);
}
return AdaptiveButton.child(
@ -175,8 +357,6 @@ Widget buildSidebarProfileButton({
class SidebarProfileAppBarLeading extends ConsumerWidget {
const SidebarProfileAppBarLeading({super.key});
static const double _avatarSize = 36;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = resolveSidebarUser(ref);
@ -214,12 +394,47 @@ class SidebarProfileAppBarLeading extends ConsumerWidget {
final style = useOpaqueFallback
? AdaptiveButtonStyle.plain
: AdaptiveButtonStyle.glass;
final inlineAvatarBytes = _decodeDataImage(avatarUrl);
final supportsNativeGlass = conduitSupportsNativeGlass();
final rawNativeAvatarBytes = !supportsNativeGlass
? null
: hermesOnly
? ref.watch(_sidebarHermesAvatarBytesProvider).asData?.value
: inlineAvatarBytes ??
(api == null || avatarUrl == null || avatarUrl.isEmpty
? null
: ref
.watch(
sidebarNativeAvatarBytesProvider((
api: api,
avatarUrl: avatarUrl,
)),
)
.asData
?.value);
final nativeAvatarBytes = rawNativeAvatarBytes == null
? null
: ref
.watch(
sidebarNativeAvatarRasterProvider(
SidebarNativeAvatarRasterRequest(
cacheKey:
'${avatarUrl ?? 'hermes'}:'
'${rawNativeAvatarBytes.length}:'
'${identityHashCode(rawNativeAvatarBytes)}',
bytes: rawNativeAvatarBytes,
devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
),
),
)
.asData
?.value;
return Semantics(
label: l10n.manage,
button: true,
child: buildSidebarProfileButton(
supportsNativeGlass: conduitSupportsNativeGlass(),
supportsNativeGlass: supportsNativeGlass,
onPressed: () async {
await Navigator.of(context).maybePop();
if (!context.mounted) return;
@ -256,10 +471,11 @@ class SidebarProfileAppBarLeading extends ConsumerWidget {
},
fallbackStyle: style,
fallbackColor: useOpaqueFallback ? iconColor : null,
nativeAvatarBytes: nativeAvatarBytes,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
child: UserAvatar(
size: _avatarSize,
size: _sidebarProfileAvatarSize,
imageUrl: avatarUrl,
fallbackText: initial,
),
@ -600,14 +816,34 @@ class SidebarProfileAppBarLeading extends ConsumerWidget {
return host != null && host.isNotEmpty ? host : fallback;
}
static final LinkedHashMap<String, Uint8List> _dataImageBytesCache =
LinkedHashMap<String, Uint8List>();
Uint8List? _decodeDataImage(String? dataUrl) {
if (dataUrl == null || !dataUrl.startsWith('data:image')) {
return null;
}
final cached = _dataImageBytesCache.remove(dataUrl);
if (cached != null) {
_dataImageBytesCache[dataUrl] = cached;
return cached;
}
try {
final commaIndex = dataUrl.indexOf(',');
if (commaIndex == -1) return null;
return base64Decode(dataUrl.substring(commaIndex + 1));
final encodedLength = dataUrl.length - commaIndex - 1;
final maximumBase64Length =
((_sidebarAvatarMaximumEncodedBytes + 2) ~/ 3) * 4;
if (encodedLength > maximumBase64Length) return null;
final decoded = base64Decode(dataUrl.substring(commaIndex + 1));
if (decoded.lengthInBytes > _sidebarAvatarMaximumEncodedBytes) {
return null;
}
_dataImageBytesCache[dataUrl] = decoded;
while (_dataImageBytesCache.length > 4) {
_dataImageBytesCache.remove(_dataImageBytesCache.keys.first);
}
return decoded;
} catch (_) {
return null;
}

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/core/models/note.dart';
import 'package:conduit/core/services/haptic_service.dart';
import 'package:conduit/features/notes/providers/notes_providers.dart';

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io' show File, Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/cupertino.dart';
@ -1673,83 +1673,92 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
AdaptiveAppBar _buildAdaptiveNoteEditorAppBar(BuildContext context) {
final tintColor = context.conduitTheme.textPrimary;
final textScaler = MediaQuery.textScalerOf(context);
final controlExtent = conduitScaledControlExtent(context);
final toolbarHeight = conduitAdaptiveToolbarHeightOf(context);
final l10n = AppLocalizations.of(context)!;
final maxTitleWidth = resolveConduitAdaptiveLeadingPillWidth(
context,
trailingActionCount: 1,
maxWidth: kConduitAdaptiveToolbarMaxPillWidth,
);
final leading = _buildNoteEditorLeading(
context,
maxTitleWidth: maxTitleWidth,
);
final actions = _buildNoteEditorToolbarActionWidgets(context);
final overlayStyle = Theme.of(context).appBarTheme.systemOverlayStyle;
final scaledLeading = ConduitSystemTextScaling(
textScaler: textScaler,
child: leading,
);
final scaledActions = [
for (final action in actions)
ConduitSystemTextScaling(textScaler: textScaler, child: action),
];
return AdaptiveAppBar(
useNativeToolbar: false,
final menuItems = _buildNoteEditorToolbarMenuItems(l10n);
final actions = _buildNoteEditorToolbarActionWidgets(context, menuItems);
final nativeMenuAction = buildConduitNativeToolbarMenuAction<String>(
iosSymbol: 'ellipsis',
accessibilityLabel: MaterialLocalizations.of(context).moreButtonTooltip,
tintColor: tintColor,
cupertinoNavigationBar: ConduitAdaptiveCupertinoNavigationBar(
textScaler: textScaler,
leading: leading,
trailing: Row(mainAxisSize: MainAxisSize.min, children: actions),
systemOverlayStyle: overlayStyle,
),
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: Elevation.none,
scrolledUnderElevation: Elevation.none,
toolbarHeight: toolbarHeight,
systemOverlayStyle: overlayStyle,
centerTitle: false,
titleSpacing: Spacing.sm,
leadingWidth: resolveConduitAdaptiveToolbarLeadingWidth(
pillWidth: maxTitleWidth,
controlExtent: controlExtent,
),
leading: scaledLeading,
actions: scaledActions,
items: menuItems,
onSelected: _handleEditorToolbarMenuSelection,
);
final useNativeActionGroup =
Platform.isIOS &&
conduitSupportsNativeGlass() &&
nativeMenuAction != null;
return buildConduitCenteredAdaptiveAppBar(
context: context,
tintColor: tintColor,
leading: ConduitAdaptiveAppBarIconButton(
icon: Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu,
iosSymbol: 'line.3.horizontal',
onPressed: () => ResponsiveDrawerLayout.of(context)?.toggle(),
iconColor: tintColor,
),
title: _buildNoteEditorTitlePill(context, maxWidth: maxTitleWidth),
actions: actions,
cupertinoTrailing: useNativeActionGroup
? ConduitNativeToolbarActionGroup(actions: [nativeMenuAction])
: Row(mainAxisSize: MainAxisSize.min, children: actions),
centerTitle: false,
);
}
Widget _buildNoteEditorLeading(
BuildContext context, {
required double maxTitleWidth,
}) {
return buildConduitAdaptiveToolbarLeadingRow(
children: [
ConduitAdaptiveAppBarIconButton(
icon: Platform.isIOS ? CupertinoIcons.line_horizontal_3 : Icons.menu,
onPressed: () => ResponsiveDrawerLayout.of(context)?.toggle(),
iconColor: context.conduitTheme.textPrimary,
List<AdaptivePopupMenuEntry> _buildNoteEditorToolbarMenuItems(
AppLocalizations l10n,
) {
return [
AdaptivePopupMenuItem<String>(
value: 'generate',
label: l10n.generateTitle,
icon: conduitAdaptivePopupMenuIcon(
iosSymbol: 'sparkles',
materialIcon: Icons.auto_awesome,
),
const SizedBox(width: kConduitAdaptiveToolbarLeadingGap),
_buildNoteEditorTitlePill(context, maxWidth: maxTitleWidth),
],
);
),
AdaptivePopupMenuItem<String>(
value: 'copy',
label: l10n.copy,
icon: conduitAdaptivePopupMenuIcon(
iosSymbol: 'doc.on.doc',
materialIcon: Icons.copy_outlined,
),
),
AdaptivePopupMenuItem<String>(
value: 'pin',
label: _note?.isPinned == true ? l10n.unpin : l10n.pin,
icon: conduitAdaptivePopupMenuIcon(
iosSymbol: _note?.isPinned == true ? 'pin.slash' : 'pin',
materialIcon: Icons.push_pin_outlined,
),
),
AdaptivePopupMenuItem<String>(
value: 'delete',
label: l10n.delete,
isDestructive: true,
icon: conduitAdaptivePopupMenuIcon(
iosSymbol: 'trash',
materialIcon: Icons.delete_outline,
),
),
];
}
List<Widget> _buildNoteEditorToolbarActionWidgets(BuildContext context) {
List<Widget> _buildNoteEditorToolbarActionWidgets(
BuildContext context,
List<AdaptivePopupMenuEntry> menuItems,
) {
return buildConduitAdaptiveToolbarActionWidgets([
_NoteEditorToolbarPopupButton(
l10n: AppLocalizations.of(context)!,
isPinned: _note?.isPinned == true,
ConduitAdaptiveToolbarOverflowButton<String>(
tintColor: context.conduitTheme.textPrimary,
items: menuItems,
onSelected: _handleEditorToolbarMenuSelection,
),
]);
@ -1781,7 +1790,7 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
}) {
final conduitTheme = context.conduitTheme;
final l10n = AppLocalizations.of(context)!;
final titleTextStyle = conduitAdaptiveToolbarPillTextStyle(context);
final titleTextStyle = conduitAdaptiveToolbarLeadingTitleTextStyle(context);
final controlExtent = conduitScaledControlExtent(context);
final titleLabel = _isGeneratingTitle
? l10n.generatingTitle
@ -2947,9 +2956,6 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
_generateTitle();
}
},
buttonStyle: conduitSupportsNativeGlass()
? PopupButtonStyle.glass
: PopupButtonStyle.plain,
child: IgnorePointer(child: button),
),
),
@ -3076,62 +3082,3 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
);
}
}
class _NoteEditorToolbarPopupButton extends StatelessWidget {
const _NoteEditorToolbarPopupButton({
required this.l10n,
required this.isPinned,
required this.tintColor,
required this.onSelected,
});
final AppLocalizations l10n;
final bool isPinned;
final Color tintColor;
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) {
return ConduitAdaptiveToolbarOverflowButton<String>(
tintColor: tintColor,
items: [
AdaptivePopupMenuItem<String>(
value: 'generate',
label: l10n.generateTitle,
icon: conduitAdaptivePopupMenuIcon(
iosSymbol: 'sparkles',
materialIcon: Icons.auto_awesome,
),
),
AdaptivePopupMenuItem<String>(
value: 'copy',
label: l10n.copy,
icon: conduitAdaptivePopupMenuIcon(
iosSymbol: 'doc.on.doc',
materialIcon: Icons.copy_outlined,
),
),
AdaptivePopupMenuItem<String>(
value: 'pin',
label: isPinned ? l10n.unpin : l10n.pin,
icon: conduitAdaptivePopupMenuIcon(
iosSymbol: isPinned ? 'pin.slash' : 'pin',
materialIcon: isPinned
? Icons.push_pin_outlined
: Icons.push_pin_outlined,
),
),
AdaptivePopupMenuItem<String>(
value: 'delete',
label: l10n.delete,
isDestructive: true,
icon: conduitAdaptivePopupMenuIcon(
iosSymbol: 'trash',
materialIcon: Icons.delete_outline,
),
),
],
onSelected: onSelected,
);
}
}

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:conduit/core/services/haptic_service.dart';

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show File, Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@ -471,7 +471,12 @@ class _AudioPlayerDialogState extends State<AudioPlayerDialog> {
thumbColor: Colors.orange,
overlayColor: Colors.orange.withValues(alpha: 0.2),
),
child: AdaptiveSlider(value: progress, onChanged: _seekTo),
child: AdaptiveSlider(
value: progress,
onChanged: _seekTo,
activeColor: Colors.orange,
thumbColor: Colors.orange,
),
),
// Time display

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'dart:ui' show FontFeature, ImageFilter;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:conduit/core/services/haptic_service.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,6 +1,6 @@
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';

View file

@ -1,6 +1,6 @@
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import '../../../shared/theme/theme_extensions.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import '../../../shared/theme/theme_extensions.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xterm/xterm.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart';

View file

@ -3,7 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -2,7 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@ -220,9 +220,9 @@ class WorkspaceScaffold extends ConsumerWidget {
);
}
// iOS 26 native toolbars contribute their height to MediaQuery padding as
// of adaptive_platform_ui 0.1.110. Older Cupertino bars still need the
// explicit status-bar + navigation-bar offset used before that release.
// iOS 26 native toolbars contribute their height to MediaQuery padding.
// Older Cupertino bars still need the explicit status-bar + navigation-bar
// offset.
final isIos = Theme.of(context).platform == TargetPlatform.iOS;
final usesNativeToolbarInset = isIos && PlatformInfo.isIOS26OrHigher();
final topInset = isIos && !usesNativeToolbarInset
@ -426,8 +426,6 @@ class _WorkspaceSectionMenu extends StatelessWidget {
return ThemedSheets.hideNativeChromeWhileCovered(
replacement: SizedBox.fromSize(size: menuSize),
child: AdaptivePopupMenuButton.widget<WorkspaceSection>(
tint: context.conduitTheme.textPrimary,
buttonStyle: PopupButtonStyle.glass,
child: SizedBox.fromSize(
size: menuSize,
child: Padding(

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:conduit/shared/theme/theme_extensions.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:conduit/l10n/app_localizations.dart';

View file

@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:conduit/l10n/app_localizations.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:conduit/core/utils/debug_logger.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:conduit/features/workspace/models/workspace_resources.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/foundation.dart'
show LicenseEntryWithLineBreaks, LicenseRegistry;
import 'package:flutter_driver/driver_extension.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import '../theme/theme_extensions.dart';
import 'package:flutter/cupertino.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'theme_extensions.dart';

View file

@ -1571,7 +1571,7 @@ class IconSize {
static const double bottomSheet = 24.0;
static const double dialog = 24.0;
static const double snackbar = 20.0;
static const double tabBar = 24.0;
static const double tabBar = 20.0;
static const double appBar = 24.0;
static const double listItem = 20.0;
static const double formField = 20.0;

View file

@ -1,7 +1,10 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
/// Whether Conduit can rely on native iOS Liquid Glass rendering.
bool conduitSupportsNativeGlass({bool? isIOS, int? iosMajorVersion}) {
if (isIOS == null && iosMajorVersion == null) {
return PlatformUiCapabilities.usesNativeIOS26;
}
final effectiveIsIOS = isIOS ?? PlatformInfo.isIOS;
if (!effectiveIsIOS) {
return false;

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/core/database/chat_database_repository.dart';
import 'package:conduit/core/models/conversation.dart';
import 'package:conduit/core/models/folder.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'dart:io' show Platform;

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/widgets.dart';
/// Shared adaptive page shell for routes that only need common scaffold

View file

@ -1,11 +1,8 @@
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart' show Factory;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior;
import 'package:flutter/services.dart';
import '../theme/theme_extensions.dart';
@ -17,19 +14,15 @@ import 'themed_sheets.dart';
const double kConduitAdaptiveToolbarLeadingGap = Spacing.sm;
const double kConduitAdaptiveToolbarMaxPillWidth = 220;
const double kConduitAdaptiveToolbarMaxModelSelectorWidth = 320;
const double kConduitModelSelectorChevronExtent = 13;
const double kConduitNativeSingleActionSymbolExtent = 18;
const double kConduitMaximumSystemControlScale = 1.5;
const double kConduitNativeSidebarSymbolExtent = 20;
const double kConduitNativeToolbarSymbolExtent = 22;
const double kConduitNativeGroupedToolbarSymbolExtent = 22;
const double kConduitNativeVisibilitySymbolExtent = 18;
const double kConduitNativeUtilitySymbolExtent = 17;
const double kConduitNativePrimarySymbolExtent = 17;
const double kConduitNativeModelChevronExtent = 13;
const double _kConduitNativeButtonHorizontalInsets = 32;
const double _kConduitNativeModelChevronPadding = 6;
const double _kConduitNativeModelChevronReservedWidth =
kConduitNativeModelChevronExtent + _kConduitNativeModelChevronPadding;
kConduitModelSelectorChevronExtent + _kConduitNativeModelChevronPadding;
// Flutter and UIKit do not produce perfectly identical SF Pro advances. Keep
// a small optical guard instead of letting UIButton wrap at the measured edge.
const double _kConduitNativeModelTitleWrapGuard = 8;
@ -37,7 +30,7 @@ const int kConduitNativeModelLabelMaxCodeUnits = 256;
const int _kConduitNativeModelLabelPrefixCodeUnits = 160;
const TextStyle _kConduitNativeModelTitleStyle = TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w400,
);
/// Converts Dynamic Type into bounded control geometry.
@ -63,8 +56,11 @@ double conduitScaledControlExtent(
return baseExtent * conduitSystemControlScaleOf(context);
}
double conduitScaledIconExtent(BuildContext context, double baseExtent) {
return baseExtent * conduitSystemControlScaleOf(context);
double conduitScaledIconExtent(BuildContext _, double baseExtent) {
// Touch targets may grow for Dynamic Type, but glyphs retain the platform's
// standard visual size. Scaling both made app-bar, tab, and composer icons
// compete with their labels at accessibility sizes.
return baseExtent;
}
double conduitAdaptiveToolbarHeightOf(BuildContext context) {
@ -140,6 +136,7 @@ class ConduitAdaptiveCupertinoNavigationBar extends StatelessWidget
required this.leading,
this.middle,
this.trailing,
this.centerMiddle = true,
this.systemOverlayStyle,
});
@ -147,6 +144,7 @@ class ConduitAdaptiveCupertinoNavigationBar extends StatelessWidget
final Widget leading;
final Widget? middle;
final Widget? trailing;
final bool centerMiddle;
final SystemUiOverlayStyle? systemOverlayStyle;
double get _controlScale => resolveConduitSystemControlScale(textScaler);
@ -170,27 +168,24 @@ class ConduitAdaptiveCupertinoNavigationBar extends StatelessWidget
),
child: ConduitSystemTextScaling(
textScaler: textScaler,
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: leading,
),
if (middle != null)
PositionedDirectional(
start: (TouchTarget.minimum * _controlScale) + Spacing.sm,
end: (TouchTarget.minimum * _controlScale) + Spacing.sm,
top: 0,
bottom: 0,
child: Center(child: middle),
),
if (trailing != null)
Align(
alignment: AlignmentDirectional.centerEnd,
child: trailing,
),
],
child: NavigationToolbar(
centerMiddle: centerMiddle,
middleSpacing: Spacing.sm,
leading: Align(
alignment: AlignmentDirectional.centerStart,
widthFactor: 1,
child: leading,
),
middle: middle == null
? null
: Align(widthFactor: 1, child: middle),
trailing: trailing == null
? null
: Align(
alignment: AlignmentDirectional.centerEnd,
widthFactor: 1,
child: trailing,
),
),
),
),
@ -216,95 +211,66 @@ Widget _hideNativeToolbarChromeWhileSheetCovered({
);
}
/// Builds the shared adaptive toolbar shell used by chat-style pages.
AdaptiveAppBar buildConduitAdaptiveToolbarAppBar({
/// Builds the shared app bar used by chat-style routes.
///
/// [NavigationToolbar] applies the same collision-aware title placement to the
/// Cupertino shell that Material's [AppBar] uses. Most page identities stay
/// centered, while wide interactive titles such as model selectors can opt
/// into the platform's leading title position.
AdaptiveAppBar buildConduitCenteredAdaptiveAppBar({
required BuildContext context,
required Color tintColor,
required Widget Function() buildLeading,
required List<AdaptiveAppBarAction> Function() buildActions,
double? leadingWidth,
required Widget leading,
required Widget title,
required List<Widget> actions,
Widget? cupertinoTrailing,
bool centerTitle = true,
}) {
final leading = buildLeading();
final actions = buildActions();
final materialActions = _buildMaterialToolbarActions(
actions,
defaultTint: tintColor,
);
final textScaler = MediaQuery.textScalerOf(context);
final controlExtent = conduitScaledControlExtent(context);
final toolbarHeight = conduitAdaptiveToolbarHeightOf(context);
final overlayStyle = Theme.of(context).appBarTheme.systemOverlayStyle;
Widget scaled(Widget child) =>
ConduitSystemTextScaling(textScaler: textScaler, child: child);
return AdaptiveAppBar(
useNativeToolbar: Platform.isIOS || leadingWidth == null,
leading: leading,
useNativeToolbar: false,
tintColor: tintColor,
actions: actions,
appBar: leadingWidth == null
? null
: _buildMaterialToolbarAppBar(
leading: leading,
leadingWidth: leadingWidth,
actions: materialActions,
),
);
}
PreferredSizeWidget _buildMaterialToolbarAppBar({
required Widget leading,
required double leadingWidth,
required List<Widget> actions,
}) {
return AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: Elevation.none,
scrolledUnderElevation: Elevation.none,
toolbarHeight: kTextTabBarHeight,
centerTitle: false,
titleSpacing: Spacing.sm,
leadingWidth: leadingWidth,
leading: leading,
actions: actions,
);
}
List<Widget> _buildMaterialToolbarActions(
List<AdaptiveAppBarAction> actions, {
required Color defaultTint,
}) {
return buildConduitAdaptiveToolbarActionWidgets([
for (final action in actions)
_buildMaterialToolbarAction(action, defaultTint: defaultTint),
]);
}
Widget _buildMaterialToolbarAction(
AdaptiveAppBarAction action, {
required Color defaultTint,
}) {
final tintColor = action.tintColor ?? defaultTint;
if (action.title != null) {
return TextButton(
onPressed: action.onPressed,
style: TextButton.styleFrom(foregroundColor: tintColor),
child: Text(action.title!),
);
}
return ConduitAdaptiveAppBarIconButton(
icon: action.icon ?? Icons.circle,
iosSymbol: action.iosSymbol,
onPressed: action.onPressed,
iconColor: tintColor,
);
}
Widget buildConduitAdaptiveToolbarLeadingRow({required List<Widget> children}) {
if (Platform.isIOS) {
return Row(mainAxisSize: MainAxisSize.min, children: children);
}
return Padding(
padding: const EdgeInsets.only(left: Spacing.inputPadding),
child: Row(mainAxisSize: MainAxisSize.min, children: children),
cupertinoNavigationBar: ConduitAdaptiveCupertinoNavigationBar(
textScaler: textScaler,
leading: leading,
middle: title,
centerMiddle: centerTitle,
trailing:
cupertinoTrailing ??
(actions.isEmpty
? null
: Row(mainAxisSize: MainAxisSize.min, children: actions)),
systemOverlayStyle: overlayStyle,
),
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
elevation: Elevation.none,
scrolledUnderElevation: Elevation.none,
toolbarHeight: toolbarHeight,
systemOverlayStyle: overlayStyle,
centerTitle: centerTitle,
titleSpacing: Spacing.sm,
leadingWidth: controlExtent + Spacing.inputPadding,
leading: Padding(
padding: const EdgeInsetsDirectional.only(start: Spacing.inputPadding),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: scaled(leading),
),
),
title: scaled(title),
actions: [for (final action in actions) scaled(action)],
),
);
}
@ -336,6 +302,16 @@ TextStyle conduitAdaptiveToolbarPillTextStyle(BuildContext context) {
);
}
TextStyle conduitAdaptiveToolbarLeadingTitleTextStyle(BuildContext context) {
return (PlatformInfo.isIOS
? CupertinoTheme.of(context).textTheme.textStyle
: Theme.of(context).textTheme.titleMedium ?? AppTypography.standard)
.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w400,
);
}
Widget buildConduitAdaptiveToolbarPillSurface({
required double width,
required Widget child,
@ -360,32 +336,44 @@ Widget buildConduitAdaptiveToolbarPillSurface({
);
}
final borderRadius = BorderRadius.circular(height / 2);
Widget content = sizedChild;
if (onPressed != null) {
content = CupertinoButton(
onPressed: onPressed,
padding: EdgeInsets.zero,
minimumSize: Size(width, height),
borderRadius: borderRadius,
child: sizedChild,
);
if (semanticLabel != null) {
content = Semantics(
label: semanticLabel,
button: true,
child: ExcludeSemantics(child: content),
);
}
}
return _hideNativeToolbarChromeWhileSheetCovered(
size: Size(width, height),
child: AdaptiveButton.child(
onPressed: onPressed ?? () {},
style: AdaptiveButtonStyle.glass,
size: AdaptiveButtonSize.large,
padding: EdgeInsets.zero,
minSize: Size(width, height),
useSmoothRectangleBorder: false,
child: sizedChild,
child: SizedBox(
width: width,
height: height,
child: Stack(
fit: StackFit.expand,
children: [
AdaptiveGlassBackdrop(
borderRadius: borderRadius,
autoHideOnModal: false,
),
content,
],
),
),
);
}
double resolveConduitAdaptiveToolbarLeadingWidth({
required double pillWidth,
double leadingGap = kConduitAdaptiveToolbarLeadingGap,
double controlExtent = TouchTarget.minimum,
}) {
return Spacing.inputPadding +
controlExtent +
leadingGap +
pillWidth +
Spacing.md;
}
/// Resolves a stable pill width inside a constrained toolbar slot.
///
/// The result never exceeds the available space. When the preferred padding
@ -507,16 +495,6 @@ final Map<IconData, String> _kConduitToolbarSfSymbolByIcon = {
String? conduitToolbarSfSymbolForIcon(IconData icon, {String? iosSymbol}) =>
iosSymbol ?? _kConduitToolbarSfSymbolByIcon[icon];
/// Resolves SF Symbol point sizes by optical footprint rather than applying a
/// single numeric size to every toolbar glyph.
double conduitNativeToolbarSymbolExtentFor(String? iosSymbol) =>
switch (iosSymbol) {
'line.3.horizontal' ||
'chevron.left' => kConduitNativeSidebarSymbolExtent,
'eye' || 'eye.slash' => kConduitNativeVisibilitySymbolExtent,
_ => kConduitNativeToolbarSymbolExtent,
};
/// Adaptive floating app-bar icon button for route-level toolbar actions.
class ConduitAdaptiveAppBarIconButton extends StatelessWidget {
/// Creates an adaptive toolbar icon button.
@ -526,7 +504,6 @@ class ConduitAdaptiveAppBarIconButton extends StatelessWidget {
this.iosSymbol,
this.onPressed,
this.iconColor,
this.iosSymbolSize,
});
/// Icon shown inside the control.
@ -541,9 +518,6 @@ class ConduitAdaptiveAppBarIconButton extends StatelessWidget {
/// Optional icon tint.
final Color? iconColor;
/// Native SF Symbol point size on iOS 26+.
final double? iosSymbolSize;
@override
Widget build(BuildContext context) {
final effectiveIconColor = iconColor ?? context.conduitTheme.textPrimary;
@ -553,9 +527,6 @@ class ConduitAdaptiveAppBarIconButton extends StatelessWidget {
icon,
iosSymbol: iosSymbol,
);
final nativeSymbolExtent =
iosSymbolSize ?? conduitNativeToolbarSymbolExtentFor(nativeSymbol);
if (conduitUsesOpaqueGlassFallback()) {
return SizedBox.square(
dimension: controlExtent,
@ -578,7 +549,7 @@ class ConduitAdaptiveAppBarIconButton extends StatelessWidget {
onPressed: onPressed,
sfSymbol: SFSymbol(
nativeSymbol,
size: nativeSymbolExtent,
size: kConduitNativeSingleActionSymbolExtent,
color: effectiveIconColor,
),
style: AdaptiveButtonStyle.glass,
@ -631,7 +602,6 @@ class ConduitNativeToolbarAction {
this.onPressed,
this.menuItems = const <ConduitNativeToolbarMenuItem>[],
this.tintColor,
this.symbolSize,
this.enabled = true,
}) : assert(onPressed != null || menuItems.length > 0 || !enabled);
@ -640,7 +610,6 @@ class ConduitNativeToolbarAction {
final VoidCallback? onPressed;
final List<ConduitNativeToolbarMenuItem> menuItems;
final Color? tintColor;
final double? symbolSize;
final bool enabled;
}
@ -654,7 +623,6 @@ List<Map<String, Object?>> encodeConduitNativeToolbarActions(
'accessibilityLabel': action.accessibilityLabel,
'enabled': action.enabled,
if (action.tintColor != null) 'tintColor': action.tintColor!.toARGB32(),
if (action.symbolSize != null) 'symbolSize': action.symbolSize,
if (action.menuItems.isNotEmpty)
'menuItems': <Map<String, Object?>>[
for (final item in action.menuItems)
@ -672,105 +640,214 @@ List<Map<String, Object?>> encodeConduitNativeToolbarActions(
/// Builds the native creation payload with navigation-bar optical sizing.
Map<String, Object?> encodeConduitNativeToolbarActionGroupParams(
List<ConduitNativeToolbarAction> actions,
) => <String, Object?>{
'actions': encodeConduitNativeToolbarActions(actions),
'symbolSize': kConduitNativeGroupedToolbarSymbolExtent,
};
) => <String, Object?>{'actions': encodeConduitNativeToolbarActions(actions)};
/// A native toolbar surface for one to three adjacent shared actions.
class ConduitNativeToolbarActionGroup extends StatefulWidget {
/// A cupertino_native_better toolbar surface for adjacent shared actions.
class ConduitNativeToolbarActionGroup extends StatelessWidget {
const ConduitNativeToolbarActionGroup({super.key, required this.actions})
: assert(actions.length >= 1 && actions.length <= 3);
final List<ConduitNativeToolbarAction> actions;
@override
State<ConduitNativeToolbarActionGroup> createState() =>
_ConduitNativeToolbarActionGroupState();
}
class _ConduitNativeToolbarActionGroupState
extends State<ConduitNativeToolbarActionGroup> {
MethodChannel? _channel;
Object get _configurationKey => Object.hashAll([
for (final action in widget.actions) ...[
action.iosSymbol,
action.accessibilityLabel,
action.enabled,
action.tintColor?.toARGB32(),
action.symbolSize,
for (final item in action.menuItems) ...[
item.label,
item.iosSymbol,
item.isDestructive,
item.isChecked,
item.enabled,
],
],
]);
@override
void dispose() {
_channel?.setMethodCallHandler(null);
super.dispose();
}
Future<void> _handleMethodCall(MethodCall call) async {
final arguments = call.arguments;
if (arguments is! Map<Object?, Object?>) return;
final actionIndex = arguments['actionIndex'];
if (actionIndex is! int ||
actionIndex < 0 ||
actionIndex >= widget.actions.length) {
return;
}
final action = widget.actions[actionIndex];
switch (call.method) {
case 'actionTapped':
action.onPressed?.call();
return;
case 'menuItemSelected':
final itemIndex = arguments['itemIndex'];
if (itemIndex is int &&
itemIndex >= 0 &&
itemIndex < action.menuItems.length) {
action.menuItems[itemIndex].onSelected();
}
return;
}
}
void _onPlatformViewCreated(int id) {
_channel?.setMethodCallHandler(null);
_channel = MethodChannel(
'app.cogwheel.conduit/native_toolbar_action_group_$id',
)..setMethodCallHandler(_handleMethodCall);
}
@override
Widget build(BuildContext context) {
if (actions.isEmpty || actions.length > 3) {
throw StateError(
'ConduitNativeToolbarActionGroup requires between 1 and 3 actions.',
);
}
final controlExtent = conduitScaledControlExtent(context);
final width = widget.actions.length == 1
final width = actions.length == 1
? controlExtent
: (controlExtent * widget.actions.length) + Spacing.sm;
: (controlExtent * actions.length) +
(Spacing.sm * (actions.length - 1));
final size = Size(width, controlExtent);
final hasRichMenu = actions.any(
(action) => !_canUseGroupedNativeAction(action),
);
Widget controls;
if (actions.length == 1) {
controls = _buildAction(actions.single, controlExtent);
} else if (!hasRichMenu) {
controls = _buildGlassGroup(actions, controlExtent);
} else {
controls = _buildMixedActionCluster(controlExtent);
}
return _hideNativeToolbarChromeWhileSheetCovered(
size: size,
child: SizedBox.fromSize(
size: size,
child: UiKitView(
key: ValueKey<Object>(_configurationKey),
viewType: 'app.cogwheel.conduit/native_toolbar_action_group',
creationParams: encodeConduitNativeToolbarActionGroupParams(
widget.actions,
child: SizedBox.fromSize(size: size, child: controls),
);
}
bool _canUseGroupedNativeAction(ConduitNativeToolbarAction action) {
if (action.menuItems.isEmpty) return true;
// CNButtonData.popup cannot represent checked or disabled menu items, so
// preserve those states with the rich popup fallback. Destructive actions
// stay in the group because their callbacks still retain the app-owned
// confirmation flow, even though the grouped package API cannot tint an
// individual popup row red.
return action.menuItems.every((item) => item.enabled && !item.isChecked);
}
Widget _buildGlassGroup(
List<ConduitNativeToolbarAction> groupedActions,
double extent,
) {
return Semantics(
container: true,
label: groupedActions
.map((action) => action.accessibilityLabel)
.join(', '),
child: CNGlassButtonGroup(
spacing: Spacing.sm,
spacingForGlass: 36,
buttons: [
for (final action in groupedActions)
if (action.menuItems.isEmpty)
CNButtonData.icon(
icon: CNSymbol(
action.iosSymbol,
size: kCupertinoNativeControlSymbolExtent,
),
onPressed: action.onPressed,
enabled: action.enabled,
tint: action.tintColor,
config: _groupedButtonConfig(extent),
)
else
CNButtonData.popup(
icon: CNSymbol(
action.iosSymbol,
size: kCupertinoNativeControlSymbolExtent,
),
popupItems: [
for (final item in action.menuItems)
CNButtonDataPopupItem(
label: item.label,
sfSymbol: item.iosSymbol,
),
],
onMenuSelected: (index) {
if (index >= 0 && index < action.menuItems.length) {
action.menuItems[index].onSelected();
}
},
enabled: action.enabled,
tint: action.tintColor,
config: _groupedButtonConfig(extent),
),
],
),
);
}
CNButtonDataConfig _groupedButtonConfig(double extent) {
return CNButtonDataConfig(
width: extent,
minHeight: extent,
style: CNButtonStyle.glass,
);
}
Widget _buildMixedActionCluster(double extent) {
final children = <Widget>[];
var index = 0;
while (index < actions.length) {
final action = actions[index];
if (!_canUseGroupedNativeAction(action)) {
children.add(_buildAction(action, extent));
index += 1;
continue;
}
final run = <ConduitNativeToolbarAction>[];
while (index < actions.length &&
_canUseGroupedNativeAction(actions[index])) {
run.add(actions[index]);
index += 1;
}
children.add(
run.length > 1
? _buildGlassGroup(run, extent)
: _buildAction(run.single, extent),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (
var childIndex = 0;
childIndex < children.length;
childIndex++
) ...[
if (childIndex > 0) const SizedBox(width: Spacing.sm),
children[childIndex],
],
],
);
}
Widget _buildAction(ConduitNativeToolbarAction action, double extent) {
if (action.menuItems.isEmpty) {
return Semantics(
label: action.accessibilityLabel,
button: true,
enabled: action.enabled,
onTap: action.enabled ? action.onPressed : null,
child: CNButton.icon(
icon: CNSymbol(
action.iosSymbol,
size: kConduitNativeSingleActionSymbolExtent,
),
onPressed: action.onPressed,
enabled: action.enabled,
tint: action.tintColor,
config: CNButtonConfig(
width: extent,
minHeight: extent,
padding: EdgeInsets.zero,
borderRadius: extent / 2,
style: CNButtonStyle.glass,
),
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: _onPlatformViewCreated,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
),
);
}
return Semantics(
label: action.accessibilityLabel,
button: true,
enabled: action.enabled,
child: AdaptivePopupMenuButton.icon<int>(
icon: action.iosSymbol,
iconSize: kConduitNativeSingleActionSymbolExtent,
tint: action.tintColor,
size: extent,
enabled: action.enabled,
buttonStyle: PopupButtonStyle.glass,
items: [
for (var index = 0; index < action.menuItems.length; index++)
AdaptivePopupMenuItem<int>(
label: action.menuItems[index].label,
icon: action.menuItems[index].iosSymbol,
enabled: action.menuItems[index].enabled,
isDestructive: action.menuItems[index].isDestructive,
checked: action.menuItems[index].isChecked,
value: index,
),
],
onSelected: (index, entry) {
if (!action.enabled) return;
final selected = entry.value;
if (selected != null &&
selected >= 0 &&
selected < action.menuItems.length) {
action.menuItems[selected].onSelected();
}
},
),
);
}
@ -955,9 +1032,7 @@ String resolveConduitNativeModelSelectorLabel({
);
}
/// Forces the package-owned platform view to be recreated when its foreground
/// changes. adaptive_platform_ui 0.1.110 does not synchronize `textColor` from
/// `didUpdateWidget`, so preserving the state would retain the previous theme.
/// Creates a stable key for native model-selector appearance changes.
ValueKey<Object> conduitNativeModelSelectorViewKey(
Color foregroundColor, {
double titleFontSize = 17,
@ -974,20 +1049,18 @@ Map<String, Object?> encodeConduitNativeModelSelectorParams({
}) => <String, Object?>{
'label': label,
'symbolName': symbolName,
'symbolSize': kConduitNativeModelChevronExtent,
'symbolPadding': _kConduitNativeModelChevronPadding,
'foregroundColor': foregroundColor.toARGB32(),
'titleFontSize': titleFontSize,
'enabled': enabled,
};
class _ConduitNativeModelSelectorButton extends StatefulWidget {
class _ConduitNativeModelSelectorButton extends StatelessWidget {
const _ConduitNativeModelSelectorButton({
super.key,
required this.label,
required this.symbolName,
required this.foregroundColor,
required this.titleFontSize,
required this.enabled,
required this.onPressed,
});
@ -995,61 +1068,33 @@ class _ConduitNativeModelSelectorButton extends StatefulWidget {
final String label;
final String? symbolName;
final Color foregroundColor;
final double titleFontSize;
final bool enabled;
final VoidCallback onPressed;
@override
State<_ConduitNativeModelSelectorButton> createState() =>
_ConduitNativeModelSelectorButtonState();
}
class _ConduitNativeModelSelectorButtonState
extends State<_ConduitNativeModelSelectorButton> {
MethodChannel? _channel;
void _handlePlatformViewCreated(int id) {
_channel?.setMethodCallHandler(null);
final channel = MethodChannel(
'app.cogwheel.conduit/native_model_selector_button_$id',
);
_channel = channel;
channel.setMethodCallHandler((call) async {
if (call.method == 'pressed' && widget.enabled) {
widget.onPressed();
}
});
}
@override
void dispose() {
_channel?.setMethodCallHandler(null);
super.dispose();
}
@override
Widget build(BuildContext context) {
return UiKitView(
key: ValueKey<Object>((
widget.label,
widget.symbolName,
widget.foregroundColor.toARGB32(),
widget.titleFontSize,
widget.enabled,
)),
viewType: 'app.cogwheel.conduit/native_model_selector_button',
creationParams: encodeConduitNativeModelSelectorParams(
label: widget.label,
symbolName: widget.symbolName,
foregroundColor: widget.foregroundColor,
titleFontSize: widget.titleFontSize,
enabled: widget.enabled,
return CNButton(
label: label,
icon: symbolName == null
? null
: CNSymbol(
symbolName!,
size: kConduitModelSelectorChevronExtent,
color: foregroundColor,
),
onPressed: enabled ? onPressed : null,
enabled: enabled,
config: CNButtonConfig(
minHeight: conduitScaledControlExtent(context),
shrinkWrap: false,
style: CNButtonStyle.glass,
imagePlacement: CNImagePlacement.trailing,
imagePadding: _kConduitNativeModelChevronPadding,
maxLines: 1,
labelColor: foregroundColor,
// UIKit's default body style supplies the standard iOS button
// metrics, optical sizing, and weight.
),
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: _handlePlatformViewCreated,
gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>{
Factory<TapGestureRecognizer>(() => TapGestureRecognizer()),
},
);
}
}
@ -1093,16 +1138,15 @@ class ConduitAdaptiveAppBarModelSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveTextStyle =
textStyle ?? conduitAdaptiveToolbarPillTextStyle(context);
textStyle ?? conduitAdaptiveToolbarLeadingTitleTextStyle(context);
final safeMaxWidth = maxWidth.clamp(0.0, double.infinity).toDouble();
if (safeMaxWidth == 0) {
return const SizedBox.shrink();
}
final controlExtent = conduitScaledControlExtent(context);
final chevronSize = conduitScaledIconExtent(
context,
Platform.isIOS ? IconSize.small : IconSize.medium,
);
final chevronSize = PlatformInfo.isIOS
? kConduitModelSelectorChevronExtent
: IconSize.small;
final usesNativeGlass = conduitSupportsNativeGlass();
final textDirection = Directionality.of(context);
final nativeTitleFontSize = resolveConduitNativeModelTitleFontSize(
@ -1163,7 +1207,7 @@ class ConduitAdaptiveAppBarModelSelector extends StatelessWidget {
if (showChevron) ...[
const SizedBox(width: Spacing.xs),
ConduitSystemAdaptiveIcon(
Platform.isIOS
PlatformInfo.isIOS
? CupertinoIcons.chevron_down
: Icons.keyboard_arrow_down,
color: context.conduitTheme.iconSecondary,
@ -1222,7 +1266,6 @@ class ConduitAdaptiveAppBarModelSelector extends StatelessWidget {
showChevron: showChevron,
),
foregroundColor: context.conduitTheme.textPrimary,
titleFontSize: nativeTitleFontSize,
enabled: !isLoading && showChevron,
onPressed: onPressed,
),
@ -1279,7 +1322,6 @@ class ConduitAdaptiveToolbarOverflowButton<T> extends StatelessWidget {
context,
).moreButtonTooltip,
tintColor: tintColor,
symbolSize: conduitNativeToolbarSymbolExtentFor(iosIcon),
items: items,
onSelected: onSelected,
)
@ -1328,7 +1370,6 @@ ConduitNativeToolbarAction? buildConduitNativeToolbarMenuAction<T>({
required String iosSymbol,
required String accessibilityLabel,
required Color tintColor,
required double symbolSize,
required List<AdaptivePopupMenuEntry> items,
required ValueChanged<T> onSelected,
}) {
@ -1347,6 +1388,7 @@ ConduitNativeToolbarAction? buildConduitNativeToolbarMenuAction<T>({
label: entry.label,
iosSymbol: entry.icon as String?,
isDestructive: entry.isDestructive,
isChecked: entry.checked,
enabled: entry.enabled,
onSelected: () => onSelected(value),
),
@ -1359,6 +1401,5 @@ ConduitNativeToolbarAction? buildConduitNativeToolbarMenuAction<T>({
accessibilityLabel: accessibilityLabel,
menuItems: nativeItems,
tintColor: tintColor,
symbolSize: symbolSize,
);
}

View file

@ -1,6 +1,6 @@
import 'dart:math' as math;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -258,6 +258,7 @@ class FloatingAppBarButton extends StatelessWidget {
button: true,
enabled: true,
label: semanticLabel,
excludeSemantics: semanticLabel != null,
child: Shortcuts(
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/semantics.dart';

View file

@ -1,4 +1,4 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

View file

@ -0,0 +1,34 @@
library;
export 'src/adaptive_app.dart';
export 'src/adaptive_controls.dart';
export 'src/adaptive_feedback.dart';
export 'src/adaptive_inputs.dart';
export 'src/adaptive_layout.dart';
export 'src/platform_ui_capabilities.dart';
export 'package:cupertino_native_better/cupertino_native_better.dart'
show
CNBottomSheet,
CNButton,
CNButtonConfig,
CNButtonData,
CNButtonDataConfig,
CNButtonDataPopupItem,
CNButtonStyle,
CNGlassButtonGroup,
CNGlassEffect,
CNGlassEffectShape,
CNImageAsset,
CNImagePlacement,
CNPopupMenuButton,
CNPopupMenuItem,
CNSegmentedControl,
CNSheetGeometryProbe,
CNSlider,
CNSymbol,
CNSwitch,
CNTabBar,
CNTabBarItem,
CNTabBarRouteObserver,
LiquidGlassConfig,
LiquidGlassContainer;

View file

@ -0,0 +1,296 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'platform_ui_capabilities.dart';
class MaterialAppData {
const MaterialAppData({
this.color,
this.highContrastTheme,
this.highContrastDarkTheme,
this.debugShowMaterialGrid = false,
this.showPerformanceOverlay = false,
this.checkerboardRasterCacheImages = false,
this.checkerboardOffscreenLayers = false,
this.showSemanticsDebugger = false,
this.debugShowCheckedModeBanner = false,
this.shortcuts,
this.actions,
this.restorationScopeId,
this.scrollBehavior,
});
final Color? color;
final ThemeData? highContrastTheme;
final ThemeData? highContrastDarkTheme;
final bool debugShowMaterialGrid;
final bool showPerformanceOverlay;
final bool checkerboardRasterCacheImages;
final bool checkerboardOffscreenLayers;
final bool showSemanticsDebugger;
final bool debugShowCheckedModeBanner;
final Map<ShortcutActivator, Intent>? shortcuts;
final Map<Type, Action<Intent>>? actions;
final String? restorationScopeId;
final ScrollBehavior? scrollBehavior;
}
class CupertinoAppData {
const CupertinoAppData({
this.color,
this.showPerformanceOverlay = false,
this.checkerboardRasterCacheImages = false,
this.checkerboardOffscreenLayers = false,
this.showSemanticsDebugger = false,
this.debugShowCheckedModeBanner = false,
this.shortcuts,
this.actions,
this.restorationScopeId,
this.scrollBehavior,
});
final Color? color;
final bool showPerformanceOverlay;
final bool checkerboardRasterCacheImages;
final bool checkerboardOffscreenLayers;
final bool showSemanticsDebugger;
final bool debugShowCheckedModeBanner;
final Map<ShortcutActivator, Intent>? shortcuts;
final Map<Type, Action<Intent>>? actions;
final String? restorationScopeId;
final ScrollBehavior? scrollBehavior;
}
enum PlatformTarget {
android,
ios,
ios26Plus,
iosBefore26,
web,
desktop,
other,
}
/// Flutter application root with Cupertino on iOS and Material elsewhere.
class AdaptiveApp extends StatelessWidget {
const AdaptiveApp.router({
super.key,
this.routerConfig,
this.routeInformationProvider,
this.routeInformationParser,
this.routerDelegate,
this.backButtonDispatcher,
this.builder,
this.title = '',
this.onGenerateTitle,
this.themeMode,
this.materialLightTheme,
this.materialDarkTheme,
this.cupertinoLightTheme,
this.cupertinoDarkTheme,
this.locale,
this.localizationsDelegates,
this.localeListResolutionCallback,
this.localeResolutionCallback,
this.supportedLocales = const <Locale>[Locale('en', 'US')],
this.material,
this.cupertino,
this.scaffoldMessengerKey,
});
final RouterConfig<Object>? routerConfig;
final RouteInformationProvider? routeInformationProvider;
final RouteInformationParser<Object>? routeInformationParser;
final RouterDelegate<Object>? routerDelegate;
final BackButtonDispatcher? backButtonDispatcher;
final TransitionBuilder? builder;
final String title;
final GenerateAppTitle? onGenerateTitle;
final ThemeMode? themeMode;
final ThemeData? materialLightTheme;
final ThemeData? materialDarkTheme;
final CupertinoThemeData? cupertinoLightTheme;
final CupertinoThemeData? cupertinoDarkTheme;
final Locale? locale;
final Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates;
final LocaleListResolutionCallback? localeListResolutionCallback;
final LocaleResolutionCallback? localeResolutionCallback;
final Iterable<Locale> supportedLocales;
final MaterialAppData Function(BuildContext, PlatformTarget)? material;
final CupertinoAppData Function(BuildContext, PlatformTarget)? cupertino;
final GlobalKey<ScaffoldMessengerState>? scaffoldMessengerKey;
PlatformTarget get _platformTarget {
if (PlatformInfo.isWeb) return PlatformTarget.web;
if (PlatformUiCapabilities.usesNativeIOS26) {
return PlatformTarget.ios26Plus;
}
if (PlatformUiCapabilities.isIOS) {
return PlatformUiCapabilities.iOSMajorVersion > 0
? PlatformTarget.iosBefore26
: PlatformTarget.ios;
}
if (PlatformUiCapabilities.isAndroid) return PlatformTarget.android;
if (PlatformInfo.isMacOS ||
PlatformInfo.isWindows ||
PlatformInfo.isLinux) {
return PlatformTarget.desktop;
}
return PlatformTarget.other;
}
@override
Widget build(BuildContext context) {
if (!PlatformUiCapabilities.isIOS) {
final data =
material?.call(context, _platformTarget) ?? const MaterialAppData();
return MaterialApp.router(
routerConfig: routerConfig,
routeInformationProvider: routeInformationProvider,
routeInformationParser: routeInformationParser,
routerDelegate: routerDelegate,
backButtonDispatcher: backButtonDispatcher,
builder: builder,
title: title,
onGenerateTitle: onGenerateTitle,
color: data.color,
theme: materialLightTheme,
darkTheme: materialDarkTheme,
themeMode: themeMode ?? ThemeMode.system,
locale: locale,
localizationsDelegates: localizationsDelegates,
localeListResolutionCallback: localeListResolutionCallback,
localeResolutionCallback: localeResolutionCallback,
supportedLocales: supportedLocales,
debugShowMaterialGrid: data.debugShowMaterialGrid,
showPerformanceOverlay: data.showPerformanceOverlay,
checkerboardRasterCacheImages: data.checkerboardRasterCacheImages,
checkerboardOffscreenLayers: data.checkerboardOffscreenLayers,
showSemanticsDebugger: data.showSemanticsDebugger,
debugShowCheckedModeBanner: data.debugShowCheckedModeBanner,
shortcuts: data.shortcuts,
actions: data.actions,
restorationScopeId: data.restorationScopeId,
scrollBehavior: data.scrollBehavior,
highContrastTheme: data.highContrastTheme,
highContrastDarkTheme: data.highContrastDarkTheme,
scaffoldMessengerKey: scaffoldMessengerKey,
);
}
final data =
cupertino?.call(context, _platformTarget) ?? const CupertinoAppData();
final lightTheme =
cupertinoLightTheme ??
CupertinoThemeData(
brightness: Brightness.light,
primaryColor: materialLightTheme?.colorScheme.primary,
);
final darkTheme =
cupertinoDarkTheme ??
CupertinoThemeData(
brightness: Brightness.dark,
primaryColor: materialDarkTheme?.colorScheme.primary,
);
Widget effectiveBuilder(BuildContext context, Widget? child) {
final brightness = switch (themeMode) {
ThemeMode.dark => Brightness.dark,
ThemeMode.light => Brightness.light,
_ => MediaQuery.platformBrightnessOf(context),
};
final themedChild = MediaQuery(
data: MediaQuery.of(context).copyWith(platformBrightness: brightness),
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
child: CupertinoTheme(
data: brightness == Brightness.dark ? darkTheme : lightTheme,
child: child ?? const SizedBox.shrink(),
),
),
);
final appChild = scaffoldMessengerKey == null
? themedChild
: ScaffoldMessenger(key: scaffoldMessengerKey, child: themedChild);
return builder?.call(context, appChild) ?? appChild;
}
return _PlatformBrightnessBuilder(
themeMode: themeMode ?? ThemeMode.system,
builder: (appBrightness) => CupertinoApp.router(
routerConfig: routerConfig,
routeInformationProvider: routeInformationProvider,
routeInformationParser: routeInformationParser,
routerDelegate: routerDelegate,
backButtonDispatcher: backButtonDispatcher,
builder: effectiveBuilder,
title: title,
onGenerateTitle: onGenerateTitle,
color: data.color,
theme: appBrightness == Brightness.dark ? darkTheme : lightTheme,
locale: locale,
localizationsDelegates: localizationsDelegates,
localeListResolutionCallback: localeListResolutionCallback,
localeResolutionCallback: localeResolutionCallback,
supportedLocales: supportedLocales,
showPerformanceOverlay: data.showPerformanceOverlay,
checkerboardRasterCacheImages: data.checkerboardRasterCacheImages,
checkerboardOffscreenLayers: data.checkerboardOffscreenLayers,
showSemanticsDebugger: data.showSemanticsDebugger,
debugShowCheckedModeBanner: data.debugShowCheckedModeBanner,
shortcuts: data.shortcuts,
actions: data.actions,
restorationScopeId: data.restorationScopeId,
scrollBehavior: data.scrollBehavior,
),
);
}
}
class _PlatformBrightnessBuilder extends StatefulWidget {
const _PlatformBrightnessBuilder({
required this.themeMode,
required this.builder,
});
final ThemeMode themeMode;
final Widget Function(Brightness brightness) builder;
@override
State<_PlatformBrightnessBuilder> createState() =>
_PlatformBrightnessBuilderState();
}
class _PlatformBrightnessBuilderState extends State<_PlatformBrightnessBuilder>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangePlatformBrightness() {
if (widget.themeMode == ThemeMode.system) setState(() {});
}
@override
Widget build(BuildContext context) {
final brightness = switch (widget.themeMode) {
ThemeMode.dark => Brightness.dark,
ThemeMode.light => Brightness.light,
ThemeMode.system => View.of(
context,
).platformDispatcher.platformBrightness,
};
return widget.builder(brightness);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,408 @@
import 'dart:async';
import 'package:cupertino_native_better/cupertino_native_better.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'platform_ui_capabilities.dart';
enum AdaptiveSnackBarType { info, success, warning, error }
class AdaptiveSnackBar {
AdaptiveSnackBar._();
static OverlayEntry? _activeIOSEntry;
static void show(
BuildContext context, {
required String message,
AdaptiveSnackBarType type = AdaptiveSnackBarType.info,
Duration duration = const Duration(seconds: 4),
String? action,
VoidCallback? onActionPressed,
}) {
final nativeDuration = _nativeDuration(duration);
if (PlatformUiCapabilities.usesNativeIOS26 &&
action == null &&
nativeDuration != null) {
final style = switch (type) {
AdaptiveSnackBarType.info => CNToastStyle.info,
AdaptiveSnackBarType.success => CNToastStyle.success,
AdaptiveSnackBarType.warning => CNToastStyle.warning,
AdaptiveSnackBarType.error => CNToastStyle.error,
};
CNToast.show(
context: context,
message: message,
position: CNToastPosition.top,
duration: nativeDuration,
style: style,
);
return;
}
if (!PlatformUiCapabilities.isIOS) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: duration,
backgroundColor: switch (type) {
AdaptiveSnackBarType.info => Theme.of(
context,
).snackBarTheme.backgroundColor,
AdaptiveSnackBarType.success => Colors.green.shade700,
AdaptiveSnackBarType.warning => Colors.orange.shade700,
AdaptiveSnackBarType.error => Colors.red.shade700,
},
action: action == null
? null
: SnackBarAction(
label: action,
onPressed: onActionPressed ?? () {},
),
),
);
return;
}
final overlay = Overlay.of(context);
final previousEntry = _activeIOSEntry;
if (previousEntry?.mounted == true) previousEntry!.remove();
_activeIOSEntry = null;
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _IOSBanner(
message: message,
type: type,
duration: duration,
action: action,
onActionPressed: onActionPressed,
onDismiss: () {
if (entry.mounted) entry.remove();
if (identical(_activeIOSEntry, entry)) _activeIOSEntry = null;
},
),
);
_activeIOSEntry = entry;
overlay.insert(entry);
}
static CNToastDuration? _nativeDuration(Duration value) {
if (value == const Duration(seconds: 2)) return CNToastDuration.short;
if (value == const Duration(milliseconds: 3500)) {
return CNToastDuration.medium;
}
if (value == const Duration(seconds: 5)) return CNToastDuration.long;
return null;
}
}
class _IOSBanner extends StatefulWidget {
const _IOSBanner({
required this.message,
required this.type,
required this.duration,
required this.onDismiss,
this.action,
this.onActionPressed,
});
final String message;
final AdaptiveSnackBarType type;
final Duration duration;
final String? action;
final VoidCallback? onActionPressed;
final VoidCallback onDismiss;
@override
State<_IOSBanner> createState() => _IOSBannerState();
}
class _IOSBannerState extends State<_IOSBanner> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer(widget.duration, widget.onDismiss);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tint = switch (widget.type) {
AdaptiveSnackBarType.info => CupertinoColors.systemBlue,
AdaptiveSnackBarType.success => CupertinoColors.systemGreen,
AdaptiveSnackBarType.warning => CupertinoColors.systemOrange,
AdaptiveSnackBarType.error => CupertinoColors.systemRed,
};
final icon = switch (widget.type) {
AdaptiveSnackBarType.info => CupertinoIcons.info_circle_fill,
AdaptiveSnackBarType.success => CupertinoIcons.check_mark_circled_solid,
AdaptiveSnackBarType.warning =>
CupertinoIcons.exclamationmark_triangle_fill,
AdaptiveSnackBarType.error => CupertinoIcons.xmark_circle_fill,
};
return Positioned(
top: 8,
left: 12,
right: 12,
child: SafeArea(
bottom: false,
child: Semantics(
liveRegion: true,
child: DecoratedBox(
decoration: BoxDecoration(
color: CupertinoColors.secondarySystemBackground.resolveFrom(
context,
),
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(
color: Color(0x26000000),
blurRadius: 18,
offset: Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Icon(icon, color: tint, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.message,
style: TextStyle(
color: CupertinoColors.label.resolveFrom(context),
),
),
),
if (widget.action != null)
CupertinoButton(
padding: const EdgeInsets.only(left: 10),
minimumSize: const Size(36, 36),
onPressed: () {
widget.onActionPressed?.call();
widget.onDismiss();
},
child: Text(widget.action!),
),
],
),
),
),
),
),
);
}
}
enum AlertActionStyle {
defaultAction,
cancel,
destructive,
primary,
secondary,
success,
warning,
info,
disabled,
}
class AlertAction {
const AlertAction({
required this.title,
required this.onPressed,
this.style = AlertActionStyle.defaultAction,
this.enabled = true,
});
final String title;
final VoidCallback onPressed;
final AlertActionStyle style;
final bool enabled;
}
class AdaptiveAlertDialogInput {
const AdaptiveAlertDialogInput({
required this.placeholder,
this.initialValue,
this.keyboardType,
this.obscureText = false,
this.maxLength,
});
final String placeholder;
final String? initialValue;
final TextInputType? keyboardType;
final bool obscureText;
final int? maxLength;
}
class AdaptiveAlertDialog {
AdaptiveAlertDialog._();
static Future<void> show({
required BuildContext context,
required String title,
String? message,
required List<AlertAction> actions,
}) async {
if (PlatformUiCapabilities.isIOS) {
await showCupertinoDialog<void>(
context: context,
builder: (dialogContext) => CupertinoAlertDialog(
title: Text(title),
content: message == null ? null : Text(message),
actions: [
for (final action in actions)
CupertinoDialogAction(
onPressed:
!action.enabled || action.style == AlertActionStyle.disabled
? null
: () {
Navigator.of(dialogContext).pop();
action.onPressed();
},
isDefaultAction: action.style == AlertActionStyle.primary,
isDestructiveAction:
action.style == AlertActionStyle.destructive,
child: Text(action.title),
),
],
),
);
return;
}
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: message == null ? null : Text(message),
actions: [
for (final action in actions)
TextButton(
style: action.style == AlertActionStyle.destructive
? TextButton.styleFrom(
foregroundColor: Theme.of(
dialogContext,
).colorScheme.error,
)
: null,
onPressed:
!action.enabled || action.style == AlertActionStyle.disabled
? null
: () {
Navigator.of(dialogContext).pop();
action.onPressed();
},
child: Text(action.title),
),
],
),
);
}
static Future<String?> inputShow({
required BuildContext context,
required String title,
String? message,
required List<AlertAction> actions,
required AdaptiveAlertDialogInput input,
}) async {
final controller = TextEditingController(text: input.initialValue);
try {
if (PlatformUiCapabilities.isIOS) {
return await showCupertinoDialog<String?>(
context: context,
builder: (dialogContext) => CupertinoAlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (message != null) Text(message),
const SizedBox(height: 10),
CupertinoTextField(
controller: controller,
placeholder: input.placeholder,
keyboardType: input.keyboardType,
obscureText: input.obscureText,
maxLength: input.maxLength,
autofocus: true,
),
],
),
actions: [
for (final action in actions)
CupertinoDialogAction(
onPressed:
!action.enabled ||
action.style == AlertActionStyle.disabled
? null
: () {
final isCancel =
action.style == AlertActionStyle.cancel;
Navigator.of(
dialogContext,
).pop(isCancel ? null : controller.text);
action.onPressed();
},
isDefaultAction: action.style == AlertActionStyle.primary,
isDestructiveAction:
action.style == AlertActionStyle.destructive,
child: Text(action.title),
),
],
),
);
}
return await showDialog<String?>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
keyboardType: input.keyboardType,
obscureText: input.obscureText,
maxLength: input.maxLength,
autofocus: true,
decoration: InputDecoration(hintText: input.placeholder),
),
actions: [
for (final action in actions)
TextButton(
style: action.style == AlertActionStyle.destructive
? TextButton.styleFrom(
foregroundColor: Theme.of(
dialogContext,
).colorScheme.error,
)
: null,
onPressed:
!action.enabled || action.style == AlertActionStyle.disabled
? null
: () {
Navigator.of(dialogContext).pop(
action.style == AlertActionStyle.cancel
? null
: controller.text,
);
action.onPressed();
},
child: Text(action.title),
),
],
),
);
} finally {
controller.dispose();
}
}
}

View file

@ -0,0 +1,406 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'platform_ui_capabilities.dart';
class AdaptiveTextField extends StatelessWidget {
const AdaptiveTextField({
super.key,
this.controller,
this.focusNode,
this.placeholder,
this.keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.style,
this.textAlign = TextAlign.start,
this.maxLines = 1,
this.minLines,
this.maxLength,
this.obscureText = false,
this.autocorrect = true,
this.autofocus = false,
this.enabled = true,
this.readOnly = false,
this.prefix,
this.suffix,
this.prefixIcon,
this.suffixIcon,
this.onChanged,
this.onSubmitted,
this.onTap,
this.inputFormatters,
this.padding,
this.decoration,
this.cupertinoDecoration,
});
final TextEditingController? controller;
final FocusNode? focusNode;
final String? placeholder;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final TextCapitalization textCapitalization;
final TextStyle? style;
final TextAlign textAlign;
final int? maxLines;
final int? minLines;
final int? maxLength;
final bool obscureText;
final bool autocorrect;
final bool autofocus;
final bool enabled;
final bool readOnly;
final Widget? prefix;
final Widget? suffix;
final Widget? prefixIcon;
final Widget? suffixIcon;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onTap;
final List<TextInputFormatter>? inputFormatters;
final EdgeInsetsGeometry? padding;
final InputDecoration? decoration;
final BoxDecoration? cupertinoDecoration;
@override
Widget build(BuildContext context) {
if (!PlatformUiCapabilities.isIOS) {
final effectiveDecoration = (decoration ?? const InputDecoration())
.copyWith(
hintText: decoration?.hintText ?? placeholder,
prefix: prefix ?? decoration?.prefix,
suffix: suffix ?? decoration?.suffix,
prefixIcon: prefixIcon ?? decoration?.prefixIcon,
suffixIcon: suffixIcon ?? decoration?.suffixIcon,
contentPadding: padding ?? decoration?.contentPadding,
);
return TextField(
controller: controller,
focusNode: focusNode,
decoration: effectiveDecoration,
keyboardType: keyboardType,
textInputAction: textInputAction,
textCapitalization: textCapitalization,
style: style,
textAlign: textAlign,
maxLines: maxLines,
minLines: minLines,
maxLength: maxLength,
obscureText: obscureText,
autocorrect: autocorrect,
autofocus: autofocus,
enabled: enabled,
readOnly: readOnly,
onChanged: onChanged,
onSubmitted: onSubmitted,
onTap: onTap,
inputFormatters: inputFormatters,
);
}
return CupertinoTextField(
controller: controller,
focusNode: focusNode,
placeholder: placeholder ?? decoration?.hintText,
keyboardType: keyboardType,
textInputAction: textInputAction,
textCapitalization: textCapitalization,
style: style,
textAlign: textAlign,
maxLines: maxLines,
minLines: minLines,
maxLength: maxLength,
obscureText: obscureText,
autocorrect: autocorrect,
autofocus: autofocus,
enabled: enabled,
readOnly: readOnly,
prefix:
prefix ??
decoration?.prefix ??
_paddedIcon(prefixIcon ?? decoration?.prefixIcon),
suffix:
suffix ??
decoration?.suffix ??
_paddedIcon(suffixIcon ?? decoration?.suffixIcon),
onChanged: onChanged,
onSubmitted: onSubmitted,
onTap: onTap,
inputFormatters: inputFormatters,
padding: padding ?? const EdgeInsets.all(12),
decoration:
cupertinoDecoration ??
BoxDecoration(
color: CupertinoColors.tertiarySystemBackground.resolveFrom(
context,
),
borderRadius: BorderRadius.circular(8),
),
);
}
static Widget? _paddedIcon(Widget? icon) {
if (icon == null) return null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: icon,
);
}
}
class AdaptiveTextFormField extends StatefulWidget {
const AdaptiveTextFormField({
super.key,
this.controller,
this.focusNode,
this.placeholder,
this.initialValue,
this.keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.style,
this.textAlign = TextAlign.start,
this.maxLines = 1,
this.minLines,
this.maxLength,
this.obscureText = false,
this.autocorrect = true,
this.autofocus = false,
this.enabled = true,
this.readOnly = false,
this.prefix,
this.suffix,
this.prefixIcon,
this.suffixIcon,
this.onChanged,
this.onSubmitted,
this.onTap,
this.onSaved,
this.validator,
this.inputFormatters,
this.padding,
this.decoration,
this.cupertinoDecoration,
this.autovalidateMode,
this.onTapOutside,
this.autofillHints,
});
final TextEditingController? controller;
final FocusNode? focusNode;
final String? placeholder;
final String? initialValue;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final TextCapitalization textCapitalization;
final TextStyle? style;
final TextAlign textAlign;
final int? maxLines;
final int? minLines;
final int? maxLength;
final bool obscureText;
final bool autocorrect;
final bool autofocus;
final bool enabled;
final bool readOnly;
final Widget? prefix;
final Widget? suffix;
final Widget? prefixIcon;
final Widget? suffixIcon;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onTap;
final FormFieldSetter<String>? onSaved;
final FormFieldValidator<String>? validator;
final List<TextInputFormatter>? inputFormatters;
final EdgeInsetsGeometry? padding;
final InputDecoration? decoration;
final BoxDecoration? cupertinoDecoration;
final AutovalidateMode? autovalidateMode;
final TapRegionCallback? onTapOutside;
final Iterable<String>? autofillHints;
@override
State<AdaptiveTextFormField> createState() => _AdaptiveTextFormFieldState();
}
class _AdaptiveTextFormFieldState extends State<AdaptiveTextFormField> {
final _formFieldKey = GlobalKey<FormFieldState<String>>();
TextEditingController? _internalController;
late String _initialValue;
TextEditingController get _effectiveController =>
widget.controller ?? _internalController!;
@override
void initState() {
super.initState();
_initialValue = widget.controller?.text ?? widget.initialValue ?? '';
if (widget.controller == null) {
_internalController = TextEditingController(text: _initialValue);
}
_effectiveController.addListener(_syncControllerValue);
}
@override
void didUpdateWidget(covariant AdaptiveTextFormField oldWidget) {
super.didUpdateWidget(oldWidget);
if (identical(oldWidget.controller, widget.controller)) {
if (widget.controller == null &&
oldWidget.initialValue != widget.initialValue) {
_initialValue = widget.initialValue ?? '';
}
return;
}
final previousController = oldWidget.controller ?? _internalController!;
previousController.removeListener(_syncControllerValue);
final currentValue = _formFieldKey.currentState?.value ?? '';
if (widget.controller == null) {
_internalController = TextEditingController(text: currentValue);
_initialValue = widget.initialValue ?? currentValue;
} else {
_internalController?.dispose();
_internalController = null;
_initialValue = widget.controller!.text;
}
_effectiveController.addListener(_syncControllerValue);
_syncControllerValue();
}
@override
void dispose() {
_effectiveController.removeListener(_syncControllerValue);
_internalController?.dispose();
super.dispose();
}
void _syncControllerValue() {
final value = _effectiveController.text;
if (_formFieldKey.currentState?.value == value) return;
_formFieldKey.currentState?.didChange(value);
}
void _resetController() {
_effectiveController.value = TextEditingValue(
text: _initialValue,
selection: TextSelection.collapsed(offset: _initialValue.length),
);
}
@override
Widget build(BuildContext context) {
if (!PlatformUiCapabilities.isIOS) {
return TextFormField(
key: _formFieldKey,
controller: _effectiveController,
focusNode: widget.focusNode,
decoration: (widget.decoration ?? const InputDecoration()).copyWith(
hintText: widget.decoration?.hintText ?? widget.placeholder,
prefix: widget.prefix ?? widget.decoration?.prefix,
suffix: widget.suffix ?? widget.decoration?.suffix,
prefixIcon: widget.prefixIcon ?? widget.decoration?.prefixIcon,
suffixIcon: widget.suffixIcon ?? widget.decoration?.suffixIcon,
contentPadding: widget.padding ?? widget.decoration?.contentPadding,
),
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
style: widget.style,
textAlign: widget.textAlign,
maxLines: widget.maxLines,
minLines: widget.minLines,
maxLength: widget.maxLength,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
autofocus: widget.autofocus,
enabled: widget.enabled,
readOnly: widget.readOnly,
onChanged: widget.onChanged,
onFieldSubmitted: widget.onSubmitted,
onTap: widget.onTap,
onSaved: widget.onSaved,
validator: widget.validator,
inputFormatters: widget.inputFormatters,
autovalidateMode: widget.autovalidateMode,
onTapOutside: widget.onTapOutside,
autofillHints: widget.autofillHints,
);
}
return FormField<String>(
key: _formFieldKey,
enabled: widget.enabled,
initialValue: _initialValue,
onSaved: widget.onSaved,
onReset: _resetController,
validator: widget.validator,
autovalidateMode: widget.autovalidateMode ?? AutovalidateMode.disabled,
builder: (field) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CupertinoTextField(
controller: _effectiveController,
focusNode: widget.focusNode,
placeholder: widget.placeholder ?? widget.decoration?.hintText,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
style: widget.style,
textAlign: widget.textAlign,
maxLines: widget.maxLines,
minLines: widget.minLines,
maxLength: widget.maxLength,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
autofocus: widget.autofocus,
enabled: widget.enabled,
readOnly: widget.readOnly,
prefix:
widget.prefix ??
widget.decoration?.prefix ??
AdaptiveTextField._paddedIcon(
widget.prefixIcon ?? widget.decoration?.prefixIcon,
),
suffix:
widget.suffix ??
widget.decoration?.suffix ??
AdaptiveTextField._paddedIcon(
widget.suffixIcon ?? widget.decoration?.suffixIcon,
),
onChanged: (value) {
field.didChange(value);
widget.onChanged?.call(value);
},
onSubmitted: widget.onSubmitted,
onTap: widget.onTap,
inputFormatters: widget.inputFormatters,
padding: widget.padding ?? const EdgeInsets.all(12),
decoration:
widget.cupertinoDecoration ??
BoxDecoration(
color: CupertinoColors.tertiarySystemBackground.resolveFrom(
context,
),
borderRadius: BorderRadius.circular(8),
),
onTapOutside: widget.onTapOutside,
autofillHints: widget.autofillHints,
),
if (field.hasError)
Padding(
padding: const EdgeInsets.only(top: 6, left: 6),
child: Text(
field.errorText ?? '',
style: const TextStyle(
color: CupertinoColors.systemRed,
fontSize: 12,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,928 @@
import 'package:cupertino_native_better/cupertino_native_better.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'adaptive_controls.dart';
import 'platform_ui_capabilities.dart';
enum ToolbarSpacerType { none, fixed, flexible }
class AdaptiveAppBarAction {
const AdaptiveAppBarAction({
this.iosSymbol,
this.icon,
this.title,
required this.onPressed,
this.spacerAfter = ToolbarSpacerType.none,
this.prominent = false,
this.tintColor,
}) : assert(iosSymbol != null || icon != null || title != null);
final String? iosSymbol;
final IconData? icon;
final String? title;
final VoidCallback onPressed;
final ToolbarSpacerType spacerAfter;
final bool prominent;
final Color? tintColor;
Map<String, dynamic> toNativeMap() => {
if (iosSymbol != null) 'icon': iosSymbol,
if (title != null) 'title': title,
'spacerAfter': spacerAfter.index,
if (prominent) 'prominent': true,
if (tintColor != null) 'tint': tintColor!.toARGB32(),
};
}
class AdaptiveAppBar {
const AdaptiveAppBar({
this.title,
this.subtitle,
this.actions,
this.leading,
this.useNativeToolbar = true,
this.tintColor,
this.titleWidget,
this.cupertinoNavigationBar,
this.appBar,
});
final String? title;
final String? subtitle;
final List<AdaptiveAppBarAction>? actions;
final Widget? leading;
final bool useNativeToolbar;
final Color? tintColor;
final Widget? titleWidget;
final ObstructingPreferredSizeWidget? cupertinoNavigationBar;
final PreferredSizeWidget? appBar;
AdaptiveAppBar copyWith({
String? title,
String? subtitle,
List<AdaptiveAppBarAction>? actions,
Widget? leading,
bool? useNativeToolbar,
Color? tintColor,
Widget? titleWidget,
ObstructingPreferredSizeWidget? cupertinoNavigationBar,
PreferredSizeWidget? appBar,
}) {
return AdaptiveAppBar(
title: title ?? this.title,
subtitle: subtitle ?? this.subtitle,
actions: actions ?? this.actions,
leading: leading ?? this.leading,
useNativeToolbar: useNativeToolbar ?? this.useNativeToolbar,
tintColor: tintColor ?? this.tintColor,
titleWidget: titleWidget ?? this.titleWidget,
cupertinoNavigationBar:
cupertinoNavigationBar ?? this.cupertinoNavigationBar,
appBar: appBar ?? this.appBar,
);
}
}
class AdaptiveNavigationDestination {
const AdaptiveNavigationDestination({
required this.icon,
required this.label,
this.selectedIcon,
this.isSearch = false,
this.badgeCount,
this.addSpacerAfter = false,
});
final dynamic icon;
final String label;
final dynamic selectedIcon;
final bool isSearch;
final int? badgeCount;
final bool addSpacerAfter;
}
class AdaptiveBottomNavigationBar {
const AdaptiveBottomNavigationBar({
this.items,
this.selectedIndex,
this.onTap,
this.useNativeBottomBar = true,
this.cupertinoTabBar,
this.bottomNavigationBar,
this.selectedItemColor,
this.unselectedItemColor,
});
final List<AdaptiveNavigationDestination>? items;
final int? selectedIndex;
final ValueChanged<int>? onTap;
final bool useNativeBottomBar;
final CupertinoTabBar? cupertinoTabBar;
final Widget? bottomNavigationBar;
final Color? selectedItemColor;
final Color? unselectedItemColor;
}
class AdaptiveScaffold extends StatelessWidget {
const AdaptiveScaffold({
super.key,
this.appBar,
this.bottomNavigationBar,
this.body,
this.resizeToAvoidBottomInset,
this.floatingActionButton,
this.extendBodyBehindAppBar = false,
this.drawer,
this.endDrawer,
this.drawerScrimColor,
this.onDrawerChanged,
this.onEndDrawerChanged,
this.drawerEnableOpenDragGesture = true,
this.endDrawerEnableOpenDragGesture = true,
this.scaffoldKey,
this.tabBarHidden = false,
});
final AdaptiveAppBar? appBar;
final AdaptiveBottomNavigationBar? bottomNavigationBar;
final Widget? body;
final bool? resizeToAvoidBottomInset;
final Widget? floatingActionButton;
final bool extendBodyBehindAppBar;
final Widget? drawer;
final Widget? endDrawer;
final Color? drawerScrimColor;
final DrawerCallback? onDrawerChanged;
final DrawerCallback? onEndDrawerChanged;
final bool drawerEnableOpenDragGesture;
final bool endDrawerEnableOpenDragGesture;
final GlobalKey<ScaffoldState>? scaffoldKey;
final bool tabBarHidden;
@override
Widget build(BuildContext context) {
return PlatformUiCapabilities.isIOS
? _buildCupertino(context)
: _buildMaterial(context);
}
Widget _buildCupertino(BuildContext context) {
final navigationBar = _cupertinoNavigationBar(context);
final navigation = bottomNavigationBar;
final items = navigation?.items ?? const <AdaptiveNavigationDestination>[];
final hasNavigation =
items.isNotEmpty &&
navigation?.selectedIndex != null &&
navigation?.onTap != null;
final useNativeTabBar =
hasNavigation &&
!tabBarHidden &&
navigation!.useNativeBottomBar &&
PlatformUiCapabilities.usesNativeIOS26 &&
items.length >= 2 &&
items.length <= 5;
Widget content = body ?? const SizedBox.shrink();
Widget? bottomBar;
if (hasNavigation && !tabBarHidden) {
if (useNativeTabBar) {
bottomBar = CNTabBar(
items: [for (final item in items) _nativeTabItem(item)],
currentIndex: navigation.selectedIndex!,
onTap: navigation.onTap!,
tint: navigation.selectedItemColor,
iconSize: kCupertinoNativeControlSymbolExtent,
);
} else {
bottomBar =
navigation!.cupertinoTabBar ??
CupertinoTabBar(
currentIndex: navigation.selectedIndex!,
onTap: navigation.onTap!,
activeColor: navigation.selectedItemColor,
inactiveColor:
navigation.unselectedItemColor ??
CupertinoColors.inactiveGray,
items: [
for (final item in items)
BottomNavigationBarItem(
icon: _flutterNavigationIcon(item.icon),
activeIcon: _flutterNavigationIcon(
item.selectedIcon ?? item.icon,
),
label: item.label,
),
],
);
}
}
if (bottomBar != null) {
content = useNativeTabBar
? Stack(
children: [
Positioned.fill(child: content),
Positioned(left: 0, right: 0, bottom: 0, child: bottomBar),
],
)
: Column(
children: [
Expanded(child: content),
bottomBar,
],
);
}
if (floatingActionButton != null) {
content = Stack(
children: [
Positioned.fill(child: content),
Positioned(
right: 16,
bottom: bottomBar == null ? 16 : 96,
child: floatingActionButton!,
),
],
);
}
content = DefaultTextStyle(
style: CupertinoTheme.of(context).textTheme.textStyle,
child: content,
);
Widget page = CupertinoPageScaffold(
resizeToAvoidBottomInset: resizeToAvoidBottomInset ?? !useNativeTabBar,
navigationBar: navigationBar,
child: content,
);
if (drawer != null || endDrawer != null) {
page = Scaffold(
key: scaffoldKey,
backgroundColor: Colors.transparent,
body: page,
drawer: drawer,
endDrawer: endDrawer,
drawerScrimColor: drawerScrimColor,
onDrawerChanged: onDrawerChanged,
onEndDrawerChanged: onEndDrawerChanged,
drawerEnableOpenDragGesture: drawerEnableOpenDragGesture,
endDrawerEnableOpenDragGesture: endDrawerEnableOpenDragGesture,
);
}
return page;
}
ObstructingPreferredSizeWidget? _cupertinoNavigationBar(
BuildContext context,
) {
final custom = appBar?.cupertinoNavigationBar;
if (custom != null) return custom;
if (appBar == null) return null;
final title =
appBar!.titleWidget ??
(appBar!.title == null
? null
: appBar!.subtitle?.isNotEmpty == true
? Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(appBar!.title!),
Text(appBar!.subtitle!, style: const TextStyle(fontSize: 12)),
],
)
: Text(appBar!.title!));
return CupertinoNavigationBar(
middle: title,
leading: appBar!.leading,
trailing: appBar!.actions?.isEmpty != false
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in appBar!.actions!)
_cupertinoAction(action, appBar!.tintColor),
],
),
);
}
Widget _cupertinoAction(AdaptiveAppBarAction action, Color? fallbackTint) {
if (PlatformUiCapabilities.usesNativeIOS26 &&
action.iosSymbol != null &&
action.title == null) {
return AdaptiveButton.sfSymbol(
onPressed: action.onPressed,
sfSymbol: SFSymbol(
action.iosSymbol!,
color: action.tintColor ?? fallbackTint,
),
style: action.prominent
? AdaptiveButtonStyle.prominentGlass
: AdaptiveButtonStyle.glass,
minSize: const Size.square(36),
padding: EdgeInsets.zero,
useSmoothRectangleBorder: false,
);
}
return CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 6),
onPressed: action.onPressed,
child: action.title != null
? Text(action.title!)
: Icon(action.icon ?? CupertinoIcons.circle),
);
}
Widget _buildMaterial(BuildContext context) {
final actions = appBar?.actions;
final materialAppBar =
appBar?.appBar ??
(appBar == null
? null
: AppBar(
title: appBar!.titleWidget ?? _materialAppBarTitle(appBar!),
leading: appBar!.leading,
actions: actions
?.map(
(action) => action.title != null
? TextButton(
onPressed: action.onPressed,
child: Text(action.title!),
)
: IconButton(
onPressed: action.onPressed,
icon: Icon(action.icon ?? Icons.circle),
),
)
.toList(),
));
final navigation = bottomNavigationBar;
final items = navigation?.items ?? const <AdaptiveNavigationDestination>[];
final bottomBar =
navigation?.bottomNavigationBar ??
(items.isEmpty || navigation?.selectedIndex == null
? null
: NavigationBar(
selectedIndex: navigation!.selectedIndex!,
onDestinationSelected: navigation.onTap,
indicatorColor: navigation.selectedItemColor,
destinations: [
for (final item in items)
NavigationDestination(
icon: _flutterNavigationIcon(item.icon),
selectedIcon: _flutterNavigationIcon(
item.selectedIcon ?? item.icon,
),
label: item.label,
),
],
));
return Scaffold(
key: scaffoldKey,
appBar: materialAppBar,
body: body,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
floatingActionButton: floatingActionButton,
bottomNavigationBar: tabBarHidden ? null : bottomBar,
extendBodyBehindAppBar: extendBodyBehindAppBar,
drawer: drawer,
endDrawer: endDrawer,
drawerScrimColor: drawerScrimColor,
onDrawerChanged: onDrawerChanged,
onEndDrawerChanged: onEndDrawerChanged,
drawerEnableOpenDragGesture: drawerEnableOpenDragGesture,
endDrawerEnableOpenDragGesture: endDrawerEnableOpenDragGesture,
);
}
Widget? _materialAppBarTitle(AdaptiveAppBar bar) {
final title = bar.title;
if (title == null) return null;
if (bar.subtitle?.isNotEmpty != true) return Text(title);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
Text(bar.subtitle!, style: const TextStyle(fontSize: 12)),
],
);
}
CNTabBarItem _nativeTabItem(AdaptiveNavigationDestination item) {
final icon = item.icon;
final selected = item.selectedIcon;
return CNTabBarItem(
label: item.label,
icon: icon is String ? CNSymbol(icon) : null,
activeIcon: selected is String ? CNSymbol(selected) : null,
customIcon: _iconData(icon),
activeCustomIcon: _iconData(selected),
imageAsset: _imageAsset(icon),
activeImageAsset: _imageAsset(selected),
badge: item.badgeCount == null || item.badgeCount == 0
? null
: '${item.badgeCount}',
);
}
IconData? _iconData(dynamic value) {
if (value is IconData) return value;
if (value is Icon) return value.icon;
return null;
}
CNImageAsset? _imageAsset(dynamic value) {
if (value is ImageIcon && value.image is AssetImage) {
return CNImageAsset((value.image as AssetImage).assetName);
}
if (value is AssetImage) return CNImageAsset(value.assetName);
return null;
}
Widget _flutterNavigationIcon(dynamic value) {
if (value is Widget) return value;
if (value is IconData) {
return Icon(value, size: kCupertinoNativeControlSymbolExtent);
}
if (value is String) {
return Icon(
cupertinoIconForSFSymbol(value) ?? CupertinoIcons.circle,
size: kCupertinoNativeControlSymbolExtent,
);
}
if (value is ImageProvider) {
return Image(
image: value,
width: kCupertinoNativeControlSymbolExtent,
height: kCupertinoNativeControlSymbolExtent,
);
}
return const Icon(Icons.circle, size: kCupertinoNativeControlSymbolExtent);
}
}
class AdaptiveCard extends StatelessWidget {
const AdaptiveCard({
super.key,
this.color,
this.elevation,
this.shape,
this.borderOnForeground = true,
this.margin,
this.clipBehavior,
this.semanticContainer = true,
required this.child,
this.padding,
this.borderRadius,
});
final Color? color;
final double? elevation;
final ShapeBorder? shape;
final bool borderOnForeground;
final EdgeInsetsGeometry? margin;
final Clip? clipBehavior;
final bool semanticContainer;
final Widget child;
final EdgeInsetsGeometry? padding;
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final content = padding == null
? child
: Padding(padding: padding!, child: child);
if (!PlatformUiCapabilities.isIOS) {
return Card(
color: color,
elevation: elevation,
shape:
shape ??
(borderRadius == null
? null
: RoundedRectangleBorder(borderRadius: borderRadius!)),
borderOnForeground: borderOnForeground,
margin: margin,
clipBehavior: clipBehavior,
semanticContainer: semanticContainer,
child: content,
);
}
return Semantics(
container: semanticContainer,
child: Container(
margin: margin,
clipBehavior: clipBehavior ?? Clip.none,
decoration: BoxDecoration(
color:
color ??
CupertinoColors.secondarySystemBackground.resolveFrom(context),
borderRadius: borderRadius ?? BorderRadius.circular(12),
),
child: content,
),
);
}
}
class AdaptiveListTile extends StatelessWidget {
const AdaptiveListTile({
super.key,
this.leading,
this.title,
this.subtitle,
this.trailing,
this.onTap,
this.onLongPress,
this.enabled = true,
this.selected = false,
this.hideBottomDivider = false,
this.backgroundColor,
this.separatorColor,
this.padding,
});
final Widget? leading;
final Widget? title;
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool enabled;
final bool selected;
final bool hideBottomDivider;
final Color? backgroundColor;
final Color? separatorColor;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
if (!PlatformUiCapabilities.isIOS) {
return ListTile(
leading: leading,
title: title,
subtitle: subtitle,
trailing: trailing,
onTap: enabled ? onTap : null,
onLongPress: enabled ? onLongPress : null,
enabled: enabled,
selected: selected,
tileColor: backgroundColor,
contentPadding: padding,
);
}
Widget tile = Container(
padding:
padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color:
backgroundColor ??
(selected
? CupertinoColors.systemGrey6.resolveFrom(context)
: CupertinoColors.systemBackground.resolveFrom(context)),
border: hideBottomDivider
? null
: Border(
bottom: BorderSide(
color:
separatorColor ??
CupertinoColors.separator.resolveFrom(context),
width: 0.5,
),
),
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 12)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
?title,
if (subtitle != null) ...[
const SizedBox(height: 2),
DefaultTextStyle(
style: TextStyle(
fontSize: 14,
color: CupertinoColors.secondaryLabel.resolveFrom(
context,
),
),
child: subtitle!,
),
],
],
),
),
if (trailing != null) ...[const SizedBox(width: 12), trailing!],
],
),
);
if (enabled && (onTap != null || onLongPress != null)) {
tile = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: tile,
);
}
return tile;
}
}
class AdaptiveTooltip extends StatelessWidget {
const AdaptiveTooltip({
super.key,
required this.message,
this.preferBelow = true,
this.verticalOffset,
this.padding,
this.margin,
this.height,
this.decoration,
this.textStyle,
this.waitDuration,
this.showDuration,
required this.child,
});
final String message;
final bool preferBelow;
final double? verticalOffset;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? height;
final Decoration? decoration;
final TextStyle? textStyle;
final Duration? waitDuration;
final Duration? showDuration;
final Widget child;
@override
Widget build(BuildContext context) => Tooltip(
message: message,
preferBelow: preferBelow,
verticalOffset: verticalOffset,
padding: padding,
margin: margin,
constraints: height == null ? null : BoxConstraints(minHeight: height!),
decoration: decoration,
textStyle: textStyle,
waitDuration: waitDuration,
showDuration: showDuration,
child: child,
);
}
class AdaptiveContextMenuAction {
const AdaptiveContextMenuAction({
required this.title,
required this.onPressed,
this.icon,
this.isDestructive = false,
this.isDisabled = false,
});
final String title;
final VoidCallback onPressed;
final dynamic icon;
final bool isDestructive;
final bool isDisabled;
}
class AdaptiveContextMenu extends StatelessWidget {
const AdaptiveContextMenu({
super.key,
required this.child,
required this.actions,
this.previewBuilder,
});
final Widget child;
final List<AdaptiveContextMenuAction> actions;
final Widget Function(BuildContext)? previewBuilder;
@override
Widget build(BuildContext context) {
if (PlatformUiCapabilities.isIOS) {
return CupertinoContextMenu.builder(
actions: [
for (final action in actions)
CupertinoContextMenuAction(
onPressed: action.isDisabled
? null
: () {
Navigator.of(context, rootNavigator: true).pop();
Future.microtask(action.onPressed);
},
isDestructiveAction: action.isDestructive,
trailingIcon: action.icon is IconData
? action.icon as IconData
: null,
child: Text(action.title),
),
],
builder: (context, animation) => previewBuilder?.call(context) ?? child,
);
}
return GestureDetector(
onLongPress: () async {
final renderObject = context.findRenderObject();
final overlayObject = Overlay.of(context).context.findRenderObject();
if (renderObject is! RenderBox ||
overlayObject is! RenderBox ||
!renderObject.attached ||
!overlayObject.attached) {
return;
}
final topLeft = renderObject.localToGlobal(
Offset.zero,
ancestor: overlayObject,
);
final bottomRight = renderObject.localToGlobal(
renderObject.size.bottomRight(Offset.zero),
ancestor: overlayObject,
);
final selected = await showMenu<int>(
context: context,
position: RelativeRect.fromRect(
Rect.fromPoints(topLeft, bottomRight),
Offset.zero & overlayObject.size,
),
items: [
for (var index = 0; index < actions.length; index++)
PopupMenuItem<int>(
value: index,
enabled: !actions[index].isDisabled,
child: Text(
actions[index].title,
style: actions[index].isDestructive
? const TextStyle(color: Colors.red)
: null,
),
),
],
);
if (selected != null) actions[selected].onPressed();
},
child: child,
);
}
}
class AdaptiveExpansionTile extends StatefulWidget {
const AdaptiveExpansionTile({
super.key,
this.leading,
required this.title,
this.subtitle,
this.trailing,
required this.children,
this.onExpansionChanged,
this.initiallyExpanded = false,
this.maintainState = false,
this.tilePadding,
this.childrenPadding,
this.backgroundColor,
this.collapsedBackgroundColor,
this.textColor,
this.collapsedTextColor,
this.iconColor,
this.collapsedIconColor,
this.shape,
this.collapsedShape,
this.enabled = true,
this.clipBehavior = Clip.none,
this.expandedAlignment,
this.expandedCrossAxisAlignment,
});
final Widget? leading;
final Widget title;
final Widget? subtitle;
final Widget? trailing;
final List<Widget> children;
final ValueChanged<bool>? onExpansionChanged;
final bool initiallyExpanded;
final bool maintainState;
final EdgeInsetsGeometry? tilePadding;
final EdgeInsetsGeometry? childrenPadding;
final Color? backgroundColor;
final Color? collapsedBackgroundColor;
final Color? textColor;
final Color? collapsedTextColor;
final Color? iconColor;
final Color? collapsedIconColor;
final ShapeBorder? shape;
final ShapeBorder? collapsedShape;
final bool enabled;
final Clip clipBehavior;
final Alignment? expandedAlignment;
final CrossAxisAlignment? expandedCrossAxisAlignment;
@override
State<AdaptiveExpansionTile> createState() => _AdaptiveExpansionTileState();
}
class _AdaptiveExpansionTileState extends State<AdaptiveExpansionTile> {
late bool _expanded = widget.initiallyExpanded;
@override
void didUpdateWidget(covariant AdaptiveExpansionTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initiallyExpanded != widget.initiallyExpanded) {
_expanded = widget.initiallyExpanded;
}
}
@override
Widget build(BuildContext context) {
if (!PlatformUiCapabilities.isIOS) {
return ExpansionTile(
leading: widget.leading,
title: widget.title,
subtitle: widget.subtitle,
trailing: widget.trailing,
initiallyExpanded: widget.initiallyExpanded,
maintainState: widget.maintainState,
onExpansionChanged: widget.onExpansionChanged,
tilePadding: widget.tilePadding,
childrenPadding: widget.childrenPadding,
backgroundColor: widget.backgroundColor,
collapsedBackgroundColor: widget.collapsedBackgroundColor,
textColor: widget.textColor,
collapsedTextColor: widget.collapsedTextColor,
iconColor: widget.iconColor,
collapsedIconColor: widget.collapsedIconColor,
shape: widget.shape,
collapsedShape: widget.collapsedShape,
enabled: widget.enabled,
clipBehavior: widget.clipBehavior,
expandedAlignment: widget.expandedAlignment,
expandedCrossAxisAlignment: widget.expandedCrossAxisAlignment,
children: widget.children,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.enabled
? () {
setState(() => _expanded = !_expanded);
widget.onExpansionChanged?.call(_expanded);
}
: null,
child: Padding(
padding:
widget.tilePadding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
if (widget.leading != null) ...[
widget.leading!,
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.title,
if (widget.subtitle != null) widget.subtitle!,
],
),
),
widget.trailing ??
Icon(
_expanded
? CupertinoIcons.chevron_up
: CupertinoIcons.chevron_down,
size: 18,
color: _expanded
? widget.iconColor
: widget.collapsedIconColor,
),
],
),
),
),
if (_expanded || widget.maintainState)
Offstage(
offstage: !_expanded,
child: Padding(
padding: widget.childrenPadding ?? EdgeInsets.zero,
child: Column(
crossAxisAlignment:
widget.expandedCrossAxisAlignment ??
CrossAxisAlignment.center,
children: widget.children,
),
),
),
],
);
}
}

View file

@ -0,0 +1,3 @@
import 'dart:io' show Platform;
String get operatingSystemVersion => Platform.operatingSystemVersion;

View file

@ -0,0 +1 @@
String get operatingSystemVersion => '';

View file

@ -0,0 +1,85 @@
import 'package:cupertino_native_better/cupertino_native_better.dart';
import 'package:flutter/foundation.dart';
import 'operating_system_version_stub.dart'
if (dart.library.io) 'operating_system_version_io.dart';
/// Central capability gate for Conduit's platform UI compatibility layer.
abstract final class PlatformUiCapabilities {
@visibleForTesting
static TargetPlatform? debugPlatformOverride;
@visibleForTesting
static int? debugIOSMajorVersionOverride;
@visibleForTesting
static bool? debugNativeIOS26Override;
static TargetPlatform get platform =>
debugPlatformOverride ?? defaultTargetPlatform;
static bool get isIOS => !kIsWeb && platform == TargetPlatform.iOS;
static bool get isAndroid => !kIsWeb && platform == TargetPlatform.android;
static int get iOSMajorVersion {
if (!isIOS) return 0;
final override = debugIOSMajorVersionOverride;
if (override != null) return override;
return _parseIOSMajorVersion(operatingSystemVersion) ?? 0;
}
/// True only when both Conduit's safe parser and the package agree.
///
/// A missing or unparseable version deliberately falls back to Flutter.
static bool get usesNativeIOS26 {
if (!isIOS) return false;
if (iOSMajorVersion < 26) return false;
final forced = debugNativeIOS26Override;
if (forced != null) return forced;
try {
return PlatformVersion.isIOS26OrLater;
} catch (_) {
return false;
}
}
@visibleForTesting
static int? parseIOSMajorVersion(String value) =>
_parseIOSMajorVersion(value);
static int? _parseIOSMajorVersion(String value) {
final versionMatch = RegExp(
r'^\s*Version\s+(\d+)',
caseSensitive: false,
).firstMatch(value);
final fallbackMatch = RegExp(r'^\s*(\d+)(?:\.\d+)?\b').firstMatch(value);
return int.tryParse((versionMatch ?? fallbackMatch)?.group(1) ?? '');
}
@visibleForTesting
static void resetDebugOverrides() {
debugPlatformOverride = null;
debugIOSMajorVersionOverride = null;
debugNativeIOS26Override = null;
}
}
/// Backwards-compatible platform surface used by existing Conduit widgets.
abstract final class PlatformInfo {
static bool get isIOS => PlatformUiCapabilities.isIOS;
static bool get isAndroid => PlatformUiCapabilities.isAndroid;
static bool get isMacOS =>
!kIsWeb && PlatformUiCapabilities.platform == TargetPlatform.macOS;
static bool get isWindows =>
!kIsWeb && PlatformUiCapabilities.platform == TargetPlatform.windows;
static bool get isLinux =>
!kIsWeb && PlatformUiCapabilities.platform == TargetPlatform.linux;
static bool get isFuchsia =>
!kIsWeb && PlatformUiCapabilities.platform == TargetPlatform.fuchsia;
static bool get isWeb => kIsWeb;
static int get iOSVersion => PlatformUiCapabilities.iOSMajorVersion;
static bool isIOS26OrHigher() => PlatformUiCapabilities.usesNativeIOS26;
static bool isBelowIOS26() => isIOS && iOSVersion > 0 && iOSVersion < 26;
static bool isIOSVersionInRange(int min, int max) =>
isIOS && iOSVersion >= min && iOSVersion <= max;
}

View file

@ -1,13 +1,12 @@
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
// ignore: implementation_imports
import 'package:adaptive_platform_ui/src/widgets/ios26/ios26_native_toolbar.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/shared/theme/theme_extensions.dart';
import 'package:flutter/cupertino.dart';
/// iOS 26 sidebar scaffold workaround for `adaptive_platform_ui`.
///
/// The sidebar owns tab body switching and keeps one body tree mounted. This
/// local scaffold also lets a fully closed mobile drawer omit the underlying
/// UIKit toolbar and tab-bar views without removing their Flutter geometry.
import 'adaptive_toolbar_components.dart';
const double _nativeTabBarPlaceholderHeight = 50;
/// iOS 26 sidebar shell backed by cupertino_native_better chrome.
class SidebarIos26Scaffold extends StatelessWidget {
const SidebarIos26Scaffold({
super.key,
@ -15,7 +14,6 @@ class SidebarIos26Scaffold extends StatelessWidget {
required this.body,
this.leading,
this.actions,
this.minimizeBehavior = TabBarMinimizeBehavior.never,
this.showNativeView = true,
});
@ -23,7 +21,6 @@ class SidebarIos26Scaffold extends StatelessWidget {
final Widget body;
final Widget? leading;
final List<AdaptiveAppBarAction>? actions;
final TabBarMinimizeBehavior minimizeBehavior;
final bool showNativeView;
@override
@ -32,62 +29,156 @@ class SidebarIos26Scaffold extends StatelessWidget {
final routeAllowsNativeView =
(route?.isCurrent ?? true) ||
route?.animation?.status == AnimationStatus.reverse;
final shouldShowNativeView = showNativeView && routeAllowsNativeView;
final hasToolbarContent =
leading != null || (actions != null && actions!.isNotEmpty);
final composeNativeViews = showNativeView && routeAllowsNativeView;
final navigation = bottomNavigationBar;
final destinations =
navigation?.items ?? const <AdaptiveNavigationDestination>[];
final hasBottomNavigation =
bottomNavigationBar?.items != null &&
bottomNavigationBar!.items!.isNotEmpty &&
bottomNavigationBar!.selectedIndex != null &&
bottomNavigationBar!.onTap != null;
final brightness = MediaQuery.platformBrightnessOf(context);
final textColor = brightness == Brightness.dark
? CupertinoColors.white
: CupertinoColors.black;
destinations.length >= 2 &&
destinations.length <= 5 &&
navigation?.selectedIndex != null &&
navigation?.onTap != null;
final safePadding = MediaQuery.paddingOf(context);
final textColor = CupertinoColors.label.resolveFrom(context);
final hasNavigationBar = leading != null || actions?.isNotEmpty == true;
final toolbarActions = actions ?? const <AdaptiveAppBarAction>[];
final toolbarActionsWidth = toolbarActions.isEmpty
? 0.0
: (toolbarActions.length * TouchTarget.minimum) +
((toolbarActions.length - 1) * Spacing.sm);
return CupertinoPageScaffold(
resizeToAvoidBottomInset: !hasBottomNavigation,
navigationBar: hasNavigationBar
? ConduitAdaptiveCupertinoNavigationBar(
textScaler: MediaQuery.textScalerOf(context),
leading: leading ?? const SizedBox.shrink(),
trailing: toolbarActions.isEmpty
? null
: SizedBox(
width: toolbarActionsWidth,
height: TouchTarget.minimum,
child: composeNativeViews
? _NativeToolbarActions(actions: toolbarActions)
: null,
),
)
: null,
child: Stack(
children: [
DefaultTextStyle(
style: TextStyle(color: textColor, fontSize: 17),
child: body,
),
if (hasToolbarContent)
Positioned(
left: 0,
right: 0,
top: 0,
child: IOS26NativeToolbar(
leading: leading,
actions: actions,
showNativeView: shouldShowNativeView,
onActionTap: (index) {
final currentActions = actions;
if (currentActions != null &&
index >= 0 &&
index < currentActions.length) {
currentActions[index].onPressed();
}
},
),
),
if (hasBottomNavigation)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: IOS26NativeTabBar(
destinations: bottomNavigationBar!.items!,
selectedIndex: bottomNavigationBar!.selectedIndex!,
onTap: bottomNavigationBar!.onTap!,
tint: CupertinoTheme.of(context).primaryColor,
minimizeBehavior: minimizeBehavior,
showNativeView: shouldShowNativeView,
),
child: composeNativeViews
? CNTabBar(
items: [
for (final destination in destinations)
_nativeTabItem(destination),
],
currentIndex: navigation!.selectedIndex!,
onTap: navigation.onTap!,
tint:
navigation.selectedItemColor ??
CupertinoTheme.of(context).primaryColor,
iconSize: kCupertinoNativeControlSymbolExtent,
)
: SizedBox(
height:
safePadding.bottom + _nativeTabBarPlaceholderHeight,
),
),
],
),
);
}
static CNTabBarItem _nativeTabItem(
AdaptiveNavigationDestination destination,
) {
return CNTabBarItem(
label: destination.label,
icon: destination.icon is String
? CNSymbol(destination.icon as String)
: null,
activeIcon: destination.selectedIcon is String
? CNSymbol(destination.selectedIcon as String)
: null,
customIcon: _iconData(destination.icon),
activeCustomIcon: _iconData(destination.selectedIcon),
imageAsset: _imageAsset(destination.icon),
activeImageAsset: _imageAsset(destination.selectedIcon),
badge: destination.badgeCount == null || destination.badgeCount == 0
? null
: '${destination.badgeCount}',
);
}
static IconData? _iconData(dynamic icon) {
if (icon is IconData) return icon;
if (icon is Icon) return icon.icon;
return null;
}
static CNImageAsset? _imageAsset(dynamic icon) {
if (icon is ImageIcon && icon.image is AssetImage) {
return CNImageAsset((icon.image as AssetImage).assetName);
}
if (icon is AssetImage) return CNImageAsset(icon.assetName);
return null;
}
}
class _NativeToolbarActions extends StatelessWidget {
const _NativeToolbarActions({required this.actions});
final List<AdaptiveAppBarAction> actions;
@override
Widget build(BuildContext context) {
return CNGlassButtonGroup.fromWidgets(
spacing: 8,
spacingForGlass: 36,
buttonWidgets: [
for (final action in actions)
if (action.title case final title?)
CNButton(
label: title,
onPressed: action.onPressed,
tint: action.tintColor,
config: CNButtonConfig(
minHeight: TouchTarget.minimum,
shrinkWrap: true,
style: action.prominent
? CNButtonStyle.prominentGlass
: CNButtonStyle.glass,
),
)
else
CNButton.icon(
icon: action.iosSymbol == null
? null
: CNSymbol(
action.iosSymbol!,
size: kCupertinoNativeControlSymbolExtent,
),
customIcon: action.iosSymbol == null ? action.icon : null,
onPressed: action.onPressed,
tint: action.tintColor,
config: CNButtonConfig(
minHeight: TouchTarget.minimum,
width: TouchTarget.minimum,
style: action.prominent
? CNButtonStyle.prominentGlass
: CNButtonStyle.glass,
),
),
],
);
}
}

View file

@ -1,9 +1,9 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:conduit/core/services/native_sheet_bridge.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../theme/conduit_input_styles.dart';
@ -70,7 +70,41 @@ class ThemedDialogs {
final effectiveConfirmText = confirmText ?? l10n?.confirm ?? 'Confirm';
final effectiveCancelText = cancelText ?? l10n?.cancel ?? 'Cancel';
if (Platform.isIOS) {
if (PlatformUiCapabilities.usesNativeIOS26 && barrierDismissible) {
try {
final result = await NativeSheetBridge.instance.presentSheet(
rethrowErrors: true,
root: NativeSheetDetailConfig(
id: 'confirmation-dialog',
title: title,
subtitle: message,
items: [
NativeSheetItemConfig(
id: 'cancel',
title: effectiveCancelText,
sfSymbol: 'xmark',
dismissOnSelect: true,
actionId: 'cancel',
),
NativeSheetItemConfig(
id: 'confirm',
title: effectiveConfirmText,
sfSymbol: isDestructive ? 'trash' : 'checkmark',
destructive: isDestructive,
dismissOnSelect: true,
actionId: 'confirm',
),
],
),
);
return result?.actionId == 'confirm';
} catch (_) {
// Fall through to the Flutter Cupertino dialog when the bridge fails.
}
if (!context.mounted) return false;
}
if (PlatformUiCapabilities.isIOS) {
final completer = Completer<bool>();
await AdaptiveAlertDialog.show(
context: context,
@ -185,7 +219,40 @@ class ThemedDialogs {
final effectiveConfirmText = confirmText ?? l10n?.save ?? 'Save';
final effectiveCancelText = cancelText ?? l10n?.cancel ?? 'Cancel';
if (Platform.isIOS) {
final nativeEditorPreservesInputConfiguration =
keyboardType == null &&
textCapitalization == TextCapitalization.sentences &&
(maxLength == null || maxLength == TextField.noMaxLength);
if (PlatformUiCapabilities.usesNativeIOS26 &&
nativeEditorPreservesInputConfiguration) {
try {
final result = await NativeSheetBridge.instance.presentTextEditor(
title: title,
value: initialValue ?? '',
placeholder: hintText,
sendLabel: effectiveConfirmText,
valueId: 'text',
sendActionId: 'confirm',
closeActionId: 'cancel',
rethrowErrors: true,
);
if (result?.actionId != 'confirm') return null;
final value = result?.values['text'];
if (value is! String) return null;
final bounded = maxLength == null || maxLength == TextField.noMaxLength
? value
: value.characters.take(maxLength).toString();
final trimmed = bounded.trim();
if (trimmed.isEmpty) return null;
if (initialValue != null && trimmed == initialValue.trim()) return null;
return trimmed;
} catch (_) {
// Fall through to the Flutter Cupertino dialog when the bridge fails.
}
if (!context.mounted) return null;
}
if (PlatformUiCapabilities.isIOS) {
final result = await AdaptiveAlertDialog.inputShow(
context: context,
title: title,

View file

@ -1,7 +1,7 @@
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
@ -136,10 +136,15 @@ class ThemedSheets {
shape: platform == TargetPlatform.iOS ? glassShape : plainShape,
clipBehavior: Clip.antiAlias,
sheetAnimationStyle: AnimationStyle.noAnimation,
builder: (sheetContext) => _SheetCoverageBoundary(
coverage: coverage,
child: Builder(builder: builder),
),
builder: (sheetContext) {
final child = _SheetCoverageBoundary(
coverage: coverage,
child: Builder(builder: builder),
);
return PlatformUiCapabilities.usesNativeIOS26
? CNSheetGeometryProbe(child: child)
: child;
},
),
);
}
@ -151,6 +156,9 @@ class ThemedSheets {
coverage: coverage,
child: Builder(builder: builder),
);
if (PlatformUiCapabilities.usesNativeIOS26) {
child = CNSheetGeometryProbe(child: child);
}
final Route<T> route;
if (platform == TargetPlatform.iOS) {
@ -196,25 +204,47 @@ class ThemedSheets {
final resolvedShape = shape ?? roundedShapeFor(context);
return _showTracked<T>(
context: context,
present: (coverage) => showModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
useSafeArea: useSafeArea,
enableDrag: enableDrag,
isDismissible: isDismissible,
useRootNavigator: useRootNavigator,
backgroundColor: Colors.transparent,
barrierColor: barrierColor,
routeSettings: routeSettings,
constraints: _sheetConstraints(context, constraints),
shape: resolvedShape,
elevation: elevation,
clipBehavior: clipBehavior ?? Clip.antiAlias,
builder: (sheetContext) => _SheetCoverageBoundary(
coverage: coverage,
child: builder(sheetContext),
),
),
present: (coverage) {
Widget sheetBuilder(BuildContext sheetContext) =>
_SheetCoverageBoundary(
coverage: coverage,
child: builder(sheetContext),
);
if (PlatformUiCapabilities.usesNativeIOS26) {
return CNBottomSheet.show<T>(
context: context,
isScrollControlled: isScrollControlled,
useSafeArea: useSafeArea,
enableDrag: enableDrag,
isDismissible: isDismissible,
useRootNavigator: useRootNavigator,
backgroundColor: Colors.transparent,
barrierColor: barrierColor,
routeSettings: routeSettings,
constraints: _sheetConstraints(context, constraints),
shape: resolvedShape,
elevation: elevation,
clipBehavior: clipBehavior ?? Clip.antiAlias,
builder: sheetBuilder,
);
}
return showModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
useSafeArea: useSafeArea,
enableDrag: enableDrag,
isDismissible: isDismissible,
useRootNavigator: useRootNavigator,
backgroundColor: Colors.transparent,
barrierColor: barrierColor,
routeSettings: routeSettings,
constraints: _sheetConstraints(context, constraints),
shape: resolvedShape,
elevation: elevation,
clipBehavior: clipBehavior ?? Clip.antiAlias,
builder: sheetBuilder,
);
},
);
}
@ -229,26 +259,59 @@ class ThemedSheets {
}) {
return _showTracked<T>(
context: context,
present: (coverage) => showModalBottomSheet<T>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
isDismissible: isDismissible,
enableDrag: false,
barrierColor: barrierColor,
routeSettings: routeSettings,
backgroundColor: context.conduitTheme.surfaceBackground,
shape: roundedShapeFor(context),
clipBehavior: Clip.antiAlias,
constraints: _sheetConstraints(context, null, capWidth: false),
builder: (sheetContext) => _SheetCoverageBoundary(
coverage: coverage,
child: FractionallySizedBox(
heightFactor: DraggableModalSheetSizes.maxChildSize,
child: builder(sheetContext),
),
),
),
present: (coverage) {
Widget sheetBuilder(BuildContext sheetContext) =>
_SheetCoverageBoundary(
coverage: coverage,
child: FractionallySizedBox(
heightFactor: DraggableModalSheetSizes.maxChildSize,
child: builder(sheetContext),
),
);
final arguments = (
context: context,
useRootNavigator: true,
isScrollControlled: true,
isDismissible: isDismissible,
enableDrag: false,
barrierColor: barrierColor,
routeSettings: routeSettings,
backgroundColor: context.conduitTheme.surfaceBackground,
shape: roundedShapeFor(context),
clipBehavior: Clip.antiAlias,
constraints: _sheetConstraints(context, null, capWidth: false),
);
if (PlatformUiCapabilities.usesNativeIOS26) {
return CNBottomSheet.show<T>(
context: arguments.context,
useRootNavigator: arguments.useRootNavigator,
isScrollControlled: arguments.isScrollControlled,
isDismissible: arguments.isDismissible,
enableDrag: arguments.enableDrag,
barrierColor: arguments.barrierColor,
routeSettings: arguments.routeSettings,
backgroundColor: arguments.backgroundColor,
shape: arguments.shape,
clipBehavior: arguments.clipBehavior,
constraints: arguments.constraints,
builder: sheetBuilder,
);
}
return showModalBottomSheet<T>(
context: arguments.context,
useRootNavigator: arguments.useRootNavigator,
isScrollControlled: arguments.isScrollControlled,
isDismissible: arguments.isDismissible,
enableDrag: arguments.enableDrag,
barrierColor: arguments.barrierColor,
routeSettings: arguments.routeSettings,
backgroundColor: arguments.backgroundColor,
shape: arguments.shape,
clipBehavior: arguments.clipBehavior,
constraints: arguments.constraints,
builder: sheetBuilder,
);
},
);
}

View file

@ -9,14 +9,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "99.0.0"
adaptive_platform_ui:
dependency: "direct main"
description:
name: adaptive_platform_ui
sha256: "43ab9f08b3fea200b5281720653603c0b689cdb8ae8969ed4c8e26c1c11e19a0"
url: "https://pub.dev"
source: hosted
version: "0.1.110"
analysis_server_plugin:
dependency: transitive
description:
@ -313,6 +305,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
cupertino_native_better:
dependency: "direct main"
description:
name: cupertino_native_better
sha256: e9a09ec11f8fb62d04ca39fcfe1e6735945bd9ed5bf8990f6f494d64c3bc2127
url: "https://pub.dev"
source: hosted
version: "1.5.4"
dart_style:
dependency: transitive
description:

View file

@ -93,7 +93,7 @@ dependencies:
home_widget: ^0.9.2
flutter_highlight: ^0.7.0
just_audio: ^0.10.5
adaptive_platform_ui: ^0.1.110
cupertino_native_better: ^1.5.4
flutter_tex: ^5.2.6
gaimon: ^1.4.2
xterm: ^4.0.0

View file

@ -110,6 +110,59 @@ void main() {
check(adapter.requestedUris).isEmpty();
});
test('native controls reuse one cached avatar request', () async {
final adapter = _BytesAdapter([4, 5, 6]);
final api = _buildApi(
const ServerConfig(
id: 'server',
name: 'Server',
url: 'https://chat.example.test',
),
adapter,
);
final hydrator = NativeSheetAvatarBytesHydrator();
const avatarUrl =
'https://chat.example.test/api/v1/users/profile/image?id=user';
final first = await hydrator.loadAvatarBytes(
api: api,
avatarUrl: avatarUrl,
);
final second = await hydrator.loadAvatarBytes(
api: api,
avatarUrl: avatarUrl,
);
check(identical(first, second)).isTrue();
check(first!.toList()).deepEquals([4, 5, 6]);
check(adapter.requestedUris).deepEquals([avatarUrl]);
});
test('persistent native controls wait for a delayed avatar', () async {
final adapter = _ControlledAdapter();
final api = _buildApi(
const ServerConfig(
id: 'server',
name: 'Server',
url: 'https://chat.example.test',
),
adapter,
);
final hydration = NativeSheetAvatarBytesHydrator().loadAvatarBytes(
api: api,
avatarUrl: 'https://chat.example.test/avatar.png',
maxWait: const Duration(seconds: 5),
);
var completed = false;
hydration.whenComplete(() => completed = true);
await _waitForRequestCount(adapter, 1);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(completed, isFalse);
adapter.requests.single.complete([7, 8, 9]);
check((await hydration)!.toList()).deepEquals([7, 8, 9]);
});
test('rejects oversized custom-TLS avatar responses', () async {
final adapter = _BytesAdapter(List<int>.filled(1024 * 1024 + 1, 7));
final api = _buildApi(

View file

@ -1,6 +1,6 @@
import 'dart:ui' as ui;
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:checks/checks.dart';
import 'package:conduit/core/models/backend_config.dart';
import 'package:conduit/core/models/server_config.dart';

View file

@ -1,4 +1,4 @@
import 'dart:io';
import 'dart:ui' show SemanticsAction;
import 'package:conduit/core/models/model.dart';
import 'package:conduit/core/models/server_config.dart';
@ -15,8 +15,9 @@ import 'package:conduit/l10n/app_localizations.dart';
import 'package:conduit/shared/theme/theme_extensions.dart';
import 'package:conduit/shared/widgets/adaptive_toolbar_components.dart';
import 'package:conduit/shared/widgets/themed_sheets.dart';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:checks/checks.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@ -82,7 +83,6 @@ void main() {
ConduitNativeToolbarAction(
iosSymbol: 'square.and.pencil',
accessibilityLabel: 'New Chat',
symbolSize: kConduitNativeVisibilitySymbolExtent,
onPressed: () {},
),
ConduitNativeToolbarAction(
@ -106,10 +106,10 @@ void main() {
final creationParams = encodeConduitNativeToolbarActionGroupParams(actions);
final params = creationParams['actions']! as List<Map<String, Object?>>;
check(creationParams['symbolSize']).equals(22.0);
check(creationParams.containsKey('symbolSize')).isFalse();
check(params.length).equals(2);
check(params[0]['iosSymbol']).equals('square.and.pencil');
check(params[0]['symbolSize']).equals(18.0);
check(params[0].containsKey('symbolSize')).isFalse();
check(params[1]['iosSymbol']).equals('ellipsis');
final menuItems = params[1]['menuItems']! as List<Map<String, Object?>>;
check(
@ -118,12 +118,11 @@ void main() {
check(menuItems[1]['isDestructive']).equals(true);
});
test('native toolbar action groups support one optical-sized menu', () {
test('native toolbar action groups leave glyph sizing to the package', () {
final params = encodeConduitNativeToolbarActionGroupParams([
ConduitNativeToolbarAction(
iosSymbol: 'ellipsis',
accessibilityLabel: 'More',
symbolSize: kConduitNativeToolbarSymbolExtent,
menuItems: [
ConduitNativeToolbarMenuItem(
label: 'Delete',
@ -137,7 +136,7 @@ void main() {
check(actions).length.equals(1);
check(actions.single['iosSymbol']).equals('ellipsis');
check(actions.single['symbolSize']).equals(22.0);
check(actions.single.containsKey('symbolSize')).isFalse();
});
test('native toolbar menu adapters preserve values, order, and state', () {
@ -146,12 +145,12 @@ void main() {
iosSymbol: 'ellipsis',
accessibilityLabel: 'More',
tintColor: Colors.black,
symbolSize: kConduitNativeToolbarSymbolExtent,
items: const [
AdaptivePopupMenuItem<String>(
value: 'edit',
label: 'Edit',
icon: 'pencil',
checked: true,
),
AdaptivePopupMenuItem<String>(
value: 'delete',
@ -168,6 +167,7 @@ void main() {
check(
action!.menuItems.map((item) => item.label),
).deepEquals(['Edit', 'Delete']);
check(action.menuItems[0].isChecked).isTrue();
check(action.menuItems[1].enabled).isFalse();
check(action.menuItems[1].isDestructive).isTrue();
action.menuItems[0].onSelected();
@ -203,27 +203,6 @@ void main() {
check(idleStyle.fontStyle).equals(FontStyle.normal);
});
test('only Android enlarges the compact overflow add glyph', () {
check(
ModernChatInput.debugOverflowIconSize(
isAndroid: true,
attachmentPanelVisible: false,
),
).equals(28);
check(
ModernChatInput.debugOverflowIconSize(
isAndroid: false,
attachmentPanelVisible: false,
),
).equals(IconSize.large);
check(
ModernChatInput.debugOverflowIconSize(
isAndroid: true,
attachmentPanelVisible: true,
),
).equals(IconSize.large);
});
test('OpenWebUI explicit attachment capability denials fail closed', () {
final model = Model.fromJson({
'id': 'text-only',
@ -934,7 +913,12 @@ void main() {
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: ModernChatInput(onSendMessage: (_) {})),
home: Scaffold(
body: ModernChatInput(
onSendMessage: (_) {},
onFileAttachment: _noop,
),
),
),
),
);
@ -967,6 +951,22 @@ void main() {
expect(find.byKey(expandedInputKey), findsOneWidget);
expect(find.byKey(expandedButtonsKey), findsOneWidget);
expect(find.byKey(quickPillsKey), findsNothing);
expect(
find.byKey(const ValueKey<String>('composer-expand-button')),
findsNothing,
);
await tester.enterText(
find.byType(TextField),
'first line\nsecond line\nthird line\nfourth line',
);
await tester.pump();
await tester.pump();
expect(
find.byKey(const ValueKey<String>('composer-expand-button')),
findsOneWidget,
);
final inputInsets = tester
.widget<Padding>(find.byKey(expandedInputKey))
@ -993,6 +993,288 @@ void main() {
);
});
testWidgets('iOS 26 plain composer actions stay Flutter-rendered', (
tester,
) async {
PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.iOS;
PlatformUiCapabilities.debugIOSMajorVersionOverride = 26;
PlatformUiCapabilities.debugNativeIOS26Override = true;
addTearDown(PlatformUiCapabilities.resetDebugOverrides);
await tester.pumpWidget(
ProviderScope(
overrides: [apiServiceProvider.overrideWithValue(null)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: ModernChatInput(onSendMessage: (_) {})),
),
),
);
await tester.pump();
await tester.enterText(
find.byType(TextField),
'first line\nsecond line\nthird line\nfourth line',
);
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
final expandButton = tester.widget<AdaptiveButton>(
find.byKey(const ValueKey<String>('composer-expand-button')),
);
expect(expandButton.sfSymbol, isNull);
expect(
tester
.widget<ConduitSystemAdaptiveIcon>(
find.descendant(
of: find.byKey(const ValueKey<String>('composer-expand-button')),
matching: find.byType(ConduitSystemAdaptiveIcon),
),
)
.size,
IconSize.medium,
);
final overflowButton = tester.widget<AdaptiveButton>(
find.descendant(
of: find.byKey(const ValueKey<String>('composer-overflow-button')),
matching: find.byType(AdaptiveButton),
),
);
expect(overflowButton.sfSymbol, isNull);
expect(
tester
.widget<ConduitSystemAdaptiveIcon>(
find.descendant(
of: find.byKey(
const ValueKey<String>('composer-overflow-button'),
),
matching: find.byType(ConduitSystemAdaptiveIcon),
),
)
.size,
IconSize.large,
);
expect(
tester.getSize(
find.byKey(const ValueKey<String>('composer-expand-button')),
),
const Size.square(TouchTarget.minimum),
);
final expandRect = tester.getRect(
find.byKey(const ValueKey<String>('composer-expand-button')),
);
final inputRect = tester.getRect(
find.byKey(const ValueKey<String>('composer-expanded-input')),
);
final textFieldRect = tester.getRect(find.byType(TextField));
expect(
find.byKey(const ValueKey<String>('composer-expand-row')),
findsNothing,
);
expect(expandRect.top, inputRect.top + Spacing.sm);
expect(
textFieldRect.right + Spacing.xs,
lessThanOrEqualTo(expandRect.left),
);
});
testWidgets('iOS 26 composer preserves native surfaces across layout swaps', (
tester,
) async {
PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.iOS;
PlatformUiCapabilities.debugIOSMajorVersionOverride = 26;
PlatformUiCapabilities.debugNativeIOS26Override = true;
addTearDown(PlatformUiCapabilities.resetDebugOverrides);
await tester.pumpWidget(
ProviderScope(
overrides: [apiServiceProvider.overrideWithValue(null)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: ModernChatInput(onSendMessage: (_) {})),
),
),
);
await tester.pump();
final shellElement = tester.element(
find.byKey(const ValueKey<String>('composer-native-shell')),
);
final backdropElement = tester.element(
find.byKey(const ValueKey<String>('composer-native-glass-backdrop')),
);
await tester.enterText(
find.byType(TextField),
'first line\nsecond line\nthird line\nfourth line',
);
await tester.pump();
await tester.pump();
expect(
tester.element(
find.byKey(const ValueKey<String>('composer-native-shell')),
),
same(shellElement),
);
expect(
tester.element(
find.byKey(const ValueKey<String>('composer-native-glass-backdrop')),
),
same(backdropElement),
);
expect(
find.byKey(const ValueKey<String>('expanded-composer-shell')),
findsOneWidget,
);
});
testWidgets('iOS 26 native primary control ignores unrelated rebuilds', (
tester,
) async {
PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.iOS;
PlatformUiCapabilities.debugIOSMajorVersionOverride = 26;
PlatformUiCapabilities.debugNativeIOS26Override = true;
addTearDown(PlatformUiCapabilities.resetDebugOverrides);
await tester.pumpWidget(
ProviderScope(
overrides: [apiServiceProvider.overrideWithValue(null)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: ModernChatInput(onSendMessage: (_) {})),
),
),
);
await tester.pump();
await tester.enterText(find.byType(TextField), '/');
await tester.pump();
Finder nativePrimary() => find.descendant(
of: find.byKey(const ValueKey<String>('primary-btn-send')),
matching: find.byType(AdaptiveButton),
);
final nativeButton = tester.widget<AdaptiveButton>(nativePrimary());
await tester.enterText(find.byType(TextField), '/a');
await tester.pump();
expect(tester.widget<AdaptiveButton>(nativePrimary()), same(nativeButton));
});
testWidgets('pre-iOS 26 composer uses 24pt Cupertino add and close glyphs', (
tester,
) async {
PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.iOS;
PlatformUiCapabilities.debugIOSMajorVersionOverride = 25;
PlatformUiCapabilities.debugNativeIOS26Override = false;
addTearDown(PlatformUiCapabilities.resetDebugOverrides);
await tester.pumpWidget(
ProviderScope(
overrides: [apiServiceProvider.overrideWithValue(null)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: ModernChatInput(onSendMessage: (_) {})),
),
),
);
await tester.pump();
final overflowButton = find.byKey(
const ValueKey<String>('composer-overflow-button'),
);
expect(
tester.widget<Icon>(find.byIcon(CupertinoIcons.add)).size,
IconSize.large,
);
expect(
tester.getSize(overflowButton),
const Size.square(TouchTarget.minimum),
);
await tester.tap(overflowButton);
await tester.pump();
expect(
tester.widget<Icon>(find.byIcon(CupertinoIcons.xmark)).size,
IconSize.large,
);
expect(
tester.getSize(overflowButton),
const Size.square(TouchTarget.minimum),
);
});
testWidgets('accessibility text sizing uses the two-tier composer', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiServiceProvider.overrideWithValue(null)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: child!,
),
home: Scaffold(body: ModernChatInput(onSendMessage: (_) {})),
),
),
);
await tester.pump();
await tester.pump();
expect(find.byKey(const ValueKey('compact-composer-shell')), findsNothing);
expect(
find.byKey(const ValueKey('expanded-composer-shell')),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('a wrapped empty-state placeholder expands the composer', (
tester,
) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(320, 800);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(
ProviderScope(
overrides: [apiServiceProvider.overrideWithValue(null)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: ModernChatInput(
onSendMessage: (_) {},
placeholder: 'Ask Conduit about a detailed multilingual question',
),
),
),
),
);
await tester.pump();
await tester.pump();
expect(find.byKey(const ValueKey('compact-composer-shell')), findsNothing);
expect(
find.byKey(const ValueKey('expanded-composer-shell')),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('a visually wrapped second line expands the composer', (
tester,
) async {
@ -1154,6 +1436,71 @@ void main() {
expect(activePrimaryRect.left - fieldRect.right, Spacing.xs);
});
testWidgets('composer action row stays anchored when it expands', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiServiceProvider.overrideWithValue(null)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: ModernChatInput(
onSendMessage: (_) {},
onFileAttachment: _noop,
),
),
),
),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'single line');
await tester.pump();
await tester.pump();
final compactPrimaryCenter = tester.getCenter(
find.byKey(const ValueKey('primary-btn-send')),
);
final compactOverflowCenter = tester.getCenter(
find.byKey(const ValueKey<String>('composer-overflow-button')),
);
final compactShellRect = tester.getRect(
find.byKey(const ValueKey('compact-composer-shell')),
);
final compactShellBottom = compactShellRect.bottom;
expect(compactShellRect.height, TouchTarget.minimum);
expect(compactPrimaryCenter.dy, compactShellRect.center.dy);
expect(compactOverflowCenter.dy, compactShellRect.center.dy);
await tester.enterText(find.byType(TextField), 'first line\nsecond line');
await tester.pump();
await tester.pump();
final expandedPrimaryCenter = tester.getCenter(
find.byKey(const ValueKey('primary-btn-send')),
);
final expandedOverflowCenter = tester.getCenter(
find.byKey(const ValueKey<String>('composer-overflow-button')),
);
final expandedShellBottom = tester
.getRect(find.byKey(const ValueKey('expanded-composer-shell')))
.bottom;
expect(expandedPrimaryCenter.dx, compactPrimaryCenter.dx);
expect(expandedOverflowCenter.dx, compactOverflowCenter.dx);
expect(
expandedShellBottom - expandedPrimaryCenter.dy,
compactShellBottom - compactPrimaryCenter.dy,
);
expect(
expandedShellBottom - expandedOverflowCenter.dy,
compactShellBottom - compactOverflowCenter.dy,
);
});
testWidgets('secondary composer actions use plain icon buttons', (
tester,
) async {
@ -1196,51 +1543,120 @@ void main() {
await tester.pump();
expect(
tester
.widget<AdaptiveButton>(
find.byKey(const ValueKey('create-draft-note-button')),
)
.style,
actionButton(
find.byKey(const ValueKey('create-draft-note-button')),
).style,
AdaptiveButtonStyle.plain,
);
final noteVisualRect = tester.getRect(
find.descendant(
of: find.byKey(const ValueKey('create-draft-note-button')),
matching: find.byType(AdaptiveButton),
),
);
final noteGlyphRect = tester.getRect(find.byIcon(Icons.note_add_outlined));
final sendVisualRect = tester.getRect(
find.descendant(
of: find.byKey(const ValueKey('primary-btn-send')),
matching: find.byType(AdaptiveButton),
),
);
expect(noteVisualRect.size, const Size.square(32));
expect(sendVisualRect.left - noteGlyphRect.right, 12);
});
testWidgets('non-Android add icon matches the standard mic glyph', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
apiServiceProvider.overrideWithValue(null),
voiceInputAvailableProvider.overrideWith((_) async => true),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: ModernChatInput(
onSendMessage: (_) {},
onFileAttachment: _noop,
testWidgets('composer controls use the standard icon extent', (tester) async {
final semanticsHandle = tester.ensureSemantics();
try {
await tester.pumpWidget(
ProviderScope(
overrides: [
apiServiceProvider.overrideWithValue(null),
voiceInputAvailableProvider.overrideWith((_) async => true),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: ModernChatInput(
onSendMessage: (_) {},
onFileAttachment: _noop,
onVoiceCall: () {},
),
),
),
),
),
);
await tester.pump();
);
await tester.pump();
final addIcon = tester.widget<Icon>(find.byIcon(Icons.add));
final micIcon = tester.widget<Icon>(find.byIcon(Icons.mic));
final expectedAddIconSize = Platform.isAndroid ? 28 : IconSize.large;
expect(addIcon.size, expectedAddIconSize);
expect(micIcon.size, IconSize.large);
final addIcon = tester.widget<Icon>(find.byIcon(Icons.add));
final micIcon = tester.widget<Icon>(find.byIcon(Icons.mic));
final voiceIcon = tester.widget<Icon>(find.byIcon(Icons.graphic_eq));
expect(addIcon.size, 28);
expect(micIcon.size, IconSize.large);
expect(voiceIcon.size, IconSize.medium);
final addButton = find.byKey(
const ValueKey<String>('composer-overflow-button'),
);
final micButton = find.byKey(
const ValueKey<String>('composer-dictation-start'),
);
expect(tester.getSize(addButton), tester.getSize(micButton));
final addButton = find.byKey(
const ValueKey<String>('composer-overflow-button'),
);
final micButton = find.byKey(
const ValueKey<String>('composer-dictation-start'),
);
expect(tester.getSize(addButton), tester.getSize(micButton));
expect(tester.getSize(addButton), const Size.square(TouchTarget.minimum));
final voiceTarget = find.byKey(
const ValueKey<String>('primary-btn-voice-call'),
);
expect(
tester.getSize(voiceTarget),
const Size.square(TouchTarget.minimum),
);
expect(
tester
.getSemantics(voiceTarget)
.getSemanticsData()
.hasAction(SemanticsAction.tap),
isTrue,
);
final micGlyphRect = tester.getRect(find.byIcon(Icons.mic));
final voiceVisualRect = tester.getRect(
find.descendant(of: voiceTarget, matching: find.byType(AdaptiveButton)),
);
expect(voiceVisualRect.left - micGlyphRect.right, 12);
expect(
tester.getSize(
find.descendant(
of: voiceTarget,
matching: find.byType(AdaptiveButton),
),
),
const Size.square(32),
);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pump();
final sendIcon = tester.widget<Icon>(
find.byIcon(Icons.arrow_upward_rounded),
);
expect(sendIcon.size, IconSize.medium);
final sendTarget = find.byKey(const ValueKey('primary-btn-send'));
expect(
tester.getSize(sendTarget),
const Size.square(TouchTarget.minimum),
);
expect(
tester.getSize(
find.descendant(
of: sendTarget,
matching: find.byType(AdaptiveButton),
),
),
const Size.square(32),
);
} finally {
semanticsHandle.dispose();
}
});
testWidgets('overflow close control keeps its compact size when expanded', (
@ -1275,6 +1691,7 @@ void main() {
final compactCloseGlyphSize = tester
.widget<Icon>(find.byIcon(Icons.close))
.size;
expect(compactCloseGlyphSize, 28);
expect(compactCloseControlSize, addControlSize);
await tester.enterText(find.byType(TextField), 'first line\nsecond line');

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:conduit/core/auth/api_auth_interceptor.dart';
import 'package:conduit/core/providers/app_providers.dart';
import 'package:conduit/core/services/api_service.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:checks/checks.dart';
import 'package:conduit/core/models/model.dart';
import 'package:conduit/core/providers/app_providers.dart';

View file

@ -11,7 +11,7 @@ import 'package:conduit/features/hermes/utils/hermes_schedule_format.dart';
import 'package:conduit/features/hermes/views/hermes_jobs_page.dart';
import 'package:conduit/features/hermes/widgets/hermes_job_editor.dart';
import 'package:conduit/features/hermes/widgets/hermes_jobs_sheet.dart';
import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

View file

@ -203,6 +203,33 @@ void main() {
find.byKey(const ValueKey<String>('folder-page-overflow-button')),
findsOneWidget,
);
final appBar = tester.widget<AppBar>(find.byType(AppBar).first);
expect(appBar.centerTitle, isFalse);
expect(
find.descendant(
of: find.byWidget(appBar.title!),
matching: find.byKey(
const ValueKey<String>('folder-page-model-selector'),
),
),
findsOneWidget,
);
final selector = find.byKey(
const ValueKey<String>('folder-page-model-selector'),
);
expect(
tester.getCenter(selector).dx,
lessThan(tester.getSize(find.byType(Scaffold).first).width / 2),
);
expect(
find.descendant(
of: find.byWidget(appBar.leading!),
matching: find.byKey(
const ValueKey<String>('folder-page-drawer-button'),
),
),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('folder-page-header')),
findsOneWidget,

Some files were not shown because too many files have changed in this diff Show more