From e5705962387bcec9b21f8b6f724f440315ec79c1 Mon Sep 17 00:00:00 2001 From: lizy Date: Wed, 16 Sep 2026 17:19:36 +0800 Subject: [PATCH 01/21] feat(3d): add interactive viewer controls and picking --- .../lib/app/router/app_route_table.dart | 8 +- .../ui/flutter_scene_3d/module_root.dart | 587 +++++++++++++++--- .../scene_camera_controller.dart | 312 ++++++++++ .../ui/flutter_scene_3d/scene_runtime.dart | 150 +++-- .../scene_selection_controller.dart | 42 ++ 5 files changed, 979 insertions(+), 120 deletions(-) create mode 100644 apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_camera_controller.dart create mode 100644 apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_selection_controller.dart diff --git a/apps/flutter_forge/lib/app/router/app_route_table.dart b/apps/flutter_forge/lib/app/router/app_route_table.dart index 9067edc..e7b7e8e 100644 --- a/apps/flutter_forge/lib/app/router/app_route_table.dart +++ b/apps/flutter_forge/lib/app/router/app_route_table.dart @@ -233,13 +233,13 @@ final List _modules = [ routes: FontPickerRoutes.routes, ), ModuleEntry( - title: 'Flutter Scene 3D 入门', + title: '教学型 3D 查看器', path: '/flutter-scene-3d', - subtitle: '用一个可交互长方体理解场景、网格与相机控制', + subtitle: '用轨道相机、部件选取与聚焦反馈理解 3D 查看器交互', category: ModuleCategory.ui, difficulty: Difficulty.intermediate, - concepts: ['Flutter GPU', 'Scene', 'Node', 'Mesh', 'PerspectiveCamera'], - estimatedMinutes: 25, + concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], + estimatedMinutes: 35, status: ModuleStatus.ready, // Evidence boundary: only macOS is admitted; other hosts remain catalog-visible but unavailable. supportedPlatforms: {TargetPlatform.macOS}, diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart index 98b150d..0cf46cd 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart @@ -1,7 +1,14 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; +import 'scene_camera_controller.dart'; import 'scene_runtime.dart'; +import 'scene_selection_controller.dart'; class FlutterScene3dPage extends StatefulWidget { const FlutterScene3dPage({super.key, this.runtime}); @@ -20,7 +27,7 @@ class _FlutterScene3dPageState extends State { @override Widget build(BuildContext context) { return LearningScaffold( - title: 'Flutter Scene 3D 入门', + title: '教学型 3D 查看器', interactiveDemo: _SceneDemo( runtime: _runtime, initialization: _initialization, @@ -29,12 +36,15 @@ class _FlutterScene3dPageState extends State { LearningObjectives( objectives: [ '理解 Scene、Node、Mesh 与 PerspectiveCamera 的最小协作关系', - '使用 SceneView 自带帧循环实现环绕观察、缩放、暂停与重置', + '比较离散按钮与拖拽、滚轮、键盘共同驱动的轨道相机', + '使用方向缩略图、扩展缩放和实时状态保持空间定位感', + '使用屏幕射线选择最近部件,并将镜头平滑聚焦到命中对象', + '实现自动巡展、手动接管、惯性滑行与低动态观察模式', '把快速演进的 flutter_scene API 隔离在模块本地运行时边界', ], ), ConceptChips( - concepts: ['Flutter GPU', 'Scene', 'Node', 'Mesh', 'Camera'], + concepts: ['Flutter GPU', 'Scene', 'Raycast', 'Node', 'Camera'], ), CodeSnippetCard( title: '一个长方体的最小场景', @@ -52,6 +62,8 @@ SceneView(scene, cameraBuilder: buildOrbitCamera);''', CommonPitfalls( pitfalls: [ '不要在 build 中重复创建 GPU 几何体或材质。', + '不要让自动巡展在使用者接管后自行恢复,避免相机争夺控制权。', + '缩略图只表达方向与缩放,不应重复创建第二个 GPU 场景。', '启用 Flutter GPU 只是主机前提,不能代替逐平台运行证据。', '首课刻意不加入 GLB、物理、着色器、后处理或场景编辑。', ], @@ -72,110 +84,543 @@ class _SceneDemo extends StatefulWidget { } class _SceneDemoState extends State<_SceneDemo> { - void _change(void Function() action) => setState(action); + final FocusNode _focusNode = FocusNode(debugLabel: '3d-viewer-controls'); + final GlobalKey _sceneSurfaceKey = GlobalKey(); + bool? _lastReducedMotion; + + SceneCameraController get _camera => widget.runtime.camera; + + @override + void initState() { + super.initState(); + _camera.addListener(_onCameraChanged); + widget.runtime.selection.addListener(_onCameraChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + if (_lastReducedMotion != reducedMotion) { + _lastReducedMotion = reducedMotion; + _camera.setReducedMotion(reducedMotion); + } + } + + @override + void dispose() { + _camera.removeListener(_onCameraChanged); + widget.runtime.selection.removeListener(_onCameraChanged); + _focusNode.dispose(); + super.dispose(); + } + + void _onCameraChanged() { + if (mounted) setState(() {}); + } @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('交互长方体', style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - SizedBox( - height: 320, - child: FutureBuilder( - future: widget.initialization, - builder: (context, snapshot) { - if (snapshot.hasError) { - return const Center( - key: Key('scene-error'), - child: Text('3D 场景初始化失败,请确认 Flutter GPU 已启用'), - ); - } - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - key: Key('scene-loading'), - child: Column( - mainAxisSize: MainAxisSize.min, + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _handleKeyEvent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('设备部件检查', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + SizedBox( + height: 320, + child: FutureBuilder( + future: widget.initialization, + builder: (context, snapshot) { + if (snapshot.hasError) { + return const Center( + key: Key('scene-error'), + child: Text('3D 场景初始化失败,请确认 Flutter GPU 已启用'), + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + key: Key('scene-loading'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 12), + Text('正在准备 3D 场景'), + ], + ), + ); + } + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, children: [ - CircularProgressIndicator(), - SizedBox(height: 12), - Text('正在准备 3D 场景'), + Listener( + onPointerDown: (_) => _focusNode.requestFocus(), + onPointerSignal: (event) { + if (event is PointerScrollEvent && + _camera.enhancedMode) { + _focusNode.requestFocus(); + _camera.beginInteraction(); + _camera.zoomBy(event.scrollDelta.dy * 0.004); + } + }, + child: KeyedSubtree( + key: const Key('scene-interaction-surface'), + child: GestureDetector( + key: _sceneSurfaceKey, + behavior: HitTestBehavior.opaque, + onTapUp: (details) { + final box = + _sceneSurfaceKey.currentContext + ?.findRenderObject() + as RenderBox?; + if (box != null) { + widget.runtime.selectAt( + details.localPosition, + box.size, + ); + } + }, + onPanStart: (_) => _camera.beginInteraction(), + onPanUpdate: (details) => + _camera.dragBy(details.delta), + onPanEnd: (details) => _camera.endInteraction( + details.velocity.pixelsPerSecond, + ), + child: widget.runtime.buildView(), + ), + ), + ), + Positioned( + top: 12, + right: 12, + child: _OrientationThumbnail(camera: _camera), + ), ], ), ); + }, + ), + ), + const SizedBox(height: 12), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: [ + ChoiceChip( + key: const Key('basic-mode'), + label: const Text('基础模式'), + selected: !_camera.enhancedMode, + onSelected: (_) => _camera.setEnhancedMode(false), + ), + ChoiceChip( + key: const Key('enhanced-mode'), + label: const Text('增强模式'), + selected: _camera.enhancedMode, + onSelected: (_) => _camera.setEnhancedMode(true), + ), + ], + ), + const SizedBox(height: 8), + Text( + _camera.enhancedMode + ? '拖拽环绕 · 滚轮缩放 · 方向键与 +/- 控制' + : '使用下方按钮观察离散控制效果', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, constraints) { + final controls = _controls(); + if (constraints.maxWidth >= 620) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: controls + .map( + (control) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: control, + ), + ) + .toList(), + ); } - return ClipRRect( - borderRadius: BorderRadius.circular(12), - child: widget.runtime.buildView(), + return Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: controls, ); }, ), - ), - const SizedBox(height: 12), - LayoutBuilder( - builder: (context, constraints) { - final controls = _controls(); - if (constraints.maxWidth >= 620) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: controls - .map( - (control) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: control, - ), - ) - .toList(), - ); - } - return Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: controls, - ); - }, - ), - const SizedBox(height: 8), - Text( - '相机距离:${widget.runtime.zoom.toStringAsFixed(1)}', - textAlign: TextAlign.center, - ), - ], + const SizedBox(height: 8), + _CameraStatus(camera: _camera), + const SizedBox(height: 8), + _SelectionPanel( + selection: widget.runtime.selection, + onFocus: widget.runtime.focusSelection, + onClear: widget.runtime.clearSelection, + ), + ], + ), ); } List _controls() => [ IconButton.filledTonal( tooltip: '向左环绕', - onPressed: () => _change(() => widget.runtime.orbitBy(-0.25)), + onPressed: () => _camera.orbitBy(-0.25), icon: const Icon(Icons.rotate_left), ), IconButton.filledTonal( tooltip: '向右环绕', - onPressed: () => _change(() => widget.runtime.orbitBy(0.25)), + onPressed: () => _camera.orbitBy(0.25), icon: const Icon(Icons.rotate_right), ), IconButton.filledTonal( tooltip: '拉近相机', - onPressed: () => _change(() => widget.runtime.zoomBy(-0.4)), + onPressed: () => _camera.animateZoomBy(-0.4), icon: const Icon(Icons.zoom_in), ), IconButton.filledTonal( tooltip: '拉远相机', - onPressed: () => _change(() => widget.runtime.zoomBy(0.4)), + onPressed: () => _camera.animateZoomBy(0.4), icon: const Icon(Icons.zoom_out), ), IconButton.filledTonal( - tooltip: widget.runtime.isPaused ? '继续环绕' : '暂停环绕', - onPressed: () => _change(widget.runtime.togglePaused), - icon: Icon(widget.runtime.isPaused ? Icons.play_arrow : Icons.pause), + tooltip: _camera.motionState == SceneMotionState.paused ? '继续环绕' : '暂停环绕', + onPressed: _camera.togglePaused, + icon: Icon( + _camera.motionState == SceneMotionState.paused + ? Icons.play_arrow + : Icons.pause, + ), ), IconButton.filledTonal( tooltip: '重置相机', - onPressed: () => _change(widget.runtime.resetCamera), + onPressed: _camera.reset, icon: const Icon(Icons.restart_alt), ), ]; + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final isRepeat = event is KeyRepeatEvent; + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowLeft) { + _camera.orbitBy(-0.15); + } else if (key == LogicalKeyboardKey.arrowRight) { + _camera.orbitBy(0.15); + } else if (key == LogicalKeyboardKey.arrowUp) { + _camera.beginInteraction(); + _camera.dragBy(const Offset(0, -12)); + } else if (key == LogicalKeyboardKey.arrowDown) { + _camera.beginInteraction(); + _camera.dragBy(const Offset(0, 12)); + } else if (key == LogicalKeyboardKey.equal || + key == LogicalKeyboardKey.numpadAdd) { + _camera.animateZoomBy(-0.3); + } else if (key == LogicalKeyboardKey.minus || + key == LogicalKeyboardKey.numpadSubtract) { + _camera.animateZoomBy(0.3); + } else if (key == LogicalKeyboardKey.space) { + if (!isRepeat) _camera.togglePaused(); + } else if (key == LogicalKeyboardKey.keyR) { + if (!isRepeat) _camera.reset(); + } else if (key == LogicalKeyboardKey.escape) { + widget.runtime.clearSelection(); + } else { + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } +} + +class _SelectionPanel extends StatelessWidget { + const _SelectionPanel({ + required this.selection, + required this.onFocus, + required this.onClear, + }); + + final SceneSelectionController selection; + final VoidCallback onFocus; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final selected = selection.selected; + if (selected == null) { + return const Text('点击部件查看命中信息;拖拽仍用于环绕观察', textAlign: TextAlign.center); + } + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, + children: [ + Text(selected.label, style: Theme.of(context).textTheme.titleSmall), + Text('命中距离 ${selected.distance.toStringAsFixed(2)}'), + Text( + '法线 (${selected.normalX.toStringAsFixed(2)}, ' + '${selected.normalY.toStringAsFixed(2)}, ' + '${selected.normalZ.toStringAsFixed(2)})', + ), + FilledButton.tonalIcon( + key: const Key('focus-selected-part'), + onPressed: onFocus, + icon: const Icon(Icons.center_focus_strong), + label: const Text('聚焦'), + ), + TextButton(onPressed: onClear, child: const Text('取消选择')), + ], + ), + ), + ); + } +} + +class _CameraStatus extends StatefulWidget { + const _CameraStatus({required this.camera}); + + final SceneCameraController camera; + + @override + State<_CameraStatus> createState() => _CameraStatusState(); +} + +class _CameraStatusState extends State<_CameraStatus> { + late final Timer _refreshTimer; + + @override + void initState() { + super.initState(); + widget.camera.addListener(_refresh); + _refreshTimer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => _refresh(), + ); + } + + @override + void didUpdateWidget(covariant _CameraStatus oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.camera != widget.camera) { + oldWidget.camera.removeListener(_refresh); + widget.camera.addListener(_refresh); + } + } + + @override + void dispose() { + _refreshTimer.cancel(); + widget.camera.removeListener(_refresh); + super.dispose(); + } + + void _refresh() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final camera = widget.camera; + return Column( + children: [ + Text( + '状态:${_motionStateLabel(camera.motionState)} · ' + '距离:${camera.distance.toStringAsFixed(1)} · ' + '缩放:${camera.zoomScale.toStringAsFixed(2)}× · ' + '水平角:${_degrees(camera.yaw)}° · ' + '俯仰角:${_degrees(camera.pitch)}°', + key: const Key('camera-status'), + textAlign: TextAlign.center, + ), + if (camera.reducedMotion) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + '已启用低动态观察模式', + key: Key('reduced-motion-status'), + textAlign: TextAlign.center, + ), + ), + ], + ); + } + + static int _degrees(double radians) => + (radians * 180 / 3.141592653589793).round(); + + static String _motionStateLabel(SceneMotionState state) => switch (state) { + SceneMotionState.autoTour => '自动巡展', + SceneMotionState.manual => '手动操控', + SceneMotionState.coasting => '惯性滑行', + SceneMotionState.paused => '已暂停', + SceneMotionState.transitioning => '平滑过渡', + }; +} + +class _OrientationThumbnail extends StatefulWidget { + const _OrientationThumbnail({required this.camera}); + + final SceneCameraController camera; + + @override + State<_OrientationThumbnail> createState() => _OrientationThumbnailState(); +} + +class _OrientationThumbnailState extends State<_OrientationThumbnail> { + late final Timer _refreshTimer; + + @override + void initState() { + super.initState(); + widget.camera.addListener(_refresh); + _refreshTimer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => _refresh(), + ); + } + + @override + void didUpdateWidget(covariant _OrientationThumbnail oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.camera != widget.camera) { + oldWidget.camera.removeListener(_refresh); + widget.camera.addListener(_refresh); + } + } + + @override + void dispose() { + _refreshTimer.cancel(); + widget.camera.removeListener(_refresh); + super.dispose(); + } + + void _refresh() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Semantics( + button: true, + label: '方向缩略图,点击重置视角', + child: Tooltip( + message: '方向缩略图 · 点击重置', + child: Material( + color: colorScheme.surface.withValues(alpha: 0.90), + elevation: 2, + borderRadius: BorderRadius.circular(10), + child: InkWell( + key: const Key('scene-orientation-thumbnail'), + onTap: widget.camera.reset, + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 84, + height: 68, + child: CustomPaint( + painter: _OrientationThumbnailPainter( + yaw: widget.camera.yaw, + pitch: widget.camera.pitch, + zoomScale: widget.camera.zoomScale, + colorScheme: colorScheme, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _OrientationThumbnailPainter extends CustomPainter { + const _OrientationThumbnailPainter({ + required this.yaw, + required this.pitch, + required this.zoomScale, + required this.colorScheme, + }); + + final double yaw; + final double pitch; + final double zoomScale; + final ColorScheme colorScheme; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2 - 4); + final objectScale = + 13.0 * math.sqrt(zoomScale).clamp(0.72, 1.35).toDouble(); + final topDepth = + 5.0 + + 5.0 * + ((pitch - SceneCameraController.minPitch) / + (SceneCameraController.maxPitch - + SceneCameraController.minPitch)) + .clamp(0.0, 1.0); + canvas.save(); + canvas.translate(center.dx, center.dy); + canvas.rotate(yaw); + final front = Rect.fromCenter( + center: Offset.zero, + width: objectScale * 1.45, + height: objectScale, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(front, const Radius.circular(2)), + Paint()..color = colorScheme.primaryContainer, + ); + final top = Path() + ..moveTo(front.left, front.top) + ..lineTo(front.left + topDepth, front.top - topDepth) + ..lineTo(front.right + topDepth, front.top - topDepth) + ..lineTo(front.right, front.top) + ..close(); + canvas.drawPath( + top, + Paint()..color = colorScheme.primary.withValues(alpha: 0.55), + ); + canvas.drawRRect( + RRect.fromRectAndRadius(front, const Radius.circular(2)), + Paint() + ..color = colorScheme.outline + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2, + ); + canvas.restore(); + + final direction = Offset(math.sin(yaw), -math.cos(yaw)); + final arrowStart = center + direction * 19; + final arrowEnd = center + direction * 27; + canvas.drawLine( + arrowStart, + arrowEnd, + Paint() + ..color = colorScheme.tertiary + ..strokeWidth = 2.2 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(covariant _OrientationThumbnailPainter oldDelegate) => + oldDelegate.yaw != yaw || + oldDelegate.pitch != pitch || + oldDelegate.zoomScale != zoomScale || + oldDelegate.colorScheme != colorScheme; } diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_camera_controller.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_camera_controller.dart new file mode 100644 index 0000000..622955e --- /dev/null +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_camera_controller.dart @@ -0,0 +1,312 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +enum SceneMotionState { autoTour, manual, coasting, paused, transitioning } + +class SceneCameraController extends ChangeNotifier { + static const initialDistance = 4.0; + static const minDistance = 1.8; + static const maxDistance = 12.0; + static const initialPitch = 0.5; + static const minPitch = -20 * math.pi / 180; + static const maxPitch = 60 * math.pi / 180; + static const _dragSensitivity = 0.008; + static const _coastDuration = Duration(milliseconds: 650); + + double _yaw = 0; + double _pitch = initialPitch; + double _distance = initialDistance; + double _targetX = 0; + double _targetY = 0; + double _targetZ = 0; + double _coastYawVelocity = 0; + double _coastPitchVelocity = 0; + Duration _coastElapsed = Duration.zero; + Duration? _previousElapsed; + bool _enhancedMode = true; + bool _reducedMotion = false; + SceneMotionState _motionState = SceneMotionState.autoTour; + _CameraTransition? _transition; + + double get yaw => _yaw; + double get pitch => _pitch; + double get distance => _distance; + double get zoomScale => initialDistance / _distance; + double get targetX => _targetX; + double get targetY => _targetY; + double get targetZ => _targetZ; + bool get enhancedMode => _enhancedMode; + bool get reducedMotion => _reducedMotion; + SceneMotionState get motionState => _motionState; + + void tick(Duration elapsed) { + final previous = _previousElapsed; + _previousElapsed = elapsed; + if (previous == null) return; + final delta = elapsed - previous; + if (delta <= Duration.zero) return; + + final transition = _transition; + if (transition != null) { + transition.elapsed += delta; + final raw = + transition.elapsed.inMicroseconds / + transition.duration.inMicroseconds; + final t = _easeOutCubic(raw.clamp(0.0, 1.0)); + _yaw = _lerp(transition.startYaw, transition.targetYaw, t); + _pitch = _lerp(transition.startPitch, transition.targetPitch, t); + _distance = _lerp(transition.startDistance, transition.targetDistance, t); + _targetX = _lerp(transition.startTargetX, transition.targetTargetX, t); + _targetY = _lerp(transition.startTargetY, transition.targetTargetY, t); + _targetZ = _lerp(transition.startTargetZ, transition.targetTargetZ, t); + if (raw >= 1) { + _transition = null; + _motionState = transition.resumeAuto && !_reducedMotion + ? SceneMotionState.autoTour + : SceneMotionState.manual; + } + return; + } + + final seconds = delta.inMicroseconds / Duration.microsecondsPerSecond; + if (_motionState == SceneMotionState.autoTour && !_reducedMotion) { + _yaw += seconds * 0.35; + } else if (_motionState == SceneMotionState.coasting) { + _coastElapsed += delta; + final progress = + _coastElapsed.inMicroseconds / _coastDuration.inMicroseconds; + if (progress >= 1) { + _motionState = SceneMotionState.manual; + _coastYawVelocity = 0; + _coastPitchVelocity = 0; + } else { + final strength = (1 - progress) * (1 - progress); + _yaw += _coastYawVelocity * seconds * strength; + _pitch = (_pitch + _coastPitchVelocity * seconds * strength).clamp( + minPitch, + maxPitch, + ); + } + } + } + + void beginInteraction() { + if (!_enhancedMode) return; + _cancelMotion(); + _motionState = SceneMotionState.manual; + notifyListeners(); + } + + void dragBy(Offset delta) { + if (!_enhancedMode) return; + _yaw -= delta.dx * _dragSensitivity; + _pitch = (_pitch - delta.dy * _dragSensitivity).clamp(minPitch, maxPitch); + _motionState = SceneMotionState.manual; + notifyListeners(); + } + + void endInteraction(Offset velocity) { + if (!_enhancedMode || _reducedMotion) return; + final angularVelocity = Offset( + -velocity.dx * _dragSensitivity, + -velocity.dy * _dragSensitivity, + ); + if (angularVelocity.distance < 0.35) return; + _coastYawVelocity = angularVelocity.dx.clamp(-4.0, 4.0); + _coastPitchVelocity = angularVelocity.dy.clamp(-3.0, 3.0); + _coastElapsed = Duration.zero; + _motionState = SceneMotionState.coasting; + notifyListeners(); + } + + void orbitBy(double radians) { + _cancelMotion(); + if (_enhancedMode && !_reducedMotion) { + _animateTo( + yaw: _yaw + radians, + duration: const Duration(milliseconds: 180), + ); + } else { + _yaw += radians; + _motionState = SceneMotionState.manual; + } + notifyListeners(); + } + + void zoomBy(double delta) { + _cancelMotion(); + _distance = (_distance + delta).clamp(minDistance, maxDistance); + _motionState = SceneMotionState.manual; + notifyListeners(); + } + + void animateZoomBy(double delta) { + _cancelMotion(); + final target = (_distance + delta).clamp(minDistance, maxDistance); + if (_enhancedMode && !_reducedMotion) { + _animateTo(distance: target, duration: const Duration(milliseconds: 180)); + } else { + _distance = target; + _motionState = SceneMotionState.manual; + } + notifyListeners(); + } + + void togglePaused() { + _cancelMotion(); + _motionState = _motionState == SceneMotionState.paused + ? (_reducedMotion ? SceneMotionState.manual : SceneMotionState.autoTour) + : SceneMotionState.paused; + notifyListeners(); + } + + void reset() { + _cancelMotion(); + if (_enhancedMode && !_reducedMotion) { + _animateTo( + yaw: 0, + pitch: initialPitch, + distance: initialDistance, + targetX: 0, + targetY: 0, + targetZ: 0, + duration: const Duration(milliseconds: 350), + resumeAuto: true, + ); + } else { + _yaw = 0; + _pitch = initialPitch; + _distance = initialDistance; + _targetX = 0; + _targetY = 0; + _targetZ = 0; + _motionState = _reducedMotion + ? SceneMotionState.paused + : SceneMotionState.autoTour; + } + _previousElapsed = null; + notifyListeners(); + } + + void focusOn({required double x, required double y, required double z}) { + _cancelMotion(); + if (_reducedMotion) { + _targetX = x; + _targetY = y; + _targetZ = z; + _distance = 3; + _motionState = SceneMotionState.manual; + } else { + _animateTo( + targetX: x, + targetY: y, + targetZ: z, + distance: 3, + duration: const Duration(milliseconds: 350), + ); + } + notifyListeners(); + } + + void setEnhancedMode(bool value) { + if (_enhancedMode == value) return; + final interruptedMotion = + _motionState == SceneMotionState.transitioning || + _motionState == SceneMotionState.coasting; + _cancelMotion(); + _enhancedMode = value; + if (interruptedMotion) { + _motionState = _reducedMotion + ? SceneMotionState.paused + : SceneMotionState.manual; + } + notifyListeners(); + } + + void setReducedMotion(bool value) { + if (_reducedMotion == value) return; + _cancelMotion(); + _reducedMotion = value; + _motionState = value ? SceneMotionState.paused : SceneMotionState.autoTour; + _previousElapsed = null; + notifyListeners(); + } + + void _animateTo({ + double? yaw, + double? pitch, + double? distance, + double? targetX, + double? targetY, + double? targetZ, + required Duration duration, + bool resumeAuto = false, + }) { + _transition = _CameraTransition( + startYaw: _yaw, + targetYaw: yaw ?? _yaw, + startPitch: _pitch, + targetPitch: pitch ?? _pitch, + startDistance: _distance, + targetDistance: distance ?? _distance, + startTargetX: _targetX, + targetTargetX: targetX ?? _targetX, + startTargetY: _targetY, + targetTargetY: targetY ?? _targetY, + startTargetZ: _targetZ, + targetTargetZ: targetZ ?? _targetZ, + duration: duration, + resumeAuto: resumeAuto, + ); + _motionState = SceneMotionState.transitioning; + } + + void _cancelMotion() { + _transition = null; + _coastYawVelocity = 0; + _coastPitchVelocity = 0; + _coastElapsed = Duration.zero; + } + + static double _easeOutCubic(double value) => + 1 - math.pow(1 - value, 3).toDouble(); + static double _lerp(double start, double end, double t) => + start + (end - start) * t; +} + +class _CameraTransition { + _CameraTransition({ + required this.startYaw, + required this.targetYaw, + required this.startPitch, + required this.targetPitch, + required this.startDistance, + required this.targetDistance, + required this.startTargetX, + required this.targetTargetX, + required this.startTargetY, + required this.targetTargetY, + required this.startTargetZ, + required this.targetTargetZ, + required this.duration, + required this.resumeAuto, + }); + + final double startYaw; + final double targetYaw; + final double startPitch; + final double targetPitch; + final double startDistance; + final double targetDistance; + final double startTargetX; + final double targetTargetX; + final double startTargetY; + final double targetTargetY; + final double startTargetZ; + final double targetTargetZ; + final Duration duration; + final bool resumeAuto; + Duration elapsed = Duration.zero; +} diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_runtime.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_runtime.dart index 58c4fb1..b33452e 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_runtime.dart +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_runtime.dart @@ -4,48 +4,62 @@ import 'package:flutter/material.dart'; import 'package:flutter_scene/scene.dart'; import 'package:vector_math/vector_math.dart' as vm; +import 'scene_camera_controller.dart'; +import 'scene_selection_controller.dart'; + /// Module-local boundary around flutter_scene's pre-1.0 API. abstract interface class SceneDemoRuntime { Future initialize(); Widget buildView(); - bool get isPaused; - - double get zoom; + SceneCameraController get camera; - void orbitBy(double radians); + SceneSelectionController get selection; - void zoomBy(double delta); + void selectAt(Offset position, Size viewSize); - void togglePaused(); + void focusSelection(); - void resetCamera(); + void clearSelection(); } class FlutterSceneRuntime implements SceneDemoRuntime { final Scene _scene = Scene(); - double _orbitOffset = 0; - double _distance = 4; - double _animatedAngle = 0; - Duration? _previousElapsed; - bool _paused = false; + final Map _parts = {}; + PerspectiveCamera? _currentCamera; + Node? _highlightedNode; @override - bool get isPaused => _paused; + final SceneCameraController camera = SceneCameraController(); @override - double get zoom => _distance; + final SceneSelectionController selection = SceneSelectionController(); @override Future initialize() async { await Scene.initializeStaticResources(); - _scene.add( - Node( - mesh: Mesh( - CuboidGeometry(vm.Vector3(1.8, 1.2, 1)), - PhysicallyBasedMaterial(), - ), + _addPart( + id: 'cuboid', + label: '机身', + position: vm.Vector3(-1.4, 0, 0), + geometry: CuboidGeometry(vm.Vector3(1.2, 1, 1)), + ); + _addPart( + id: 'sphere', + label: '传感器', + position: vm.Vector3(0, 0, 0), + geometry: SphereGeometry(radius: 0.62, segments: 24, rings: 12), + ); + _addPart( + id: 'cylinder', + label: '驱动轴', + position: vm.Vector3(1.4, 0, 0), + geometry: CylinderGeometry( + bottomRadius: 0.55, + topRadius: 0.55, + height: 1.2, + radialSegments: 24, ), ); } @@ -55,44 +69,90 @@ class FlutterSceneRuntime implements SceneDemoRuntime { return SceneView( _scene, cameraBuilder: (elapsed) { - final previous = _previousElapsed; - _previousElapsed = elapsed; - if (!_paused && previous != null) { - _animatedAngle += - (elapsed - previous).inMicroseconds / - Duration.microsecondsPerSecond * - 0.35; - } - final angle = _animatedAngle + _orbitOffset; - return PerspectiveCamera( + camera.tick(elapsed); + final horizontalDistance = math.cos(camera.pitch) * camera.distance; + final target = vm.Vector3( + camera.targetX, + camera.targetY, + camera.targetZ, + ); + final result = PerspectiveCamera( position: vm.Vector3( - math.sin(angle) * _distance, - 2.2, - -math.cos(angle) * _distance, + target.x + math.sin(camera.yaw) * horizontalDistance, + target.y + math.sin(camera.pitch) * camera.distance, + target.z - math.cos(camera.yaw) * horizontalDistance, ), - target: vm.Vector3.zero(), + target: target, ); + _currentCamera = result; + return result; }, ); } - @override - void orbitBy(double radians) => _orbitOffset += radians; + void _addPart({ + required String id, + required String label, + required vm.Vector3 position, + required Geometry geometry, + }) { + final node = Node( + name: '$id|$label', + mesh: Mesh(geometry, PhysicallyBasedMaterial()), + )..position = position; + _parts[id] = node; + _scene.add(node); + } @override - void zoomBy(double delta) { - _distance = (_distance + delta).clamp(2.4, 7).toDouble(); + void selectAt(Offset position, Size viewSize) { + final activeCamera = _currentCamera; + if (activeCamera == null) return; + final hit = _scene.raycast( + activeCamera.screenPointToRay(position, viewSize), + ); + if (hit == null) { + clearSelection(); + return; + } + final fields = hit.node.name.split('|'); + if (fields.length != 2 || !_parts.containsKey(fields.first)) { + clearSelection(); + return; + } + _highlightedNode?.highlightColor = null; + _highlightedNode = hit.node..highlightColor = vm.Vector4(0.18, 0.65, 1, 1); + final center = hit.node.position; + selection.select( + ScenePartHit( + id: fields.first, + label: fields.last, + centerX: center.x, + centerY: center.y, + centerZ: center.z, + distance: hit.distance, + normalX: hit.worldNormal.x, + normalY: hit.worldNormal.y, + normalZ: hit.worldNormal.z, + ), + ); } @override - void togglePaused() => _paused = !_paused; + void focusSelection() { + final selected = selection.selected; + if (selected == null) return; + camera.focusOn( + x: selected.centerX, + y: selected.centerY, + z: selected.centerZ, + ); + } @override - void resetCamera() { - _orbitOffset = 0; - _animatedAngle = 0; - _distance = 4; - _previousElapsed = null; - _paused = false; + void clearSelection() { + _highlightedNode?.highlightColor = null; + _highlightedNode = null; + selection.clear(); } } diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_selection_controller.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_selection_controller.dart new file mode 100644 index 0000000..bc9d912 --- /dev/null +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_selection_controller.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +class ScenePartHit { + const ScenePartHit({ + required this.id, + required this.label, + required this.centerX, + required this.centerY, + required this.centerZ, + required this.distance, + required this.normalX, + required this.normalY, + required this.normalZ, + }); + + final String id; + final String label; + final double centerX; + final double centerY; + final double centerZ; + final double distance; + final double normalX; + final double normalY; + final double normalZ; +} + +class SceneSelectionController extends ChangeNotifier { + ScenePartHit? _selected; + + ScenePartHit? get selected => _selected; + + void select(ScenePartHit hit) { + _selected = hit; + notifyListeners(); + } + + void clear() { + if (_selected == null) return; + _selected = null; + notifyListeners(); + } +} From 9249df81b0b6dbe490f741c14896d08b168f9b64 Mon Sep 17 00:00:00 2001 From: lizy Date: Wed, 16 Sep 2026 17:19:46 +0800 Subject: [PATCH 02/21] test(3d): cover viewer input and part selection --- .../flutter_scene_3d_test.dart | 209 ++++++++++++++++-- .../scene_camera_controller_test.dart | 107 +++++++++ .../scene_selection_controller_test.dart | 24 ++ 3 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_camera_controller_test.dart create mode 100644 apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_selection_controller_test.dart diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart index 7d58a64..e59a145 100644 --- a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart @@ -1,13 +1,17 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/module_root.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_camera_controller.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_runtime.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_selection_controller.dart'; void main() { testWidgets('shows title, learning content, and semantic controls', ( @@ -19,8 +23,8 @@ void main() { ); await tester.pump(); - expect(find.text('Flutter Scene 3D 入门'), findsOneWidget); - expect(find.text('交互长方体'), findsOneWidget); + expect(find.text('教学型 3D 查看器'), findsOneWidget); + expect(find.text('设备部件检查'), findsOneWidget); expect(find.textContaining('Scene、Node、Mesh'), findsOneWidget); for (final label in ['向左环绕', '向右环绕', '拉近相机', '拉远相机', '暂停环绕', '重置相机']) { expect(find.byTooltip(label), findsOneWidget); @@ -29,6 +33,167 @@ void main() { await tester.tap(find.byTooltip('暂停环绕')); await tester.pump(); expect(find.byTooltip('继续环绕'), findsOneWidget); + expect(find.textContaining('状态:已暂停'), findsOneWidget); + }); + + testWidgets('enhanced mode supports drag takeover and mode comparison', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + + await tester.fling( + find.byKey(const Key('scene-interaction-surface')), + const Offset(80, -40), + 1200, + ); + await tester.pump(); + expect(runtime.camera.motionState, SceneMotionState.coasting); + expect(find.textContaining('状态:惯性滑行'), findsOneWidget); + + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.pump(); + expect(runtime.camera.enhancedMode, isFalse); + expect(find.textContaining('离散控制效果'), findsOneWidget); + }); + + testWidgets('reduced motion disables automatic and inertial motion', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ), + ); + await tester.pump(); + + expect(runtime.camera.reducedMotion, isTrue); + expect(runtime.camera.motionState, SceneMotionState.paused); + expect(find.byKey(const Key('reduced-motion-status')), findsOneWidget); + }); + + testWidgets('basic mode blocks wheel zoom', (tester) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.pump(); + + await tester.sendEventToBinding( + PointerScrollEvent( + position: tester.getCenter( + find.byKey(const Key('scene-interaction-surface')), + ), + scrollDelta: const Offset(0, 100), + ), + ); + await tester.pump(); + + expect(runtime.camera.distance, SceneCameraController.initialDistance); + }); + + testWidgets('keyboard remains active after selecting a mode control', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(runtime.camera.yaw, closeTo(0.15, 0.001)); + }); + + testWidgets('held arrow keys consume repeat events inside the viewer', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + var escapedArrowRepeats = 0; + await tester.pumpWidget( + MaterialApp( + home: Focus( + onKeyEvent: (_, event) { + if (event is KeyRepeatEvent && + event.logicalKey == LogicalKeyboardKey.arrowLeft) { + escapedArrowRepeats++; + } + return KeyEventResult.ignored; + }, + child: FlutterScene3dPage(runtime: runtime), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(runtime.camera.yaw, closeTo(-0.30, 0.001)); + expect(escapedArrowRepeats, 0); + }); + + testWidgets('top-right orientation thumbnail previews and resets camera', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + final surface = find.byKey(const Key('scene-interaction-surface')); + final thumbnail = find.byKey(const Key('scene-orientation-thumbnail')); + + expect(thumbnail, findsOneWidget); + final surfaceRect = tester.getRect(surface); + final thumbnailRect = tester.getRect(thumbnail); + expect(thumbnailRect.top, closeTo(surfaceRect.top + 12, 0.1)); + expect(thumbnailRect.right, closeTo(surfaceRect.right - 12, 0.1)); + + await tester.tap(find.byKey(const Key('basic-mode'))); + runtime.camera.orbitBy(0.5); + expect(runtime.camera.yaw, 0.5); + await tester.tap(thumbnail); + await tester.pump(); + expect(runtime.camera.yaw, 0); + }); + + testWidgets('tap selects a part and focus targets its center', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp(home: FlutterScene3dPage(runtime: runtime)), + ); + await tester.pump(); + + await tester.tap(find.byKey(const Key('scene-interaction-surface'))); + await tester.pump(); + expect(find.text('机身'), findsOneWidget); + + final focusButton = find.byKey(const Key('focus-selected-part')); + await tester.ensureVisible(focusButton); + await tester.pump(); + await tester.tap(focusButton); + await tester.pump(); + runtime.camera.tick(Duration.zero); + runtime.camera.tick(const Duration(milliseconds: 350)); + expect(runtime.camera.targetX, -1.4); }); testWidgets('shows loading state', (tester) async { @@ -83,14 +248,10 @@ class FakeSceneRuntime implements SceneDemoRuntime { : _initialization = initialization ?? Future.value(); final Future _initialization; - bool _paused = false; - double _zoom = 4; - @override - bool get isPaused => _paused; - + final SceneCameraController camera = SceneCameraController(); @override - double get zoom => _zoom; + final SceneSelectionController selection = SceneSelectionController(); @override Widget buildView() => @@ -100,17 +261,33 @@ class FakeSceneRuntime implements SceneDemoRuntime { Future initialize() => _initialization; @override - void orbitBy(double radians) {} + void clearSelection() => selection.clear(); @override - void resetCamera() { - _paused = false; - _zoom = 4; + void focusSelection() { + final selected = selection.selected; + if (selected == null) return; + camera.focusOn( + x: selected.centerX, + y: selected.centerY, + z: selected.centerZ, + ); } @override - void togglePaused() => _paused = !_paused; - - @override - void zoomBy(double delta) => _zoom += delta; + void selectAt(Offset position, Size viewSize) { + selection.select( + const ScenePartHit( + id: 'cuboid', + label: '机身', + centerX: -1.4, + centerY: 0, + centerZ: 0, + distance: 3.2, + normalX: 0, + normalY: 1, + normalZ: 0, + ), + ); + } } diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_camera_controller_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_camera_controller_test.dart new file mode 100644 index 0000000..7b25582 --- /dev/null +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_camera_controller_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_camera_controller.dart'; + +void main() { + group('SceneCameraController', () { + test('auto tour advances until direct manipulation takes over', () { + final controller = SceneCameraController(); + + controller.tick(const Duration(seconds: 1)); + controller.tick(const Duration(seconds: 2)); + expect(controller.yaw, closeTo(0.35, 0.001)); + + controller.beginInteraction(); + controller.dragBy(const Offset(20, -10)); + final yawAfterDrag = controller.yaw; + controller.tick(const Duration(seconds: 3)); + + expect(controller.motionState, SceneMotionState.manual); + expect(controller.yaw, yawAfterDrag); + expect( + controller.pitch, + inInclusiveRange( + SceneCameraController.minPitch, + SceneCameraController.maxPitch, + ), + ); + }); + + test( + 'coasting decays within the bounded duration and new input cancels it', + () { + final controller = SceneCameraController()..beginInteraction(); + controller.endInteraction(const Offset(700, 120)); + expect(controller.motionState, SceneMotionState.coasting); + + controller.tick(Duration.zero); + controller.tick(const Duration(milliseconds: 900)); + expect(controller.motionState, SceneMotionState.manual); + + controller.endInteraction(const Offset(700, 0)); + expect(controller.motionState, SceneMotionState.coasting); + controller.zoomBy(-0.2); + expect(controller.motionState, SceneMotionState.manual); + }, + ); + + test('zoom, pitch, reduced motion, and reset remain bounded', () { + final controller = SceneCameraController()..beginInteraction(); + controller.dragBy(const Offset(0, -10000)); + controller.zoomBy(-100); + expect(controller.pitch, SceneCameraController.maxPitch); + expect(controller.distance, SceneCameraController.minDistance); + + controller.setReducedMotion(true); + controller.endInteraction(const Offset(900, 0)); + expect(controller.motionState, SceneMotionState.paused); + + controller.reset(); + expect(controller.yaw, 0); + expect(controller.pitch, SceneCameraController.initialPitch); + expect(controller.distance, SceneCameraController.initialDistance); + expect(controller.motionState, SceneMotionState.paused); + }); + + test('expanded zoom range exposes a relative zoom scale', () { + final controller = SceneCameraController(); + + controller.zoomBy(-100); + expect(controller.distance, 1.8); + expect(controller.zoomScale, closeTo(4 / 1.8, 0.001)); + + controller.zoomBy(100); + expect(controller.distance, 12); + expect(controller.zoomScale, closeTo(4 / 12, 0.001)); + }); + + test( + 'basic mode rejects direct manipulation but keeps button controls', + () { + final controller = SceneCameraController()..setEnhancedMode(false); + controller.beginInteraction(); + controller.dragBy(const Offset(100, 100)); + expect(controller.yaw, 0); + expect(controller.pitch, SceneCameraController.initialPitch); + + controller.orbitBy(0.25); + expect(controller.yaw, 0.25); + }, + ); + + test('mode changes normalize interrupted transition and coasting', () { + final controller = SceneCameraController(); + + controller.orbitBy(0.25); + expect(controller.motionState, SceneMotionState.transitioning); + controller.setEnhancedMode(false); + expect(controller.motionState, SceneMotionState.manual); + + controller.setEnhancedMode(true); + controller.beginInteraction(); + controller.endInteraction(const Offset(700, 0)); + expect(controller.motionState, SceneMotionState.coasting); + controller.setEnhancedMode(false); + expect(controller.motionState, SceneMotionState.manual); + }); + }); +} diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_selection_controller_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_selection_controller_test.dart new file mode 100644 index 0000000..3fbaf0e --- /dev/null +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_selection_controller_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_selection_controller.dart'; + +void main() { + test('selection can be replaced and cleared', () { + final controller = SceneSelectionController(); + const cuboid = ScenePartHit( + id: 'cuboid', + label: '机身', + centerX: -1.4, + centerY: 0, + centerZ: 0, + distance: 3.2, + normalX: 0, + normalY: 1, + normalZ: 0, + ); + + controller.select(cuboid); + expect(controller.selected, cuboid); + controller.clear(); + expect(controller.selected, isNull); + }); +} From befb99d36217e51709a7bf6a9f8a33401ca7417c Mon Sep 17 00:00:00 2001 From: lizy Date: Wed, 16 Sep 2026 17:20:29 +0800 Subject: [PATCH 03/21] docs(agents): sync interactive 3d viewer contracts --- CONTEXT.md | 16 ++++++++++ REFACTOR_PLAN.md | 45 ++++++++++++++++++++++++++++- tool/generate_agent_indexes.js | 53 ++++++++++++++++++++++++++++++---- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5ccd3b6..ef53d2b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,6 +48,22 @@ _Avoid_: 单浏览器通过、全浏览器已支持 以单个可交互长方体讲解 Scene、Node、Mesh、Camera 与 Flutter GPU 的最小实践;首期不包含模型加载、物理、定制着色器、后处理、场景编辑和高级材质。 _Avoid_: 3D 编辑器、完整渲染框架 +**教学型 3D 查看器**: +以真实的 360 度商品或设备查看流程承载相机动效教学;首阶段只深化单个长方体的相机环绕、缩放、暂停与复位,不扩展到模型加载或场景编辑。 +_Avoid_: 3D 商品平台、模型编辑器、通用 Viewer SDK + +**直接观察操控**: +使用者通过鼠标或键盘接管受限的双轴轨道相机;接管后自动巡展保持停止,直至使用者明确继续或重置。 +_Avoid_: 自由飞行相机、操作结束后强制恢复巡展 + +**低动态观察模式**: +遵从系统减少动态效果偏好,不主动播放持续巡展或惯性运动,同时保留观察对象所需的直接控制。 +_Avoid_: 禁用全部交互、仅隐藏动画控件 + +**3D 部件选取**: +使用者从当前可见部件中选择最接近视线的命中项,并可将观察镜头聚焦到该部件;环绕拖拽只改变观察方向,不产生选择。 +_Avoid_: 场景编辑、物理碰撞查询、拖拽即选择 + **GPU 平台准入**: 主机已配置 Flutter GPU 仅表示具备运行前提,不等于模块已获得平台证据;Flutter Scene 3D 首期只准入 macOS,Windows、移动端与 Web 必须保持目录可见但不可用,直至各自主机验收完成。 _Avoid_: 配置即支持、包声明即支持 diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index fa49bb1..29b05b2 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -285,6 +285,50 @@ "quality gate passed all six stages on 2026-09-13", "Windows, mobile and Web remain unavailable pending separate evidence" ] + }, + { + "id": "flutter_scene_3d_camera_motion", + "priority": 14, + "status": "pending", + "targets": [ + "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", + "apps/flutter_forge/test/modules/ui/flutter_scene_3d" + ], + "acceptance": [ + "bounded_two_axis_orbit", + "direct_manipulation_takeover", + "bounded_inertial_coasting", + "keyboard_and_reduced_motion", + "macos_motion_visual_acceptance" + ], + "evidence": [ + "controller and widget automation pass for takeover, bounds, inertia, mode comparison and reduced motion", + "expanded zoom bounds and the single-scene orientation thumbnail have controller and widget coverage", + "macOS Debug build passes and the real window renders the updated 3D first frame", + "macOS pointer, keyboard and motion-feel acceptance remains required for completion" + ] + }, + { + "id": "flutter_scene_3d_scene_picking", + "priority": 15, + "status": "pending", + "targets": [ + "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", + "apps/flutter_forge/test/modules/ui/flutter_scene_3d" + ], + "acceptance": [ + "nearest_visible_part_selection", + "drag_does_not_select", + "selection_highlight_and_details", + "focus_selected_part", + "clear_selection", + "macos_retina_pointer_acceptance" + ], + "evidence": [ + "selection state and widget focus flow have automated coverage", + "runtime uses screenPointToRay and Scene.raycast without physics", + "macOS Retina pointer and rendered highlight acceptance remains required" + ] } ], "quality_gate": [ @@ -302,7 +346,6 @@ "recommended_module_visual_evidence", "flutter_scene_3d_model_loading", "flutter_scene_3d_material_lighting", - "flutter_scene_3d_scene_picking", "flutter_scene_3d_windows_admission" ] } diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index 2916aa7..9333490 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -200,11 +200,11 @@ const modules = [ route: '/flutter-scene-3d', status: 'ready', depends: ['shared_learning', 'flutter_scene', 'vector_math', 'module_registry'], - title: 'Flutter Scene 3D 入门', - subtitle: '用一个可交互长方体理解场景、网格与相机控制', + title: '教学型 3D 查看器', + subtitle: '用轨道相机、部件选取与聚焦反馈理解 3D 查看器交互', difficulty: 'intermediate', - concepts: ['Flutter GPU', 'Scene', 'Node', 'Mesh', 'PerspectiveCamera'], - estimatedMinutes: 25, + concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], + estimatedMinutes: 35, entry: 'FlutterScene3dEntry', supportedPlatforms: ['macOS'], supportedPlatformsComment: '// Evidence boundary: only macOS is admitted; other hosts remain catalog-visible but unavailable.', @@ -859,6 +859,50 @@ function writeRefactorPlan() { 'Windows, mobile and Web remain unavailable pending separate evidence', ], }, + { + id: 'flutter_scene_3d_camera_motion', + priority: 14, + status: 'pending', + targets: [ + 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + ], + acceptance: [ + 'bounded_two_axis_orbit', + 'direct_manipulation_takeover', + 'bounded_inertial_coasting', + 'keyboard_and_reduced_motion', + 'macos_motion_visual_acceptance', + ], + evidence: [ + 'controller and widget automation pass for takeover, bounds, inertia, mode comparison and reduced motion', + 'expanded zoom bounds and the single-scene orientation thumbnail have controller and widget coverage', + 'macOS Debug build passes and the real window renders the updated 3D first frame', + 'macOS pointer, keyboard and motion-feel acceptance remains required for completion', + ], + }, + { + id: 'flutter_scene_3d_scene_picking', + priority: 15, + status: 'pending', + targets: [ + 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + ], + acceptance: [ + 'nearest_visible_part_selection', + 'drag_does_not_select', + 'selection_highlight_and_details', + 'focus_selected_part', + 'clear_selection', + 'macos_retina_pointer_acceptance', + ], + evidence: [ + 'selection state and widget focus flow have automated coverage', + 'runtime uses screenPointToRay and Scene.raycast without physics', + 'macOS Retina pointer and rendered highlight acceptance remains required', + ], + }, ], quality_gate: [ 'node tool/validate_agent_docs.js', @@ -875,7 +919,6 @@ function writeRefactorPlan() { 'recommended_module_visual_evidence', 'flutter_scene_3d_model_loading', 'flutter_scene_3d_material_lighting', - 'flutter_scene_3d_scene_picking', 'flutter_scene_3d_windows_admission', ], }); From 6ef48b5c147f0b68105128fee6e6964d84b68de9 Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 18 Sep 2026 10:34:19 +0800 Subject: [PATCH 04/21] feat(3d): admit Flutter Scene 3D on Windows --- CONTEXT.md | 2 +- apps/flutter_forge/lib/AI_MODULE_INDEX.md | 3 ++- .../lib/app/router/app_route_table.dart | 4 ++-- .../modules/ui/flutter_scene_3d/AI_ANALYSIS.md | 3 ++- .../flutter_scene_3d/flutter_scene_3d_test.dart | 16 ++++++++++++---- ...8-flutter-scene-platform-evidence-boundary.md | 2 +- ...09-flutter-scene-windows-logical-admission.md | 8 ++++++++ docs/adr/README.md | 2 ++ tool/generate_agent_indexes.js | 4 ++-- 9 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 docs/adr/0009-flutter-scene-windows-logical-admission.md diff --git a/CONTEXT.md b/CONTEXT.md index ef53d2b..25ea041 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -65,5 +65,5 @@ _Avoid_: 禁用全部交互、仅隐藏动画控件 _Avoid_: 场景编辑、物理碰撞查询、拖拽即选择 **GPU 平台准入**: -主机已配置 Flutter GPU 仅表示具备运行前提,不等于模块已获得平台证据;Flutter Scene 3D 首期只准入 macOS,Windows、移动端与 Web 必须保持目录可见但不可用,直至各自主机验收完成。 +主机已配置 Flutter GPU 仅表示具备运行前提,不等于模块已获得平台证据;Flutter Scene 3D 已逻辑准入 macOS 与 Windows,其中 Windows 仍需真实主机完成构建、首帧、DPI 输入和安装器验收,移动端与 Web 保持目录可见但不可用。 _Avoid_: 配置即支持、包声明即支持 diff --git a/apps/flutter_forge/lib/AI_MODULE_INDEX.md b/apps/flutter_forge/lib/AI_MODULE_INDEX.md index 0b44f6f..debc986 100644 --- a/apps/flutter_forge/lib/AI_MODULE_INDEX.md +++ b/apps/flutter_forge/lib/AI_MODULE_INDEX.md @@ -194,7 +194,8 @@ "module_registry" ], "supported_platforms": [ - "macOS" + "macOS", + "windows" ], "analysis": "lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md" }, diff --git a/apps/flutter_forge/lib/app/router/app_route_table.dart b/apps/flutter_forge/lib/app/router/app_route_table.dart index e7b7e8e..6069290 100644 --- a/apps/flutter_forge/lib/app/router/app_route_table.dart +++ b/apps/flutter_forge/lib/app/router/app_route_table.dart @@ -241,8 +241,8 @@ final List _modules = [ concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], estimatedMinutes: 35, status: ModuleStatus.ready, - // Evidence boundary: only macOS is admitted; other hosts remain catalog-visible but unavailable. - supportedPlatforms: {TargetPlatform.macOS}, + // Windows is logically admitted; host GPU, DPI, and installer evidence remains pending. + supportedPlatforms: {TargetPlatform.macOS, TargetPlatform.windows}, builder: (context) => const FlutterScene3dEntry(), ), diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md index 40bd067..3d0bb6b 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md @@ -11,7 +11,8 @@ "route": "/flutter-scene-3d", "category": "ui", "supported_platforms": [ - "macOS" + "macOS", + "windows" ], "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart index e59a145..9adcf75 100644 --- a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart @@ -219,16 +219,19 @@ void main() { expect(find.textContaining('Flutter GPU 已启用'), findsOneWidget); }); - test('catalog registers ready macOS-only module and route', () { + test('catalog registers ready macOS and Windows module and route', () { final module = AppRouteTable.modules.singleWhere( (item) => item.path == '/flutter-scene-3d', ); expect(module.status, ModuleStatus.ready); - expect(module.supportedPlatforms, {TargetPlatform.macOS}); + expect(module.supportedPlatforms, { + TargetPlatform.macOS, + TargetPlatform.windows, + }); expect(isModuleAvailable(module, TargetPlatform.macOS), isTrue); + expect(isModuleAvailable(module, TargetPlatform.windows), isTrue); for (final platform in [ - TargetPlatform.windows, TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.linux, @@ -238,7 +241,12 @@ void main() { } expect( AppRouteTable.routes.where((route) => route.path == module.path), - defaultTargetPlatform == TargetPlatform.macOS ? isNotEmpty : isEmpty, + { + TargetPlatform.macOS, + TargetPlatform.windows, + }.contains(defaultTargetPlatform) + ? isNotEmpty + : isEmpty, ); }); } diff --git a/docs/adr/0008-flutter-scene-platform-evidence-boundary.md b/docs/adr/0008-flutter-scene-platform-evidence-boundary.md index 263ba75..120500b 100644 --- a/docs/adr/0008-flutter-scene-platform-evidence-boundary.md +++ b/docs/adr/0008-flutter-scene-platform-evidence-boundary.md @@ -2,7 +2,7 @@ | 属性 | 值 | |------|-----| -| 状态 | accepted | +| 状态 | accepted;Windows 准入部分由 ADR 0009 取代 | | 日期 | 2026-09-13 | | 决策者 | forest | diff --git a/docs/adr/0009-flutter-scene-windows-logical-admission.md b/docs/adr/0009-flutter-scene-windows-logical-admission.md new file mode 100644 index 0000000..f74b6d9 --- /dev/null +++ b/docs/adr/0009-flutter-scene-windows-logical-admission.md @@ -0,0 +1,8 @@ +# Flutter Scene Windows 逻辑准入 + +状态:accepted。Flutter Scene 3D 从本决策起在目录与路由层同时准入 macOS 和 Windows,因为 Windows Runner 已按固定依赖要求启用 Flutter GPU,且模块逻辑不依赖 macOS 专属 API;这一准入只表示 Windows 可以进入模块,不表示 Windows 构建、GPU 首帧、高 DPI 输入或安装器验收已经通过。 + +## Consequences + +- ADR 0008 中的 Windows 目录限制由本决策取代;Android、iOS 与 Web 的限制不变。 +- Windows 主机证据继续由 flutter_scene_3d_windows_admission 独立任务管理,未完成前不得标记 Windows 平台 PASS。 diff --git a/docs/adr/README.md b/docs/adr/README.md index e88a484..4d677ae 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,6 +13,8 @@ | 0005 | 移动端单窗口与桌面响应式导航 | accepted | | 0006 | PC 可维护性封板与 Android 兼容轨道 | accepted | | 0007 | Web 运行能力与启动体验基线 | accepted | +| 0008 | Flutter Scene 依赖与平台证据边界 | accepted;Windows 部分被 0009 取代 | +| 0009 | Flutter Scene Windows 逻辑准入 | accepted | ## 状态定义 diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index 9333490..d1b8bf4 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -206,8 +206,8 @@ const modules = [ concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], estimatedMinutes: 35, entry: 'FlutterScene3dEntry', - supportedPlatforms: ['macOS'], - supportedPlatformsComment: '// Evidence boundary: only macOS is admitted; other hosts remain catalog-visible but unavailable.', + supportedPlatforms: ['macOS', 'windows'], + supportedPlatformsComment: '// Windows is logically admitted; host GPU, DPI, and installer evidence remains pending.', }, { category: 'popup_table', From 28e694e00616ebe3f4e5029874aa4ee50be03ae1 Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 18 Sep 2026 10:34:22 +0800 Subject: [PATCH 05/21] test(3d): add desktop viewer integration flow --- .../flutter_scene_3d_flow_test.dart | 111 ++++++++++++++++++ .../ui/flutter_scene_3d/module_root.dart | 6 +- 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart diff --git a/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart b/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart new file mode 100644 index 0000000..f8624cf --- /dev/null +++ b/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/router/app_router.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + AppRouter.router.go('/'); + }); + + testWidgets('3D viewer desktop interaction flow', (tester) async { + expect( + {TargetPlatform.macOS, TargetPlatform.windows}, + contains(defaultTargetPlatform), + reason: 'This acceptance flow requires a desktop Flutter GPU host.', + ); + + await tester.pumpWidget(const App()); + await _pumpFrames(tester, const Duration(milliseconds: 600)); + expect(find.text('Flutter 学习实验室'), findsOneWidget); + + final moduleTile = find.byKey(const ValueKey('module:/flutter-scene-3d')); + await tester.scrollUntilVisible( + moduleTile, + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(moduleTile); + await _pumpFrames(tester, const Duration(milliseconds: 200)); + await tester.tap(moduleTile); + await _pumpUntil( + tester, + find.byKey(const Key('scene-interaction-surface')), + ); + expect(find.byKey(const Key('scene-error')), findsNothing); + expect(find.text('设备部件检查'), findsOneWidget); + + await tester.tap(find.byTooltip('暂停环绕')); + await tester.pump(); + expect(find.textContaining('状态:已暂停'), findsOneWidget); + + await tester.tap(find.byKey(const Key('basic-mode'))); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(find.text('教学型 3D 查看器'), findsWidgets); + + final surface = find.byKey(const Key('scene-interaction-surface')); + await tester.tap(find.byKey(const Key('enhanced-mode'))); + await tester.sendEventToBinding( + PointerScrollEvent( + position: tester.getCenter(surface), + scrollDelta: const Offset(0, -160), + ), + ); + await _pumpFrames(tester, const Duration(milliseconds: 250)); + expect(find.textContaining('缩放:1.19×'), findsOneWidget); + await tester.tap(find.byKey(const Key('scene-orientation-thumbnail'))); + await _pumpFrames(tester, const Duration(milliseconds: 450)); + + await tester.tapAt(tester.getCenter(surface)); + await _pumpUntil(tester, find.text('传感器')); + expect(find.byKey(const Key('focus-selected-part')), findsOneWidget); + + final focusButton = find.byKey(const Key('focus-selected-part')); + await tester.ensureVisible(focusButton); + await tester.tap(focusButton); + await _pumpFrames(tester, const Duration(milliseconds: 450)); + expect(find.textContaining('距离:3.0'), findsOneWidget); + + final clearButton = find.byKey(const Key('clear-selected-part')); + await tester.ensureVisible(clearButton); + await tester.tap(clearButton); + await tester.pump(); + expect(find.text('传感器'), findsNothing); + + await tester.pageBack(); + await _pumpFrames(tester, const Duration(milliseconds: 400)); + expect(find.text('Flutter 学习实验室'), findsOneWidget); + }); +} + +Future _pumpFrames(WidgetTester tester, Duration duration) async { + const step = Duration(milliseconds: 50); + var elapsed = Duration.zero; + while (elapsed < duration) { + await tester.pump(step); + elapsed += step; + } +} + +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 8), +}) async { + const step = Duration(milliseconds: 80); + var elapsed = Duration.zero; + while (finder.evaluate().isEmpty && elapsed < timeout) { + await tester.pump(step); + elapsed += step; + } + expect(finder, findsOneWidget); +} diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart index 0cf46cd..01e44ef 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart @@ -382,7 +382,11 @@ class _SelectionPanel extends StatelessWidget { icon: const Icon(Icons.center_focus_strong), label: const Text('聚焦'), ), - TextButton(onPressed: onClear, child: const Text('取消选择')), + TextButton( + key: const Key('clear-selected-part'), + onPressed: onClear, + child: const Text('取消选择'), + ), ], ), ), From 89c5385f7b1701122333902eb70be1d5939a0d0c Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 18 Sep 2026 10:34:24 +0800 Subject: [PATCH 06/21] docs(agents): track windows admission and desktop flow --- REFACTOR_PLAN.md | 32 +++++++++++++++++++++++++++++--- tool/generate_agent_indexes.js | 28 +++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index 29b05b2..b888f61 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -292,7 +292,8 @@ "status": "pending", "targets": [ "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", - "apps/flutter_forge/test/modules/ui/flutter_scene_3d" + "apps/flutter_forge/test/modules/ui/flutter_scene_3d", + "apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart" ], "acceptance": [ "bounded_two_axis_orbit", @@ -329,6 +330,32 @@ "runtime uses screenPointToRay and Scene.raycast without physics", "macOS Retina pointer and rendered highlight acceptance remains required" ] + }, + { + "id": "flutter_scene_3d_windows_admission", + "priority": 16, + "status": "pending", + "targets": [ + "apps/flutter_forge/windows/runner/main.cpp", + "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", + "apps/flutter_forge/test/modules/ui/flutter_scene_3d", + "apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart" + ], + "acceptance": [ + "windows_catalog_and_route_admission", + "desktop_3d_viewer_automated_ui_flow", + "windows_debug_and_release_build", + "impeller_flutter_gpu_first_frame", + "windows_pointer_keyboard_and_dpi", + "selection_highlight_and_focus", + "windows_installer_launch" + ], + "evidence": [ + "Windows runner enables Flutter GPU through DartProject", + "catalog and route contract admits macOS and Windows", + "desktop integration flow covers navigation, camera input, selection, focus, clear and return", + "Windows host build, GPU, DPI, interaction, and installer evidence remains required" + ] } ], "quality_gate": [ @@ -345,7 +372,6 @@ "flutterguard_med_reduction", "recommended_module_visual_evidence", "flutter_scene_3d_model_loading", - "flutter_scene_3d_material_lighting", - "flutter_scene_3d_windows_admission" + "flutter_scene_3d_material_lighting" ] } diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index d1b8bf4..72e0c58 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -866,6 +866,7 @@ function writeRefactorPlan() { targets: [ 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart', ], acceptance: [ 'bounded_two_axis_orbit', @@ -903,6 +904,32 @@ function writeRefactorPlan() { 'macOS Retina pointer and rendered highlight acceptance remains required', ], }, + { + id: 'flutter_scene_3d_windows_admission', + priority: 16, + status: 'pending', + targets: [ + 'apps/flutter_forge/windows/runner/main.cpp', + 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart', + ], + acceptance: [ + 'windows_catalog_and_route_admission', + 'desktop_3d_viewer_automated_ui_flow', + 'windows_debug_and_release_build', + 'impeller_flutter_gpu_first_frame', + 'windows_pointer_keyboard_and_dpi', + 'selection_highlight_and_focus', + 'windows_installer_launch', + ], + evidence: [ + 'Windows runner enables Flutter GPU through DartProject', + 'catalog and route contract admits macOS and Windows', + 'desktop integration flow covers navigation, camera input, selection, focus, clear and return', + 'Windows host build, GPU, DPI, interaction, and installer evidence remains required', + ], + }, ], quality_gate: [ 'node tool/validate_agent_docs.js', @@ -919,7 +946,6 @@ function writeRefactorPlan() { 'recommended_module_visual_evidence', 'flutter_scene_3d_model_loading', 'flutter_scene_3d_material_lighting', - 'flutter_scene_3d_windows_admission', ], }); } From 8f5adb03f72e3e1e40e17861cf9d765a861b2a46 Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 18 Sep 2026 18:01:19 +0800 Subject: [PATCH 07/21] feat(android): admit read-only Flutter Scene viewer --- .../android/app/src/main/AndroidManifest.xml | 3 + .../lib/app/router/app_route_table.dart | 8 +- .../ui/flutter_scene_3d/module_entry.dart | 6 +- .../ui/flutter_scene_3d/module_root.dart | 204 +++++++++++------- .../scene_interaction_mode.dart | 12 ++ 5 files changed, 148 insertions(+), 85 deletions(-) create mode 100644 apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_interaction_mode.dart diff --git a/apps/flutter_forge/android/app/src/main/AndroidManifest.xml b/apps/flutter_forge/android/app/src/main/AndroidManifest.xml index 9b90bd6..6a5fd61 100644 --- a/apps/flutter_forge/android/app/src/main/AndroidManifest.xml +++ b/apps/flutter_forge/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ android:label="Flutter Forge" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + _modules = [ concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], estimatedMinutes: 35, status: ModuleStatus.ready, - // Windows is logically admitted; host GPU, DPI, and installer evidence remains pending. - supportedPlatforms: {TargetPlatform.macOS, TargetPlatform.windows}, + // Android is view-only; Windows and Android host evidence remains pending. + supportedPlatforms: { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }, builder: (context) => const FlutterScene3dEntry(), ), diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_entry.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_entry.dart index 069cf6a..cfd027e 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_entry.dart +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_entry.dart @@ -1,10 +1,14 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'module_root.dart'; +import 'scene_interaction_mode.dart'; class FlutterScene3dEntry extends StatelessWidget { const FlutterScene3dEntry({super.key}); @override - Widget build(BuildContext context) => const FlutterScene3dPage(); + Widget build(BuildContext context) => FlutterScene3dPage( + interactionMode: SceneInteractionPolicy.resolve(defaultTargetPlatform), + ); } diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart index 01e44ef..5d9fa1c 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/module_root.dart @@ -7,13 +7,19 @@ import 'package:flutter/services.dart'; import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'scene_camera_controller.dart'; +import 'scene_interaction_mode.dart'; import 'scene_runtime.dart'; import 'scene_selection_controller.dart'; class FlutterScene3dPage extends StatefulWidget { - const FlutterScene3dPage({super.key, this.runtime}); + const FlutterScene3dPage({ + super.key, + this.runtime, + this.interactionMode = SceneInteractionMode.interactive, + }); final SceneDemoRuntime? runtime; + final SceneInteractionMode interactionMode; @override State createState() => _FlutterScene3dPageState(); @@ -31,6 +37,7 @@ class _FlutterScene3dPageState extends State { interactiveDemo: _SceneDemo( runtime: _runtime, initialization: _initialization, + interactionMode: widget.interactionMode, ), sections: const [ LearningObjectives( @@ -74,10 +81,15 @@ SceneView(scene, cameraBuilder: buildOrbitCamera);''', } class _SceneDemo extends StatefulWidget { - const _SceneDemo({required this.runtime, required this.initialization}); + const _SceneDemo({ + required this.runtime, + required this.initialization, + required this.interactionMode, + }); final SceneDemoRuntime runtime; final Future initialization; + final SceneInteractionMode interactionMode; @override State<_SceneDemo> createState() => _SceneDemoState(); @@ -89,6 +101,8 @@ class _SceneDemoState extends State<_SceneDemo> { bool? _lastReducedMotion; SceneCameraController get _camera => widget.runtime.camera; + bool get _isInteractive => + widget.interactionMode == SceneInteractionMode.interactive; @override void initState() { @@ -124,7 +138,7 @@ class _SceneDemoState extends State<_SceneDemo> { return Focus( focusNode: _focusNode, autofocus: true, - onKeyEvent: _handleKeyEvent, + onKeyEvent: _isInteractive ? _handleKeyEvent : null, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -160,9 +174,12 @@ class _SceneDemoState extends State<_SceneDemo> { fit: StackFit.expand, children: [ Listener( - onPointerDown: (_) => _focusNode.requestFocus(), + onPointerDown: _isInteractive + ? (_) => _focusNode.requestFocus() + : null, onPointerSignal: (event) { - if (event is PointerScrollEvent && + if (_isInteractive && + event is PointerScrollEvent && _camera.enhancedMode) { _focusNode.requestFocus(); _camera.beginInteraction(); @@ -174,33 +191,41 @@ class _SceneDemoState extends State<_SceneDemo> { child: GestureDetector( key: _sceneSurfaceKey, behavior: HitTestBehavior.opaque, - onTapUp: (details) { - final box = - _sceneSurfaceKey.currentContext - ?.findRenderObject() - as RenderBox?; - if (box != null) { - widget.runtime.selectAt( - details.localPosition, - box.size, - ); - } - }, - onPanStart: (_) => _camera.beginInteraction(), - onPanUpdate: (details) => - _camera.dragBy(details.delta), - onPanEnd: (details) => _camera.endInteraction( - details.velocity.pixelsPerSecond, - ), + onTapUp: _isInteractive + ? (details) { + final box = + _sceneSurfaceKey.currentContext + ?.findRenderObject() + as RenderBox?; + if (box != null) { + widget.runtime.selectAt( + details.localPosition, + box.size, + ); + } + } + : null, + onPanStart: _isInteractive + ? (_) => _camera.beginInteraction() + : null, + onPanUpdate: _isInteractive + ? (details) => _camera.dragBy(details.delta) + : null, + onPanEnd: _isInteractive + ? (details) => _camera.endInteraction( + details.velocity.pixelsPerSecond, + ) + : null, child: widget.runtime.buildView(), ), ), ), - Positioned( - top: 12, - right: 12, - child: _OrientationThumbnail(camera: _camera), - ), + if (_isInteractive) + Positioned( + top: 12, + right: 12, + child: _OrientationThumbnail(camera: _camera), + ), ], ), ); @@ -208,64 +233,79 @@ class _SceneDemoState extends State<_SceneDemo> { ), ), const SizedBox(height: 12), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - children: [ - ChoiceChip( - key: const Key('basic-mode'), - label: const Text('基础模式'), - selected: !_camera.enhancedMode, - onSelected: (_) => _camera.setEnhancedMode(false), - ), - ChoiceChip( - key: const Key('enhanced-mode'), - label: const Text('增强模式'), - selected: _camera.enhancedMode, - onSelected: (_) => _camera.setEnhancedMode(true), + if (!_isInteractive) + const Card( + key: Key('android-view-only-notice'), + child: Padding( + padding: EdgeInsets.all(12), + child: Text( + 'Android 第一阶段为只读查看:保留自动巡展,暂不开放手势、选择与聚焦。', + textAlign: TextAlign.center, + ), ), - ], - ), - const SizedBox(height: 8), - Text( - _camera.enhancedMode - ? '拖拽环绕 · 滚轮缩放 · 方向键与 +/- 控制' - : '使用下方按钮观察离散控制效果', - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - LayoutBuilder( - builder: (context, constraints) { - final controls = _controls(); - if (constraints.maxWidth >= 620) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: controls - .map( - (control) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: control, - ), - ) - .toList(), + ), + if (_isInteractive) + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: [ + ChoiceChip( + key: const Key('basic-mode'), + label: const Text('基础模式'), + selected: !_camera.enhancedMode, + onSelected: (_) => _camera.setEnhancedMode(false), + ), + ChoiceChip( + key: const Key('enhanced-mode'), + label: const Text('增强模式'), + selected: _camera.enhancedMode, + onSelected: (_) => _camera.setEnhancedMode(true), + ), + ], + ), + if (_isInteractive) const SizedBox(height: 8), + if (_isInteractive) + Text( + _camera.enhancedMode + ? '拖拽环绕 · 滚轮缩放 · 方向键与 +/- 控制' + : '使用下方按钮观察离散控制效果', + textAlign: TextAlign.center, + ), + if (_isInteractive) const SizedBox(height: 12), + if (_isInteractive) + LayoutBuilder( + builder: (context, constraints) { + final controls = _controls(); + if (constraints.maxWidth >= 620) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: controls + .map( + (control) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: control, + ), + ) + .toList(), + ); + } + return Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: controls, ); - } - return Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: controls, - ); - }, - ), + }, + ), const SizedBox(height: 8), _CameraStatus(camera: _camera), - const SizedBox(height: 8), - _SelectionPanel( - selection: widget.runtime.selection, - onFocus: widget.runtime.focusSelection, - onClear: widget.runtime.clearSelection, - ), + if (_isInteractive) const SizedBox(height: 8), + if (_isInteractive) + _SelectionPanel( + selection: widget.runtime.selection, + onFocus: widget.runtime.focusSelection, + onClear: widget.runtime.clearSelection, + ), ], ), ); diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_interaction_mode.dart b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_interaction_mode.dart new file mode 100644 index 0000000..aebeed2 --- /dev/null +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/scene_interaction_mode.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; + +enum SceneInteractionMode { interactive, viewOnly } + +class SceneInteractionPolicy { + const SceneInteractionPolicy._(); + + static SceneInteractionMode resolve(TargetPlatform platform) => + platform == TargetPlatform.android + ? SceneInteractionMode.viewOnly + : SceneInteractionMode.interactive; +} From 1fd017fb65c8b3083ee2838bb1f7b6b828e4ec89 Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 18 Sep 2026 18:01:42 +0800 Subject: [PATCH 08/21] test(android): cover read-only 3d admission --- .../flutter_scene_3d_test.dart | 30 +++++++++++++++++-- .../scene_interaction_mode_test.dart | 20 +++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_interaction_mode_test.dart diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart index 9adcf75..c947f24 100644 --- a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart @@ -10,6 +10,7 @@ import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/module_root.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_camera_controller.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_interaction_mode.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_runtime.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_selection_controller.dart'; @@ -77,6 +78,29 @@ void main() { expect(find.byKey(const Key('reduced-motion-status')), findsOneWidget); }); + testWidgets('Android view-only mode renders scene without controls', ( + tester, + ) async { + final runtime = FakeSceneRuntime(); + await tester.pumpWidget( + MaterialApp( + home: FlutterScene3dPage( + runtime: runtime, + interactionMode: SceneInteractionMode.viewOnly, + ), + ), + ); + await tester.pump(); + + expect(find.byKey(const Key('scene-interaction-surface')), findsOneWidget); + expect(find.byKey(const Key('android-view-only-notice')), findsOneWidget); + expect(find.byKey(const Key('basic-mode')), findsNothing); + expect(find.byKey(const Key('enhanced-mode')), findsNothing); + expect(find.byKey(const Key('scene-orientation-thumbnail')), findsNothing); + expect(find.byTooltip('暂停环绕'), findsNothing); + expect(find.textContaining('点击部件'), findsNothing); + }); + testWidgets('basic mode blocks wheel zoom', (tester) async { final runtime = FakeSceneRuntime(); await tester.pumpWidget( @@ -219,7 +243,7 @@ void main() { expect(find.textContaining('Flutter GPU 已启用'), findsOneWidget); }); - test('catalog registers ready macOS and Windows module and route', () { + test('catalog registers Android, macOS, and Windows module and route', () { final module = AppRouteTable.modules.singleWhere( (item) => item.path == '/flutter-scene-3d', ); @@ -228,11 +252,12 @@ void main() { expect(module.supportedPlatforms, { TargetPlatform.macOS, TargetPlatform.windows, + TargetPlatform.android, }); expect(isModuleAvailable(module, TargetPlatform.macOS), isTrue); expect(isModuleAvailable(module, TargetPlatform.windows), isTrue); + expect(isModuleAvailable(module, TargetPlatform.android), isTrue); for (final platform in [ - TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.linux, TargetPlatform.fuchsia, @@ -242,6 +267,7 @@ void main() { expect( AppRouteTable.routes.where((route) => route.path == module.path), { + TargetPlatform.android, TargetPlatform.macOS, TargetPlatform.windows, }.contains(defaultTargetPlatform) diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_interaction_mode_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_interaction_mode_test.dart new file mode 100644 index 0000000..cdca606 --- /dev/null +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/scene_interaction_mode_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/scene_interaction_mode.dart'; + +void main() { + test('Android uses view-only mode while desktop remains interactive', () { + expect( + SceneInteractionPolicy.resolve(TargetPlatform.android), + SceneInteractionMode.viewOnly, + ); + expect( + SceneInteractionPolicy.resolve(TargetPlatform.macOS), + SceneInteractionMode.interactive, + ); + expect( + SceneInteractionPolicy.resolve(TargetPlatform.windows), + SceneInteractionMode.interactive, + ); + }); +} From 7972ffb4bb491b8fdcb5a1650dc4f9746fb5ff47 Mon Sep 17 00:00:00 2001 From: lizy Date: Fri, 18 Sep 2026 18:01:54 +0800 Subject: [PATCH 09/21] docs(agents): record Android 3d view-only phase --- CONTEXT.md | 6 +++- REFACTOR_PLAN.md | 25 ++++++++++++++++ apps/flutter_forge/lib/AI_MODULE_INDEX.md | 1 + .../ui/flutter_scene_3d/AI_ANALYSIS.md | 1 + ...utter-scene-android-view-only-admission.md | 9 ++++++ docs/adr/README.md | 1 + tool/generate_agent_indexes.js | 29 +++++++++++++++++-- 7 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0010-flutter-scene-android-view-only-admission.md diff --git a/CONTEXT.md b/CONTEXT.md index 25ea041..781a813 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -65,5 +65,9 @@ _Avoid_: 禁用全部交互、仅隐藏动画控件 _Avoid_: 场景编辑、物理碰撞查询、拖拽即选择 **GPU 平台准入**: -主机已配置 Flutter GPU 仅表示具备运行前提,不等于模块已获得平台证据;Flutter Scene 3D 已逻辑准入 macOS 与 Windows,其中 Windows 仍需真实主机完成构建、首帧、DPI 输入和安装器验收,移动端与 Web 保持目录可见但不可用。 +主机已配置 Flutter GPU 仅表示具备运行前提,不等于模块已获得平台证据;Flutter Scene 3D 已逻辑准入 macOS、Windows 与 Android,其中 Android 首阶段仅提供自动巡展的只读查看,Windows 与 Android 仍需各自主机完成构建和 GPU 首帧验收,iOS 与 Web 保持目录可见但不可用。 _Avoid_: 配置即支持、包声明即支持 + +**Android 3D 只读查看**: +Android 首阶段只呈现场景和自动巡展,不提供手势、键盘、缩略图复位、部件选取或镜头聚焦;它是后续移动操控建设前的渲染准入阶段。 +_Avoid_: Android 3D 编辑器、移动端完整操控 diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index b888f61..6a96cba 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -356,6 +356,31 @@ "desktop integration flow covers navigation, camera input, selection, focus, clear and return", "Windows host build, GPU, DPI, interaction, and installer evidence remains required" ] + }, + { + "id": "flutter_scene_3d_android_view_only_admission", + "priority": 17, + "status": "pending", + "targets": [ + "apps/flutter_forge/android/app/src/main/AndroidManifest.xml", + "apps/flutter_forge/lib/modules/ui/flutter_scene_3d", + "apps/flutter_forge/test/modules/ui/flutter_scene_3d" + ], + "acceptance": [ + "android_flutter_gpu_enabled", + "android_catalog_and_route_admission", + "android_view_only_scene", + "android_controls_selection_and_focus_hidden", + "android_debug_apk_build", + "android_gpu_first_frame_manual_evidence" + ], + "evidence": [ + "Android manifest enables Flutter GPU", + "module policy limits Android to automatic view-only rendering", + "catalog and widget contracts cover Android admission and hidden controls", + "Android emulator or device automation is intentionally deferred by scope", + "Android host build and GPU first-frame evidence remains required" + ] } ], "quality_gate": [ diff --git a/apps/flutter_forge/lib/AI_MODULE_INDEX.md b/apps/flutter_forge/lib/AI_MODULE_INDEX.md index debc986..061a910 100644 --- a/apps/flutter_forge/lib/AI_MODULE_INDEX.md +++ b/apps/flutter_forge/lib/AI_MODULE_INDEX.md @@ -194,6 +194,7 @@ "module_registry" ], "supported_platforms": [ + "android", "macOS", "windows" ], diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md index 3d0bb6b..0bbb764 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md @@ -11,6 +11,7 @@ "route": "/flutter-scene-3d", "category": "ui", "supported_platforms": [ + "android", "macOS", "windows" ], diff --git a/docs/adr/0010-flutter-scene-android-view-only-admission.md b/docs/adr/0010-flutter-scene-android-view-only-admission.md new file mode 100644 index 0000000..4f979a0 --- /dev/null +++ b/docs/adr/0010-flutter-scene-android-view-only-admission.md @@ -0,0 +1,9 @@ +# Flutter Scene Android 只读准入 + +状态:accepted。Flutter Scene 3D 在 Android 首阶段只开放场景初始化和自动巡展,不开放手势、选择、聚焦或编辑能力;这样可以先隔离验证 Android Flutter GPU 渲染路径,再在后续任务中独立建设移动端操控。 + +## Consequences + +- Android 进入模块后看到明确的只读说明,桌面平台继续保留完整交互。 +- Android Flutter GPU、APK 构建和真实首帧仍需独立证据;目录准入不构成平台 PASS。 +- 本阶段不新增 Android integration 自动化流程。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 4d677ae..e967f8b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,6 +15,7 @@ | 0007 | Web 运行能力与启动体验基线 | accepted | | 0008 | Flutter Scene 依赖与平台证据边界 | accepted;Windows 部分被 0009 取代 | | 0009 | Flutter Scene Windows 逻辑准入 | accepted | +| 0010 | Flutter Scene Android 只读准入 | accepted | ## 状态定义 diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index 72e0c58..6108d6b 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -206,8 +206,8 @@ const modules = [ concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], estimatedMinutes: 35, entry: 'FlutterScene3dEntry', - supportedPlatforms: ['macOS', 'windows'], - supportedPlatformsComment: '// Windows is logically admitted; host GPU, DPI, and installer evidence remains pending.', + supportedPlatforms: ['android', 'macOS', 'windows'], + supportedPlatformsComment: '// Android is view-only; Windows and Android host evidence remains pending.', }, { category: 'popup_table', @@ -930,6 +930,31 @@ function writeRefactorPlan() { 'Windows host build, GPU, DPI, interaction, and installer evidence remains required', ], }, + { + id: 'flutter_scene_3d_android_view_only_admission', + priority: 17, + status: 'pending', + targets: [ + 'apps/flutter_forge/android/app/src/main/AndroidManifest.xml', + 'apps/flutter_forge/lib/modules/ui/flutter_scene_3d', + 'apps/flutter_forge/test/modules/ui/flutter_scene_3d', + ], + acceptance: [ + 'android_flutter_gpu_enabled', + 'android_catalog_and_route_admission', + 'android_view_only_scene', + 'android_controls_selection_and_focus_hidden', + 'android_debug_apk_build', + 'android_gpu_first_frame_manual_evidence', + ], + evidence: [ + 'Android manifest enables Flutter GPU', + 'module policy limits Android to automatic view-only rendering', + 'catalog and widget contracts cover Android admission and hidden controls', + 'Android emulator or device automation is intentionally deferred by scope', + 'Android host build and GPU first-frame evidence remains required', + ], + }, ], quality_gate: [ 'node tool/validate_agent_docs.js', From d75e382b00634c8391e2a72a35c996d60100b72f Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 16:09:54 +0800 Subject: [PATCH 10/21] refactor(registry): drive module platform support from generated manifest --- AI_ANALYSIS_SCHEMA.json | 3 +- AI_PROJECT_CONTEXT.md | 5 +- REFACTOR_PLAN.md | 54 ++- apps/flutter_forge/lib/AI_MODULE_INDEX.md | 141 ++++-- .../lib/app/router/app_route_table.dart | 386 +--------------- .../lib/module_registry/module_entry.dart | 20 +- .../lib/module_registry/module_manifest.dart | 425 ++++++++++++++++++ .../module_platform_support.dart | 18 + .../async/isolate_basic/AI_ANALYSIS.md | 5 +- .../async/isolate_task_manager/AI_ANALYSIS.md | 5 +- .../async/stream_subscription/AI_ANALYSIS.md | 4 + .../basic/debounce_throttle/AI_ANALYSIS.md | 4 + .../modules/basic/microtask/AI_ANALYSIS.md | 4 + .../modules/basic/tree_state/AI_ANALYSIS.md | 4 + .../platform/dio_interceptor/AI_ANALYSIS.md | 5 +- .../platform/file_picker/AI_ANALYSIS.md | 13 +- .../online_video_player/AI_ANALYSIS.md | 13 +- .../platform/usb_detector/AI_ANALYSIS.md | 7 +- .../modules/platform/webview/AI_ANALYSIS.md | 13 +- .../overlay_follow_compare/AI_ANALYSIS.md | 4 + .../popup_list_interaction/AI_ANALYSIS.md | 4 + .../popup_table/popup_widgets/AI_ANALYSIS.md | 4 + .../popup_table/scroll_table/AI_ANALYSIS.md | 4 + .../modules/state/flutter_ioc/AI_ANALYSIS.md | 4 + .../state/local_persistence/AI_ANALYSIS.md | 4 + .../state/status_management/AI_ANALYSIS.md | 4 + .../modules/ui/adsorption_line/AI_ANALYSIS.md | 4 + .../ui/download_animation/AI_ANALYSIS.md | 4 + .../ui/flutter_scene_3d/AI_ANALYSIS.md | 13 +- .../lib/modules/ui/font_picker/AI_ANALYSIS.md | 4 + .../ui/gcode_visualizer/AI_ANALYSIS.md | 9 +- .../file_picker/file_picker_test.dart | 34 ++ .../online_video_player_test.dart | 48 ++ .../usb_detector/usb_detector_test.dart | 17 + .../flutter_scene_3d_test.dart | 2 +- .../shared/module_catalog_utils_test.dart | 23 +- .../test/shared/web_compatibility_test.dart | 6 +- tool/generate_agent_indexes.js | 182 ++++++-- tool/validate_agent_docs.js | 24 +- 39 files changed, 996 insertions(+), 531 deletions(-) create mode 100644 apps/flutter_forge/lib/module_registry/module_manifest.dart create mode 100644 apps/flutter_forge/lib/module_registry/module_platform_support.dart diff --git a/AI_ANALYSIS_SCHEMA.json b/AI_ANALYSIS_SCHEMA.json index fd09856..43dc800 100644 --- a/AI_ANALYSIS_SCHEMA.json +++ b/AI_ANALYSIS_SCHEMA.json @@ -70,8 +70,7 @@ "route", "category", "status", - "supported_platforms", - "supports_web", + "platform_support", "entrypoints", "analysis_parent" ], diff --git a/AI_PROJECT_CONTEXT.md b/AI_PROJECT_CONTEXT.md index 29eaa5b..a43b439 100644 --- a/AI_PROJECT_CONTEXT.md +++ b/AI_PROJECT_CONTEXT.md @@ -27,7 +27,8 @@ "bootstrap": "lib/app/app_bootstrap.dart", "app": "lib/app/app.dart", "router": "lib/app/router/app_router.dart", - "route_table": "lib/app/router/app_route_table.dart" + "route_table": "lib/app/router/app_route_table.dart", + "module_manifest": "lib/module_registry/module_manifest.dart" }, "repository": { "layout": "pub_workspace", @@ -147,7 +148,7 @@ "module_entry.dart", "AI_ANALYSIS.md" ], - "required_registration": "lib/app/router/app_route_table.dart", + "required_registration": "lib/module_registry/module_manifest.dart", "required_metadata": [ "category", "difficulty", diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index 6a96cba..dd5b9a8 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -5,6 +5,7 @@ "completed_milestones": [ "directory_layers", "shared_package_extraction", + "module_manifest_route_composition", "module_analysis_coverage", "app_navigation_boundary", "host_bootstrap_boundary", @@ -92,7 +93,12 @@ "acceptance": [ "no_windows_hardcode", "android_system_info", - "error_branch_test" + "error_branch_test", + "mobile_usb_route_disabled" + ], + "evidence": [ + "USB module remains catalog-visible but has no supported host routes", + "generic Android USB monitoring is deferred until a concrete OTG workflow exists" ] }, { @@ -381,6 +387,52 @@ "Android emulator or device automation is intentionally deferred by scope", "Android host build and GPU first-frame evidence remains required" ] + }, + { + "id": "android_online_video_playback", + "priority": 18, + "status": "pending", + "targets": [ + "apps/flutter_forge/android/app/src/main/AndroidManifest.xml", + "apps/flutter_forge/lib/modules/platform/online_video_player", + "apps/flutter_forge/test/modules/platform/online_video_player" + ], + "acceptance": [ + "android_catalog_and_route_admission", + "android_release_internet_permission", + "compact_video_controls", + "android_debug_apk_build", + "android_real_video_playback" + ], + "evidence": [ + "video_player_android is registered in GeneratedPluginRegistrant", + "main Android manifest grants INTERNET for release playback", + "catalog and 320dp widget contracts cover Android entry", + "Android device playback evidence remains required" + ] + }, + { + "id": "android_file_picker_admission", + "priority": 19, + "status": "pending", + "targets": [ + "packages/file_picker_bridge", + "apps/flutter_forge/lib/modules/platform/file_picker", + "apps/flutter_forge/test/modules/platform/file_picker" + ], + "acceptance": [ + "android_catalog_and_route_admission", + "android_file_selector_backend", + "compact_file_picker_layout", + "android_debug_apk_build", + "android_real_device_pick_and_cancel" + ], + "evidence": [ + "file_picker_bridge selects file_selector on Android", + "file_selector_android is registered in GeneratedPluginRegistrant", + "catalog and compact widget contracts cover Android entry", + "Android real-device document provider behavior remains required" + ] } ], "quality_gate": [ diff --git a/apps/flutter_forge/lib/AI_MODULE_INDEX.md b/apps/flutter_forge/lib/AI_MODULE_INDEX.md index 061a910..4ed5b3c 100644 --- a/apps/flutter_forge/lib/AI_MODULE_INDEX.md +++ b/apps/flutter_forge/lib/AI_MODULE_INDEX.md @@ -1,6 +1,6 @@ { "schema": "flutter_forge.agent_docs.module_index.v1", - "registry": "lib/app/router/app_route_table.dart", + "registry": "lib/module_registry/module_manifest.dart", "count": 23, "modules": [ { @@ -14,6 +14,10 @@ "module_registry", "go_router" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/basic/tree_state/AI_ANALYSIS.md" }, { @@ -27,6 +31,10 @@ "module_registry", "go_router" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/basic/microtask/AI_ANALYSIS.md" }, { @@ -39,6 +47,10 @@ "shared_learning", "module_registry" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/basic/debounce_throttle/AI_ANALYSIS.md" }, { @@ -52,6 +64,10 @@ "module_registry", "go_router" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/async/stream_subscription/AI_ANALYSIS.md" }, { @@ -65,7 +81,10 @@ "module_registry", "go_router" ], - "supports_web": false, + "platform_support": { + "native_platforms": "all", + "web": false + }, "analysis": "lib/modules/async/isolate_basic/AI_ANALYSIS.md" }, { @@ -78,7 +97,10 @@ "shared_learning", "module_registry" ], - "supports_web": false, + "platform_support": { + "native_platforms": "all", + "web": false + }, "analysis": "lib/modules/async/isolate_task_manager/AI_ANALYSIS.md" }, { @@ -95,6 +117,10 @@ "module_registry", "go_router" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/state/status_management/AI_ANALYSIS.md" }, { @@ -109,6 +135,10 @@ "provider", "module_registry" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/state/flutter_ioc/AI_ANALYSIS.md" }, { @@ -122,6 +152,10 @@ "shared_preferences", "module_registry" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/state/local_persistence/AI_ANALYSIS.md" }, { @@ -136,9 +170,12 @@ "file_picker_bridge", "module_registry" ], - "supported_platforms": [ - "macOS" - ], + "platform_support": { + "native_platforms": [ + "macOS" + ], + "web": false + }, "analysis": "lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md" }, { @@ -152,6 +189,10 @@ "provider", "module_registry" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/ui/adsorption_line/AI_ANALYSIS.md" }, { @@ -165,6 +206,10 @@ "module_registry", "go_router" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/ui/download_animation/AI_ANALYSIS.md" }, { @@ -179,6 +224,10 @@ "module_registry", "go_router" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/ui/font_picker/AI_ANALYSIS.md" }, { @@ -193,11 +242,14 @@ "vector_math", "module_registry" ], - "supported_platforms": [ - "android", - "macOS", - "windows" - ], + "platform_support": { + "native_platforms": [ + "android", + "macOS", + "windows" + ], + "web": false + }, "analysis": "lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md" }, { @@ -210,6 +262,10 @@ "shared_learning", "module_registry" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md" }, { @@ -223,6 +279,10 @@ "module_registry", "go_router" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md" }, { @@ -236,6 +296,10 @@ "two_dimensional_scrollables", "module_registry" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/popup_table/scroll_table/AI_ANALYSIS.md" }, { @@ -248,6 +312,10 @@ "shared_learning", "module_registry" ], + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md" }, { @@ -262,7 +330,10 @@ "module_registry", "go_router" ], - "supports_web": true, + "platform_support": { + "native_platforms": "all", + "web": true + }, "analysis": "lib/modules/platform/dio_interceptor/AI_ANALYSIS.md" }, { @@ -276,9 +347,10 @@ "device_info_plus", "module_registry" ], - "supported_platforms": [ - "android" - ], + "platform_support": { + "native_platforms": [], + "web": false + }, "analysis": "lib/modules/platform/usb_detector/AI_ANALYSIS.md" }, { @@ -292,11 +364,14 @@ "file_picker_bridge", "module_registry" ], - "supported_platforms": [ - "macOS", - "windows" - ], - "supports_web": true, + "platform_support": { + "native_platforms": [ + "android", + "macOS", + "windows" + ], + "web": true + }, "analysis": "lib/modules/platform/file_picker/AI_ANALYSIS.md" }, { @@ -313,11 +388,14 @@ "video_player_win", "module_registry" ], - "supported_platforms": [ - "macOS", - "windows" - ], - "supports_web": true, + "platform_support": { + "native_platforms": [ + "android", + "macOS", + "windows" + ], + "web": true + }, "analysis": "lib/modules/platform/online_video_player/AI_ANALYSIS.md" }, { @@ -332,11 +410,14 @@ "webview_flutter", "webview_windows" ], - "supported_platforms": [ - "android", - "macOS", - "windows" - ], + "platform_support": { + "native_platforms": [ + "android", + "macOS", + "windows" + ], + "web": false + }, "analysis": "lib/modules/platform/webview/AI_ANALYSIS.md" } ] diff --git a/apps/flutter_forge/lib/app/router/app_route_table.dart b/apps/flutter_forge/lib/app/router/app_route_table.dart index 4f7967c..147b19a 100644 --- a/apps/flutter_forge/lib/app/router/app_route_table.dart +++ b/apps/flutter_forge/lib/app/router/app_route_table.dart @@ -1,396 +1,18 @@ // GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT -import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; import '../module_home_page.dart'; -import '../../module_registry/module_category.dart'; import '../../module_registry/module_catalog_utils.dart'; import '../../module_registry/module_entry.dart'; -import '../../modules/basic/debounce_throttle/module_entry.dart'; -import '../../modules/basic/microtask/module_entry.dart'; -import '../../modules/basic/microtask/module_routes.dart'; -import '../../modules/basic/tree_state/module_entry.dart'; -import '../../modules/basic/tree_state/module_routes.dart'; - -import '../../modules/async/isolate_basic/module_entry.dart'; -import '../../modules/async/isolate_basic/module_routes.dart'; -import '../../modules/async/isolate_task_manager/module_entry.dart'; -import '../../modules/async/stream_subscription/module_entry.dart'; -import '../../modules/async/stream_subscription/module_routes.dart'; - -import '../../modules/state/flutter_ioc/module_entry.dart'; -import '../../modules/state/status_management/module_routes.dart'; - -import '../../modules/state/status_management/module_entry.dart'; -import '../../modules/ui/adsorption_line/module_entry.dart'; -import '../../modules/ui/download_animation/module_entry.dart'; -import '../../modules/ui/download_animation/module_routes.dart'; -import '../../modules/ui/font_picker/module_entry.dart'; -import '../../modules/ui/font_picker/module_routes.dart'; -import '../../modules/ui/flutter_scene_3d/module_entry.dart'; -import '../../modules/ui/gcode_visualizer/module_entry.dart'; -import '../../modules/popup_table/popup_widgets/module_entry.dart'; -import '../../modules/popup_table/popup_list_interaction/module_entry.dart'; -import '../../modules/popup_table/popup_list_interaction/module_routes.dart'; -import '../../modules/popup_table/scroll_table/module_entry.dart'; -import '../../modules/popup_table/overlay_follow_compare/module_entry.dart'; - -import '../../modules/platform/dio_interceptor/module_entry.dart'; -import '../../modules/platform/dio_interceptor/module_routes.dart'; -import '../../modules/platform/file_picker/module_entry.dart'; -import '../../modules/platform/online_video_player/module_entry.dart'; -import '../../modules/platform/usb_detector/module_entry.dart'; -import '../../modules/state/local_persistence/module_entry.dart'; -import '../../modules/platform/webview/module_entry.dart'; - -// ==================== 状态管理子路由(模块内部已定义映射) ==================== - -List _buildStatusManageRoutes() => StatusManagementRoutes - .routes - .entries - .map( - (entry) => GoRoute( - path: entry.key.startsWith('/') ? entry.key.substring(1) : entry.key, - builder: (context, state) => entry.value(context), - ), - ) - .toList(); - -// ==================== 模块注册 ==================== - -final List _modules = [ - // 基础机制 - ModuleEntry( - title: '三棵树与生命周期', - path: '/tree-state', - subtitle: '理解 Widget/Element/RenderObject 的关系与重建机制', - category: ModuleCategory.basic, - difficulty: Difficulty.beginner, - concepts: ['Widget 树', 'Element 树', 'RenderObject', '生命周期'], - estimatedMinutes: 30, - status: ModuleStatus.recommended, - builder: (context) => const TreeStateEntry(), - routes: TreeStateRoutes.routes, - ), - ModuleEntry( - title: '事件循环与微任务', - path: '/microtask', - subtitle: '掌握 Dart 事件循环中微任务队列与事件队列的执行顺序', - category: ModuleCategory.basic, - difficulty: Difficulty.beginner, - concepts: ['Microtask', 'Event Queue', 'async/await', 'Zone'], - estimatedMinutes: 25, - status: ModuleStatus.recommended, - builder: (context) => const MicrotaskEntry(), - routes: MicrotaskRoutes.routes, - ), - ModuleEntry( - title: '防抖与节流', - path: '/debounce-throttle', - subtitle: '对比防抖和节流的执行时序,理解适用场景', - category: ModuleCategory.basic, - difficulty: Difficulty.beginner, - concepts: ['Debouncer', 'Throttle', 'Timer'], - estimatedMinutes: 15, - status: ModuleStatus.ready, - builder: (context) => const DebounceThrottleEntry(), - ), - - // 异步并发 - ModuleEntry( - title: 'Stream 订阅机制', - path: '/stream-subscription', - subtitle: '学习单订阅流与广播流的区别及使用场景', - category: ModuleCategory.async, - difficulty: Difficulty.intermediate, - concepts: ['StreamController', '单订阅', '广播流', 'Stream 变换'], - estimatedMinutes: 30, - status: ModuleStatus.recommended, - builder: (context) => const StreamSubscriptionEntry(), - routes: StreamSubscriptionRoutes.routes, - ), - ModuleEntry( - title: 'Isolate 并发对比', - path: '/isolate-basic', - subtitle: '对比主线程与 Isolate 执行耗时计算对 UI 流畅度的影响', - category: ModuleCategory.async, - difficulty: Difficulty.intermediate, - concepts: ['Isolate', 'SendPort', 'ReceivePort', '性能优化'], - estimatedMinutes: 20, - status: ModuleStatus.ready, - // Safari Web validation showed no usable progress lifecycle for Isolate.spawn. - supportsWeb: false, - builder: (context) => const IsolateTestEntry(), - routes: IsolateTestRoutes.routes, - ), - ModuleEntry( - title: '多任务 Isolate 管理器', - path: '/isolate-stream', - subtitle: '使用 Isolate 并行处理多任务,通过 Stream 实时上报进度', - category: ModuleCategory.async, - difficulty: Difficulty.advanced, - concepts: ['Isolate.spawn', '多任务', '进度上报', '暂停/恢复'], - estimatedMinutes: 35, - status: ModuleStatus.ready, - // Safari Web validation showed tasks stalled at zero progress. - supportsWeb: false, - builder: (context) => const IsolateStreamEntry(), - ), - - // 状态管理 - ModuleEntry( - title: '状态管理演进', - path: '/status-management', - subtitle: '串联 setState、Provider、Riverpod、Bloc,对比不同方案', - category: ModuleCategory.state, - difficulty: Difficulty.intermediate, - concepts: ['Provider', 'Riverpod', 'Bloc', '状态提升', 'FutureProvider'], - estimatedMinutes: 45, - status: ModuleStatus.recommended, - builder: (context) => const StatusManageEntry(), - routes: _buildStatusManageRoutes(), - ), - ModuleEntry( - title: 'Flutter IoC 容器', - path: '/flutter-ioc', - subtitle: '自研 IoC 容器实现,支持单例/瞬态/作用域生命周期', - category: ModuleCategory.state, - difficulty: Difficulty.advanced, - concepts: ['IoC', '依赖注入', '生命周期', '作用域'], - estimatedMinutes: 30, - status: ModuleStatus.ready, - builder: (context) => const FlutterIocEntry(), - ), - ModuleEntry( - title: '本地持久化', - path: '/local-persistence', - subtitle: '使用 shared_preferences 持久化设置项与计数器,理解异步读取与状态恢复', - category: ModuleCategory.state, - difficulty: Difficulty.intermediate, - concepts: ['shared_preferences', '键值存储', '异步读取', '状态恢复', '测试替身'], - estimatedMinutes: 25, - status: ModuleStatus.ready, - builder: (context) => const LocalPersistenceEntry(), - ), - - // UI 与动效 - ModuleEntry( - title: 'G-code 解析与轨迹动画', - path: '/gcode-visualizer', - subtitle: '解析 G-code 指令,绘制刀路轨迹并用动画展示执行过程', - category: ModuleCategory.ui, - difficulty: Difficulty.advanced, - concepts: ['G-code', 'Parser', 'CustomPaint', 'PathMetric', '动画控制'], - estimatedMinutes: 45, - status: ModuleStatus.ready, - // gcode_core v0.2.0-dev.1 validates macOS GPU rendering only. - supportedPlatforms: {TargetPlatform.macOS}, - builder: (context) => const GcodeVisualizerEntry(), - ), - ModuleEntry( - title: '智能吸附线画板', - path: '/adsorption-line', - subtitle: '类似设计工具的对齐吸附功能,学习自定义绘制与手势', - category: ModuleCategory.ui, - difficulty: Difficulty.advanced, - concepts: ['CustomPaint', '手势检测', '吸附算法', '状态管理'], - estimatedMinutes: 40, - status: ModuleStatus.ready, - builder: (context) => const AdsorptionLineEntry(), - ), - ModuleEntry( - title: '下载飞入动效', - path: '/download-animation', - subtitle: '三种实现方式对比:Custom View / CustomPaint / Overlay', - category: ModuleCategory.ui, - difficulty: Difficulty.intermediate, - concepts: ['Tween 动画', 'CustomPaint', 'OverlayEntry', '动画配置'], - estimatedMinutes: 30, - status: ModuleStatus.ready, - builder: (context) => const DownloadAnimationEntry(), - routes: DownloadAnimationRoutes.routes, - ), - ModuleEntry( - title: '字体选择器', - path: '/font-picker', - subtitle: '命名列表中直观对比不同字体族与字重样式,并通过文件选择器加载本地字体', - category: ModuleCategory.ui, - difficulty: Difficulty.intermediate, - concepts: [ - 'fontFamily', - 'TextStyle', - '字重', - '字距', - '字体 fallback', - '平台字体', - 'FontLoader', - '中台复用', - ], - estimatedMinutes: 30, - status: ModuleStatus.ready, - builder: (context) => const FontPickerEntry(), - routes: FontPickerRoutes.routes, - ), - ModuleEntry( - title: '教学型 3D 查看器', - path: '/flutter-scene-3d', - subtitle: '用轨道相机、部件选取与聚焦反馈理解 3D 查看器交互', - category: ModuleCategory.ui, - difficulty: Difficulty.intermediate, - concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], - estimatedMinutes: 35, - status: ModuleStatus.ready, - // Android is view-only; Windows and Android host evidence remains pending. - supportedPlatforms: { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }, - builder: (context) => const FlutterScene3dEntry(), - ), - - // 弹窗与列表 - ModuleEntry( - title: '弹窗合集', - path: '/popup-widgets', - subtitle: '全面展示 Flutter 中的对话框、底部抽屉、菜单等弹窗类型', - category: ModuleCategory.popupTable, - difficulty: Difficulty.beginner, - concepts: ['AlertDialog', 'BottomSheet', 'Overlay', 'ContextMenu'], - estimatedMinutes: 20, - status: ModuleStatus.ready, - builder: (context) => const PopWidgetEntry(), - ), - ModuleEntry( - title: '弹窗与列表交互', - path: '/popup-list-interaction', - subtitle: 'Flutter 弹窗组件与二维滚动表格的综合演示', - category: ModuleCategory.popupTable, - difficulty: Difficulty.beginner, - concepts: ['Dialog', 'BottomSheet', 'Overlay', 'TableView', '二维滚动'], - estimatedMinutes: 25, - status: ModuleStatus.ready, - builder: (context) => const PopupListInteractionEntry(), - routes: PopupListInteractionRoutes.routes, - ), - ModuleEntry( - title: '二维滚动表格', - path: '/scroll-table', - subtitle: '使用 two_dimensional_scrollables 实现固定表头的表格', - category: ModuleCategory.popupTable, - difficulty: Difficulty.beginner, - concepts: ['TableView', '固定表头', '二维滚动'], - estimatedMinutes: 15, - status: ModuleStatus.ready, - builder: (context) => const ScrollTableEntry(), - ), - ModuleEntry( - title: 'Overlay 跟随方案对照组', - path: '/overlay-compare', - subtitle: '对比 CompositedTransformFollower 与 markNeedsBuild 两种浮层跟随方案', - category: ModuleCategory.popupTable, - difficulty: Difficulty.intermediate, - concepts: [ - 'Overlay', - 'LayerLink', - 'CompositedTransformFollower', - 'markNeedsBuild', - 'ScrollController', - ], - estimatedMinutes: 30, - status: ModuleStatus.ready, - builder: (context) => const OverlayFollowCompareEntry(), - ), - - // 网络与平台 - ModuleEntry( - title: 'Dio 拦截器链路', - path: '/dio-interceptor', - subtitle: 'Auth/Error/Retry/Log 拦截器 + 本地 Mock Server 实战', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: ['Dio', '拦截器', 'Token 刷新', 'Mock Server', '重试机制'], - estimatedMinutes: 35, - status: ModuleStatus.ready, - // Web uses an in-memory Dio adapter; native hosts keep the localhost mock server. - supportsWeb: true, - builder: (context) => const InterceptorTestEntry(), - routes: InterceptorTestRoutes.routes, - ), - ModuleEntry( - title: 'USB 设备检测', - path: '/usb-detector', - subtitle: 'Android USB 设备检测与状态监控', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: ['Android USB', 'MethodChannel', 'Stream 广播', '设备扫描'], - estimatedMinutes: 25, - status: ModuleStatus.ready, - // Android-only: Windows 原生插件已移除,目录显示不可用、路由不注册 - supportedPlatforms: {TargetPlatform.android}, - builder: (context) => const UsbDetectorEntry(), - ), - ModuleEntry( - title: '文件选择器', - path: '/file-picker', - subtitle: '复用 file_picker_bridge 中台能力,演示扩展过滤、取消分支与平台差异', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: ['FilePickerService', 'MethodChannel', '平台桥接', '扩展名过滤', '取消分支'], - estimatedMinutes: 20, - status: ModuleStatus.ready, - // Web accepts one user-selected file and exposes only its filename. - supportsWeb: true, - supportedPlatforms: {TargetPlatform.macOS, TargetPlatform.windows}, - builder: (context) => const FilePickerEntry(), - ), - ModuleEntry( - title: '在线视频播放', - path: '/online-video-player', - subtitle: '使用 video_player 播放在线 HTTP 视频流并操控播放参数', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: [ - 'video_player', - '平台播放器', - 'HTTP 流', - '播放控制', - '倍速', - 'Controller 生命周期', - ], - estimatedMinutes: 35, - status: ModuleStatus.ready, - // Web uses a same-origin media asset and starts playback from a user gesture. - supportsWeb: true, - supportedPlatforms: {TargetPlatform.macOS, TargetPlatform.windows}, - builder: (context) => const OnlineVideoPlayerEntry(), - ), - ModuleEntry( - title: '网页容器与跨平台导航', - path: '/webview', - subtitle: '学习 Android、macOS、Windows 网页导航与生命周期', - category: ModuleCategory.platform, - difficulty: Difficulty.intermediate, - concepts: ['WebView', 'WebView2', '加载进度', '生命周期'], - estimatedMinutes: 30, - status: ModuleStatus.ready, - // Android/macOS use webview_flutter; Windows uses WebView2. - supportedPlatforms: { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }, - builder: (context) => const WebViewEntry(), - ), -]; +import '../../module_registry/module_manifest.dart'; final List _routes = [ GoRoute( path: '/', - builder: (context, state) => ModuleHomePage(modules: _modules), + builder: (context, state) => ModuleHomePage(modules: moduleManifest), ), - for (final module in availableModules(_modules)) + for (final module in availableModules(moduleManifest)) GoRoute( path: module.path, builder: (context, state) => module.builder(context), @@ -400,5 +22,5 @@ final List _routes = [ class AppRouteTable { static List get routes => _routes; - static List get modules => _modules; + static List get modules => moduleManifest; } diff --git a/apps/flutter_forge/lib/module_registry/module_entry.dart b/apps/flutter_forge/lib/module_registry/module_entry.dart index a1e5536..f9fa811 100644 --- a/apps/flutter_forge/lib/module_registry/module_entry.dart +++ b/apps/flutter_forge/lib/module_registry/module_entry.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'module_category.dart'; +import 'module_platform_support.dart'; class ModuleEntry { ModuleEntry({ @@ -15,8 +16,7 @@ class ModuleEntry { required this.status, required this.builder, this.routes = const [], - this.supportedPlatforms, - this.supportsWeb, + required this.platformSupport, }); final String title; @@ -30,21 +30,9 @@ class ModuleEntry { final WidgetBuilder builder; final List routes; - /// Null means the module is platform-neutral and supported by default. - /// A non-null set restricts availability to the listed host platforms. - final Set? supportedPlatforms; - - /// Overrides Web availability independently from native host support. - /// - /// Platform-neutral modules support Web by default. Set this to false when - /// an otherwise platform-neutral module depends on a native-only runtime - /// capability. A module with an explicit [supportedPlatforms] set is - /// unavailable on Web unless this is explicitly true. - final bool? supportsWeb; + final ModulePlatformSupport platformSupport; bool isSupportedOn(TargetPlatform platform, {required bool isWeb}) { - if (isWeb) return supportsWeb ?? supportedPlatforms == null; - final platforms = supportedPlatforms; - return platforms == null || platforms.contains(platform); + return platformSupport.supports(platform, isWeb: isWeb); } } diff --git a/apps/flutter_forge/lib/module_registry/module_manifest.dart b/apps/flutter_forge/lib/module_registry/module_manifest.dart new file mode 100644 index 0000000..91bf8de --- /dev/null +++ b/apps/flutter_forge/lib/module_registry/module_manifest.dart @@ -0,0 +1,425 @@ +// GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT + +import 'package:flutter/foundation.dart'; +import 'package:go_router/go_router.dart'; + +import 'module_category.dart'; +import 'module_entry.dart'; +import 'module_platform_support.dart'; +import '../modules/basic/debounce_throttle/module_entry.dart'; +import '../modules/basic/microtask/module_entry.dart'; +import '../modules/basic/microtask/module_routes.dart'; +import '../modules/basic/tree_state/module_entry.dart'; +import '../modules/basic/tree_state/module_routes.dart'; + +import '../modules/async/isolate_basic/module_entry.dart'; +import '../modules/async/isolate_basic/module_routes.dart'; +import '../modules/async/isolate_task_manager/module_entry.dart'; +import '../modules/async/stream_subscription/module_entry.dart'; +import '../modules/async/stream_subscription/module_routes.dart'; + +import '../modules/state/flutter_ioc/module_entry.dart'; +import '../modules/state/status_management/module_routes.dart'; + +import '../modules/state/status_management/module_entry.dart'; +import '../modules/ui/adsorption_line/module_entry.dart'; +import '../modules/ui/download_animation/module_entry.dart'; +import '../modules/ui/download_animation/module_routes.dart'; +import '../modules/ui/font_picker/module_entry.dart'; +import '../modules/ui/font_picker/module_routes.dart'; +import '../modules/ui/flutter_scene_3d/module_entry.dart'; +import '../modules/ui/gcode_visualizer/module_entry.dart'; +import '../modules/popup_table/popup_widgets/module_entry.dart'; +import '../modules/popup_table/popup_list_interaction/module_entry.dart'; +import '../modules/popup_table/popup_list_interaction/module_routes.dart'; +import '../modules/popup_table/scroll_table/module_entry.dart'; +import '../modules/popup_table/overlay_follow_compare/module_entry.dart'; + +import '../modules/platform/dio_interceptor/module_entry.dart'; +import '../modules/platform/dio_interceptor/module_routes.dart'; +import '../modules/platform/file_picker/module_entry.dart'; +import '../modules/platform/online_video_player/module_entry.dart'; +import '../modules/platform/usb_detector/module_entry.dart'; +import '../modules/state/local_persistence/module_entry.dart'; +import '../modules/platform/webview/module_entry.dart'; + +// ==================== 状态管理子路由(模块内部已定义映射) ==================== + +List _buildStatusManageRoutes() => StatusManagementRoutes + .routes + .entries + .map( + (entry) => GoRoute( + path: entry.key.startsWith('/') ? entry.key.substring(1) : entry.key, + builder: (context, state) => entry.value(context), + ), + ) + .toList(); + +// ==================== 模块注册 ==================== + +final List moduleManifest = [ + // 基础机制 + ModuleEntry( + title: '三棵树与生命周期', + path: '/tree-state', + subtitle: '理解 Widget/Element/RenderObject 的关系与重建机制', + category: ModuleCategory.basic, + difficulty: Difficulty.beginner, + concepts: ['Widget 树', 'Element 树', 'RenderObject', '生命周期'], + estimatedMinutes: 30, + status: ModuleStatus.recommended, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const TreeStateEntry(), + routes: TreeStateRoutes.routes, + ), + ModuleEntry( + title: '事件循环与微任务', + path: '/microtask', + subtitle: '掌握 Dart 事件循环中微任务队列与事件队列的执行顺序', + category: ModuleCategory.basic, + difficulty: Difficulty.beginner, + concepts: ['Microtask', 'Event Queue', 'async/await', 'Zone'], + estimatedMinutes: 25, + status: ModuleStatus.recommended, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const MicrotaskEntry(), + routes: MicrotaskRoutes.routes, + ), + ModuleEntry( + title: '防抖与节流', + path: '/debounce-throttle', + subtitle: '对比防抖和节流的执行时序,理解适用场景', + category: ModuleCategory.basic, + difficulty: Difficulty.beginner, + concepts: ['Debouncer', 'Throttle', 'Timer'], + estimatedMinutes: 15, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const DebounceThrottleEntry(), + ), + + // 异步并发 + ModuleEntry( + title: 'Stream 订阅机制', + path: '/stream-subscription', + subtitle: '学习单订阅流与广播流的区别及使用场景', + category: ModuleCategory.async, + difficulty: Difficulty.intermediate, + concepts: ['StreamController', '单订阅', '广播流', 'Stream 变换'], + estimatedMinutes: 30, + status: ModuleStatus.recommended, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const StreamSubscriptionEntry(), + routes: StreamSubscriptionRoutes.routes, + ), + ModuleEntry( + title: 'Isolate 并发对比', + path: '/isolate-basic', + subtitle: '对比主线程与 Isolate 执行耗时计算对 UI 流畅度的影响', + category: ModuleCategory.async, + difficulty: Difficulty.intermediate, + concepts: ['Isolate', 'SendPort', 'ReceivePort', '性能优化'], + estimatedMinutes: 20, + status: ModuleStatus.ready, + // Safari Web validation showed no usable progress lifecycle for Isolate.spawn. + platformSupport: const ModulePlatformSupport(web: false), + builder: (context) => const IsolateTestEntry(), + routes: IsolateTestRoutes.routes, + ), + ModuleEntry( + title: '多任务 Isolate 管理器', + path: '/isolate-stream', + subtitle: '使用 Isolate 并行处理多任务,通过 Stream 实时上报进度', + category: ModuleCategory.async, + difficulty: Difficulty.advanced, + concepts: ['Isolate.spawn', '多任务', '进度上报', '暂停/恢复'], + estimatedMinutes: 35, + status: ModuleStatus.ready, + // Safari Web validation showed tasks stalled at zero progress. + platformSupport: const ModulePlatformSupport(web: false), + builder: (context) => const IsolateStreamEntry(), + ), + + // 状态管理 + ModuleEntry( + title: '状态管理演进', + path: '/status-management', + subtitle: '串联 setState、Provider、Riverpod、Bloc,对比不同方案', + category: ModuleCategory.state, + difficulty: Difficulty.intermediate, + concepts: ['Provider', 'Riverpod', 'Bloc', '状态提升', 'FutureProvider'], + estimatedMinutes: 45, + status: ModuleStatus.recommended, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const StatusManageEntry(), + routes: _buildStatusManageRoutes(), + ), + ModuleEntry( + title: 'Flutter IoC 容器', + path: '/flutter-ioc', + subtitle: '自研 IoC 容器实现,支持单例/瞬态/作用域生命周期', + category: ModuleCategory.state, + difficulty: Difficulty.advanced, + concepts: ['IoC', '依赖注入', '生命周期', '作用域'], + estimatedMinutes: 30, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const FlutterIocEntry(), + ), + ModuleEntry( + title: '本地持久化', + path: '/local-persistence', + subtitle: '使用 shared_preferences 持久化设置项与计数器,理解异步读取与状态恢复', + category: ModuleCategory.state, + difficulty: Difficulty.intermediate, + concepts: ['shared_preferences', '键值存储', '异步读取', '状态恢复', '测试替身'], + estimatedMinutes: 25, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const LocalPersistenceEntry(), + ), + + // UI 与动效 + ModuleEntry( + title: 'G-code 解析与轨迹动画', + path: '/gcode-visualizer', + subtitle: '解析 G-code 指令,绘制刀路轨迹并用动画展示执行过程', + category: ModuleCategory.ui, + difficulty: Difficulty.advanced, + concepts: ['G-code', 'Parser', 'CustomPaint', 'PathMetric', '动画控制'], + estimatedMinutes: 45, + status: ModuleStatus.ready, + // gcode_core v0.2.0-dev.1 validates macOS GPU rendering only. + platformSupport: const ModulePlatformSupport( + nativePlatforms: {TargetPlatform.macOS}, + web: false, + ), + builder: (context) => const GcodeVisualizerEntry(), + ), + ModuleEntry( + title: '智能吸附线画板', + path: '/adsorption-line', + subtitle: '类似设计工具的对齐吸附功能,学习自定义绘制与手势', + category: ModuleCategory.ui, + difficulty: Difficulty.advanced, + concepts: ['CustomPaint', '手势检测', '吸附算法', '状态管理'], + estimatedMinutes: 40, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const AdsorptionLineEntry(), + ), + ModuleEntry( + title: '下载飞入动效', + path: '/download-animation', + subtitle: '三种实现方式对比:Custom View / CustomPaint / Overlay', + category: ModuleCategory.ui, + difficulty: Difficulty.intermediate, + concepts: ['Tween 动画', 'CustomPaint', 'OverlayEntry', '动画配置'], + estimatedMinutes: 30, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const DownloadAnimationEntry(), + routes: DownloadAnimationRoutes.routes, + ), + ModuleEntry( + title: '字体选择器', + path: '/font-picker', + subtitle: '命名列表中直观对比不同字体族与字重样式,并通过文件选择器加载本地字体', + category: ModuleCategory.ui, + difficulty: Difficulty.intermediate, + concepts: [ + 'fontFamily', + 'TextStyle', + '字重', + '字距', + '字体 fallback', + '平台字体', + 'FontLoader', + '中台复用', + ], + estimatedMinutes: 30, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const FontPickerEntry(), + routes: FontPickerRoutes.routes, + ), + ModuleEntry( + title: '教学型 3D 查看器', + path: '/flutter-scene-3d', + subtitle: '用轨道相机、部件选取与聚焦反馈理解 3D 查看器交互', + category: ModuleCategory.ui, + difficulty: Difficulty.intermediate, + concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], + estimatedMinutes: 35, + status: ModuleStatus.ready, + // Android is view-only; Windows and Android host evidence remains pending. + platformSupport: const ModulePlatformSupport( + nativePlatforms: { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }, + web: false, + ), + builder: (context) => const FlutterScene3dEntry(), + ), + + // 弹窗与列表 + ModuleEntry( + title: '弹窗合集', + path: '/popup-widgets', + subtitle: '全面展示 Flutter 中的对话框、底部抽屉、菜单等弹窗类型', + category: ModuleCategory.popupTable, + difficulty: Difficulty.beginner, + concepts: ['AlertDialog', 'BottomSheet', 'Overlay', 'ContextMenu'], + estimatedMinutes: 20, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const PopWidgetEntry(), + ), + ModuleEntry( + title: '弹窗与列表交互', + path: '/popup-list-interaction', + subtitle: 'Flutter 弹窗组件与二维滚动表格的综合演示', + category: ModuleCategory.popupTable, + difficulty: Difficulty.beginner, + concepts: ['Dialog', 'BottomSheet', 'Overlay', 'TableView', '二维滚动'], + estimatedMinutes: 25, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const PopupListInteractionEntry(), + routes: PopupListInteractionRoutes.routes, + ), + ModuleEntry( + title: '二维滚动表格', + path: '/scroll-table', + subtitle: '使用 two_dimensional_scrollables 实现固定表头的表格', + category: ModuleCategory.popupTable, + difficulty: Difficulty.beginner, + concepts: ['TableView', '固定表头', '二维滚动'], + estimatedMinutes: 15, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const ScrollTableEntry(), + ), + ModuleEntry( + title: 'Overlay 跟随方案对照组', + path: '/overlay-compare', + subtitle: '对比 CompositedTransformFollower 与 markNeedsBuild 两种浮层跟随方案', + category: ModuleCategory.popupTable, + difficulty: Difficulty.intermediate, + concepts: [ + 'Overlay', + 'LayerLink', + 'CompositedTransformFollower', + 'markNeedsBuild', + 'ScrollController', + ], + estimatedMinutes: 30, + status: ModuleStatus.ready, + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const OverlayFollowCompareEntry(), + ), + + // 网络与平台 + ModuleEntry( + title: 'Dio 拦截器链路', + path: '/dio-interceptor', + subtitle: 'Auth/Error/Retry/Log 拦截器 + 本地 Mock Server 实战', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['Dio', '拦截器', 'Token 刷新', 'Mock Server', '重试机制'], + estimatedMinutes: 35, + status: ModuleStatus.ready, + // Web uses an in-memory Dio adapter; native hosts keep the localhost mock server. + platformSupport: const ModulePlatformSupport(web: true), + builder: (context) => const InterceptorTestEntry(), + routes: InterceptorTestRoutes.routes, + ), + ModuleEntry( + title: 'USB 设备检测', + path: '/usb-detector', + subtitle: 'Android USB 设备检测与状态监控', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['Android USB', 'MethodChannel', 'Stream 广播', '设备扫描'], + estimatedMinutes: 25, + status: ModuleStatus.ready, + // Disabled on every host until the module is reframed as a concrete OTG workflow. + platformSupport: const ModulePlatformSupport( + nativePlatforms: {}, + web: false, + ), + builder: (context) => const UsbDetectorEntry(), + ), + ModuleEntry( + title: '文件选择器', + path: '/file-picker', + subtitle: '复用 file_picker_bridge 中台能力,演示扩展过滤、取消分支与平台差异', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['FilePickerService', 'MethodChannel', '平台桥接', '扩展名过滤', '取消分支'], + estimatedMinutes: 20, + status: ModuleStatus.ready, + // Web accepts one user-selected file and exposes only its filename. + // Android uses file_selector and requires real-device provider validation. + platformSupport: const ModulePlatformSupport( + nativePlatforms: { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }, + web: true, + ), + builder: (context) => const FilePickerEntry(), + ), + ModuleEntry( + title: '在线视频播放', + path: '/online-video-player', + subtitle: '使用 video_player 播放在线 HTTP 视频流并操控播放参数', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: [ + 'video_player', + '平台播放器', + 'HTTP 流', + '播放控制', + '倍速', + 'Controller 生命周期', + ], + estimatedMinutes: 35, + status: ModuleStatus.ready, + // Web uses a same-origin media asset and starts playback from a user gesture. + // Android/macOS use official video_player backends; Windows uses video_player_win. + platformSupport: const ModulePlatformSupport( + nativePlatforms: { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }, + web: true, + ), + builder: (context) => const OnlineVideoPlayerEntry(), + ), + ModuleEntry( + title: '网页容器与跨平台导航', + path: '/webview', + subtitle: '学习 Android、macOS、Windows 网页导航与生命周期', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['WebView', 'WebView2', '加载进度', '生命周期'], + estimatedMinutes: 30, + status: ModuleStatus.ready, + // Android/macOS use webview_flutter; Windows uses WebView2. + platformSupport: const ModulePlatformSupport( + nativePlatforms: { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }, + web: false, + ), + builder: (context) => const WebViewEntry(), + ), +]; diff --git a/apps/flutter_forge/lib/module_registry/module_platform_support.dart b/apps/flutter_forge/lib/module_registry/module_platform_support.dart new file mode 100644 index 0000000..ba3a167 --- /dev/null +++ b/apps/flutter_forge/lib/module_registry/module_platform_support.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; + +/// Declares where a module is available without coupling routing to a host. +class ModulePlatformSupport { + const ModulePlatformSupport({this.nativePlatforms, this.web = false}); + + /// A null set means every native Flutter host is supported. + final Set? nativePlatforms; + + /// Whether the browser implementation is supported. + final bool web; + + bool supports(TargetPlatform platform, {required bool isWeb}) { + if (isWeb) return web; + final platforms = nativePlatforms; + return platforms == null || platforms.contains(platform); + } +} diff --git a/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md index 7c9bb6b..4058d4d 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md @@ -10,7 +10,10 @@ }, "route": "/isolate-basic", "category": "async", - "supports_web": false, + "platform_support": { + "native_platforms": "all", + "web": false + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md index 8ead482..cf411f2 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md @@ -10,7 +10,10 @@ }, "route": "/isolate-stream", "category": "async", - "supports_web": false, + "platform_support": { + "native_platforms": "all", + "web": false + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md index aaa44dc..23c710d 100644 --- a/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/stream-subscription", "category": "async", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md index 0acab03..9ea2aa0 100644 --- a/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/debounce-throttle", "category": "basic", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md index 1aa12a4..e01fe76 100644 --- a/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/microtask", "category": "basic", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md index 270f77e..c20b094 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/tree-state", "category": "basic", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md index 9b289c2..8c72b23 100644 --- a/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md @@ -10,7 +10,10 @@ }, "route": "/dio-interceptor", "category": "platform", - "supports_web": true, + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md index 382eb92..4c09ff9 100644 --- a/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md @@ -10,11 +10,14 @@ }, "route": "/file-picker", "category": "platform", - "supported_platforms": [ - "macOS", - "windows" - ], - "supports_web": true, + "platform_support": { + "native_platforms": [ + "android", + "macOS", + "windows" + ], + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md index e5e89b9..6c0560e 100644 --- a/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md @@ -10,11 +10,14 @@ }, "route": "/online-video-player", "category": "platform", - "supported_platforms": [ - "macOS", - "windows" - ], - "supports_web": true, + "platform_support": { + "native_platforms": [ + "android", + "macOS", + "windows" + ], + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md index fd98ec6..6fe2925 100644 --- a/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md @@ -10,9 +10,10 @@ }, "route": "/usb-detector", "category": "platform", - "supported_platforms": [ - "android" - ], + "platform_support": { + "native_platforms": [], + "web": false + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md index 6a00bb3..c1add7c 100644 --- a/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md @@ -10,11 +10,14 @@ }, "route": "/webview", "category": "platform", - "supported_platforms": [ - "android", - "macOS", - "windows" - ], + "platform_support": { + "native_platforms": [ + "android", + "macOS", + "windows" + ], + "web": false + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md index 7e57afa..77db7d6 100644 --- a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/overlay-compare", "category": "popup_table", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md index f94653c..a1e201b 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/popup-list-interaction", "category": "popup_table", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md index ff5903e..64af072 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/popup-widgets", "category": "popup_table", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md index 4fddfaa..70eb212 100644 --- a/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/scroll-table", "category": "popup_table", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md index 2df4a04..b8326ce 100644 --- a/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/flutter-ioc", "category": "state", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md index 003f27d..738fde0 100644 --- a/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/local-persistence", "category": "state", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md index b8226d2..4c255b7 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/status-management", "category": "state", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_routes.dart", diff --git a/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md index d02bd55..317812f 100644 --- a/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/adsorption-line", "category": "ui", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "pages", diff --git a/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md index bb94f00..e5a13d6 100644 --- a/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/download-animation", "category": "ui", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md index 0bbb764..528b72f 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md @@ -10,11 +10,14 @@ }, "route": "/flutter-scene-3d", "category": "ui", - "supported_platforms": [ - "android", - "macOS", - "windows" - ], + "platform_support": { + "native_platforms": [ + "android", + "macOS", + "windows" + ], + "web": false + }, "entrypoints": [ "module_entry.dart", "module_root.dart" diff --git a/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md index 4fbf423..24ce93f 100644 --- a/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md @@ -10,6 +10,10 @@ }, "route": "/font-picker", "category": "ui", + "platform_support": { + "native_platforms": "all", + "web": true + }, "entrypoints": [ "module_entry.dart", "module_root.dart", diff --git a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md index 75e0b5c..4f991e9 100644 --- a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md @@ -10,9 +10,12 @@ }, "route": "/gcode-visualizer", "category": "ui", - "supported_platforms": [ - "macOS" - ], + "platform_support": { + "native_platforms": [ + "macOS" + ], + "web": false + }, "entrypoints": [ "module_entry.dart", "pages", diff --git a/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart b/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart index 33dc2ea..eafa199 100644 --- a/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart +++ b/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart @@ -2,10 +2,32 @@ import 'package:file_picker_bridge/file_picker_bridge.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/modules/platform/file_picker/module_root.dart'; void main() { + test('catalog admits file picker on Android, macOS, and Windows', () { + final module = AppRouteTable.modules.singleWhere( + (item) => item.path == '/file-picker', + ); + + expect(module.platformSupport.nativePlatforms, { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }); + for (final platform in [ + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + ]) { + expect(isModuleAvailable(module, platform, false), isTrue); + } + expect(isModuleAvailable(module, TargetPlatform.iOS, false), isFalse); + }); + testWidgets('picked file shows its name and path', (tester) async { final picker = FakeFilePickerService( result: const PickedFile(path: '/tmp/a.gcode', name: 'a.gcode'), @@ -66,6 +88,18 @@ void main() { await tester.pump(); expect(picker.allowedExtensions, contains('.txt')); }); + + testWidgets('file picker fits a compact Android viewport', (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpModule(tester, FakeFilePickerService()); + + expect(find.byKey(const Key('pick-file-button')), findsOneWidget); + expect(tester.takeException(), isNull); + }); } Future _pumpModule(WidgetTester tester, FilePickerService picker) async { diff --git a/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart b/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart index b96563e..14cba0f 100644 --- a/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart +++ b/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart @@ -1,4 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/modules/platform/online_video_player/module_root.dart'; import 'package:flutter_forge_app/modules/platform/online_video_player/state/video_player_adapter.dart'; @@ -6,6 +9,35 @@ import 'package:flutter_forge_app/modules/platform/online_video_player/widgets/v import 'package:video_player/video_player.dart'; void main() { + test('catalog admits online video on Android, macOS, and Windows', () { + final module = AppRouteTable.modules.singleWhere( + (item) => item.path == '/online-video-player', + ); + + expect(module.platformSupport.nativePlatforms, { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }); + for (final platform in [ + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + ]) { + expect(isModuleAvailable(module, platform, false), isTrue); + } + expect( + AppRouteTable.routes.where((route) => route.path == module.path), + { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }.contains(defaultTargetPlatform) + ? isNotEmpty + : isEmpty, + ); + }); + testWidgets('controls render playback, seek, rate and volume controls', ( tester, ) async { @@ -60,6 +92,22 @@ void main() { expect(find.text('视频加载失败,请检查网络后重试'), findsOneWidget); expect(adapter.openCount, 1); }); + + testWidgets('video controls fit a compact Android viewport', (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final adapter = FakeVideoPlayerAdapter(); + + await tester.pumpWidget( + MaterialApp( + home: MyHomePage(title: '在线视频播放', adapter: adapter), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('video-play-pause')), findsOneWidget); + }); } class FakeVideoPlayerAdapter implements VideoPlayerAdapter { diff --git a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart index 2750478..a0dd1b3 100644 --- a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart +++ b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/modules/platform/usb_detector/module_entry.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -7,6 +9,21 @@ void main() { expect(const UsbDetectorEntry(), isA()); }); + test('catalog keeps USB visible but blocks every platform route', () { + final module = AppRouteTable.modules.singleWhere( + (item) => item.path == '/usb-detector', + ); + + expect(module.platformSupport.nativePlatforms, isEmpty); + for (final platform in TargetPlatform.values) { + expect(isModuleAvailable(module, platform, false), isFalse); + } + expect( + AppRouteTable.routes.where((route) => route.path == module.path), + isEmpty, + ); + }); + testWidgets('USB detector fits a compact Android viewport', (tester) async { await tester.binding.setSurfaceSize(const Size(320, 640)); addTearDown(() => tester.binding.setSurfaceSize(null)); diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart index c947f24..46126dd 100644 --- a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart @@ -249,7 +249,7 @@ void main() { ); expect(module.status, ModuleStatus.ready); - expect(module.supportedPlatforms, { + expect(module.platformSupport.nativePlatforms, { TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.android, diff --git a/apps/flutter_forge/test/shared/module_catalog_utils_test.dart b/apps/flutter_forge/test/shared/module_catalog_utils_test.dart index 9b5363b..ef318d4 100644 --- a/apps/flutter_forge/test/shared/module_catalog_utils_test.dart +++ b/apps/flutter_forge/test/shared/module_catalog_utils_test.dart @@ -4,14 +4,15 @@ import 'package:go_router/go_router.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; import 'package:flutter_forge_app/module_registry/module_entry.dart'; +import 'package:flutter_forge_app/module_registry/module_platform_support.dart'; void main() { ModuleEntry createModule({ required String path, required ModuleCategory category, List routes = const [], - Set? supportedPlatforms, - bool? supportsWeb, + Set? nativePlatforms, + bool web = true, }) { return ModuleEntry( title: path, @@ -24,8 +25,10 @@ void main() { status: ModuleStatus.ready, builder: (_) => const SizedBox.shrink(), routes: routes, - supportedPlatforms: supportedPlatforms, - supportsWeb: supportsWeb, + platformSupport: ModulePlatformSupport( + nativePlatforms: nativePlatforms, + web: web, + ), ); } @@ -76,7 +79,8 @@ void main() { final module = createModule( path: '/macos-only', category: ModuleCategory.platform, - supportedPlatforms: {TargetPlatform.macOS}, + nativePlatforms: {TargetPlatform.macOS}, + web: false, ); expect(isModuleAvailable(module, TargetPlatform.macOS, true), isFalse); @@ -86,8 +90,7 @@ void main() { final module = createModule( path: '/web-capable', category: ModuleCategory.platform, - supportedPlatforms: {TargetPlatform.macOS}, - supportsWeb: true, + nativePlatforms: {TargetPlatform.macOS}, ); expect(isModuleAvailable(module, TargetPlatform.windows, true), isTrue); @@ -97,7 +100,8 @@ void main() { final module = createModule( path: '/macos-only', category: ModuleCategory.platform, - supportedPlatforms: {TargetPlatform.macOS}, + nativePlatforms: {TargetPlatform.macOS}, + web: false, ); expect(isModuleAvailable(module, TargetPlatform.macOS, false), isTrue); @@ -109,7 +113,8 @@ void main() { final module = createModule( path: '/windows-only', category: ModuleCategory.platform, - supportedPlatforms: {TargetPlatform.windows}, + nativePlatforms: {TargetPlatform.windows}, + web: false, ); expect(buildCategoryRoutes([module]), isEmpty); diff --git a/apps/flutter_forge/test/shared/web_compatibility_test.dart b/apps/flutter_forge/test/shared/web_compatibility_test.dart index d2b89b8..62e909b 100644 --- a/apps/flutter_forge/test/shared/web_compatibility_test.dart +++ b/apps/flutter_forge/test/shared/web_compatibility_test.dart @@ -37,7 +37,7 @@ void main() { (entry) => entry.path == path, ); expect(isModuleAvailable(module), isTrue, reason: path); - expect(module.supportsWeb, isTrue, reason: path); + expect(module.platformSupport.web, isTrue, reason: path); } }); @@ -65,8 +65,8 @@ void main() { ]) { expect(isModuleAvailable(module, platform, false), isTrue); } - expect(module.supportedPlatforms, isNull); - expect(module.supportsWeb, isTrue); + expect(module.platformSupport.nativePlatforms, isNull); + expect(module.platformSupport.web, isTrue); }); testWidgets('Web entries render safe compatibility states', (tester) async { diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index 6108d6b..f517ae3 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -290,8 +290,8 @@ const modules = [ concepts: ['Android USB', 'MethodChannel', 'Stream 广播', '设备扫描'], estimatedMinutes: 25, entry: 'UsbDetectorEntry', - supportedPlatforms: ['android'], - supportedPlatformsComment: '// Android-only: Windows 原生插件已移除,目录显示不可用、路由不注册', + supportedPlatforms: [], + supportedPlatformsComment: '// Disabled on every host until the module is reframed as a concrete OTG workflow.', }, { category: 'platform', @@ -305,7 +305,8 @@ const modules = [ concepts: ['FilePickerService', 'MethodChannel', '平台桥接', '扩展名过滤', '取消分支'], estimatedMinutes: 20, entry: 'FilePickerEntry', - supportedPlatforms: ['macOS', 'windows'], + supportedPlatforms: ['android', 'macOS', 'windows'], + supportedPlatformsComment: '// Android uses file_selector and requires real-device provider validation.', supportsWeb: true, supportsWebComment: '// Web accepts one user-selected file and exposes only its filename.', }, @@ -321,7 +322,8 @@ const modules = [ concepts: ['video_player', '平台播放器', 'HTTP 流', '播放控制', '倍速', 'Controller 生命周期'], estimatedMinutes: 35, entry: 'OnlineVideoPlayerEntry', - supportedPlatforms: ['macOS', 'windows'], + supportedPlatforms: ['android', 'macOS', 'windows'], + supportedPlatformsComment: '// Android/macOS use official video_player backends; Windows uses video_player_win.', supportsWeb: true, supportsWebComment: '// Web uses a same-origin media asset and starts playback from a user gesture.', }, @@ -531,7 +533,7 @@ function writeSchema() { }, module_contract_policy: { keep_for_module_rule: true, - content: ['route', 'category', 'status', 'supported_platforms', 'supports_web', 'entrypoints', 'analysis_parent'], + content: ['route', 'category', 'status', 'platform_support', 'entrypoints', 'analysis_parent'], avoid: ['class_descriptions', 'long_file_inventory', 'natural_language_notes'], }, package_contract_policy: { @@ -562,6 +564,7 @@ function writeProjectContext() { app: 'lib/app/app.dart', router: 'lib/app/router/app_router.dart', route_table: 'lib/app/router/app_route_table.dart', + module_manifest: 'lib/module_registry/module_manifest.dart', }, repository: { layout: 'pub_workspace', @@ -606,7 +609,7 @@ function writeProjectContext() { ], module_contract: { required_files: ['module_entry.dart', 'AI_ANALYSIS.md'], - required_registration: 'lib/app/router/app_route_table.dart', + required_registration: 'lib/module_registry/module_manifest.dart', required_metadata: ['category', 'difficulty', 'concepts', 'estimatedMinutes', 'status', 'subtitle'], required_learning_dependency: 'shared_learning', route_path_style: 'kebab_case', @@ -674,6 +677,7 @@ function writeRefactorPlan() { completed_milestones: [ 'directory_layers', 'shared_package_extraction', + 'module_manifest_route_composition', 'module_analysis_coverage', 'app_navigation_boundary', 'host_bootstrap_boundary', @@ -723,7 +727,16 @@ function writeRefactorPlan() { priority: 6, status: 'pending', targets: ['lib/modules/platform/usb_detector'], - acceptance: ['no_windows_hardcode', 'android_system_info', 'error_branch_test'], + acceptance: [ + 'no_windows_hardcode', + 'android_system_info', + 'error_branch_test', + 'mobile_usb_route_disabled', + ], + evidence: [ + 'USB module remains catalog-visible but has no supported host routes', + 'generic Android USB monitoring is deferred until a concrete OTG workflow exists', + ], }, { id: 'mobile_layout_baseline', @@ -955,6 +968,52 @@ function writeRefactorPlan() { 'Android host build and GPU first-frame evidence remains required', ], }, + { + id: 'android_online_video_playback', + priority: 18, + status: 'pending', + targets: [ + 'apps/flutter_forge/android/app/src/main/AndroidManifest.xml', + 'apps/flutter_forge/lib/modules/platform/online_video_player', + 'apps/flutter_forge/test/modules/platform/online_video_player', + ], + acceptance: [ + 'android_catalog_and_route_admission', + 'android_release_internet_permission', + 'compact_video_controls', + 'android_debug_apk_build', + 'android_real_video_playback', + ], + evidence: [ + 'video_player_android is registered in GeneratedPluginRegistrant', + 'main Android manifest grants INTERNET for release playback', + 'catalog and 320dp widget contracts cover Android entry', + 'Android device playback evidence remains required', + ], + }, + { + id: 'android_file_picker_admission', + priority: 19, + status: 'pending', + targets: [ + 'packages/file_picker_bridge', + 'apps/flutter_forge/lib/modules/platform/file_picker', + 'apps/flutter_forge/test/modules/platform/file_picker', + ], + acceptance: [ + 'android_catalog_and_route_admission', + 'android_file_selector_backend', + 'compact_file_picker_layout', + 'android_debug_apk_build', + 'android_real_device_pick_and_cancel', + ], + evidence: [ + 'file_picker_bridge selects file_selector on Android', + 'file_selector_android is registered in GeneratedPluginRegistrant', + 'catalog and compact widget contracts cover Android entry', + 'Android real-device document provider behavior remains required', + ], + }, ], quality_gate: [ 'node tool/validate_agent_docs.js', @@ -978,7 +1037,7 @@ function writeRefactorPlan() { function writeModuleIndex() { writeJson('lib/AI_MODULE_INDEX.md', { schema: 'flutter_forge.agent_docs.module_index.v1', - registry: 'lib/app/router/app_route_table.dart', + registry: 'lib/module_registry/module_manifest.dart', count: modules.length, modules: modules.map(({ category, id, route, status, depends, supportedPlatforms, supportsWeb }) => ({ id, @@ -987,8 +1046,10 @@ function writeModuleIndex() { route, status, depends, - ...(supportedPlatforms ? { supported_platforms: supportedPlatforms } : {}), - ...(supportsWeb !== undefined ? { supports_web: supportsWeb } : {}), + platform_support: { + native_platforms: supportedPlatforms ?? 'all', + web: supportsWeb ?? supportedPlatforms === undefined, + }, analysis: `lib/modules/${category}/${id}/AI_ANALYSIS.md`, })), }); @@ -1002,7 +1063,7 @@ function conceptsLiteral(concepts) { } function writeRouteTable() { - const file = path.join(appRoot, 'lib/app/router/app_route_table.dart'); + const manifestFile = path.join(appRoot, 'lib/module_registry/module_manifest.dart'); const lines = []; lines.push('// GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT'); @@ -1010,23 +1071,22 @@ function writeRouteTable() { lines.push("import 'package:flutter/foundation.dart';"); lines.push("import 'package:go_router/go_router.dart';"); lines.push(''); - lines.push("import '../module_home_page.dart';"); - lines.push("import '../../module_registry/module_category.dart';"); - lines.push("import '../../module_registry/module_catalog_utils.dart';"); - lines.push("import '../../module_registry/module_entry.dart';"); + lines.push("import 'module_category.dart';"); + lines.push("import 'module_entry.dart';"); + lines.push("import 'module_platform_support.dart';"); const emittedImports = new Set(); for (const [category, id, kind, gap] of routeTableImportOrder) { if (!modules.some((m) => m.category === category && m.id === id)) continue; const fileName = kind === 'routes' ? 'module_routes.dart' : 'module_entry.dart'; - lines.push(`import '../../modules/${category}/${id}/${fileName}';`); + lines.push(`import '../modules/${category}/${id}/${fileName}';`); emittedImports.add(`${category}/${id}/${fileName}`); if (gap === 'gap') lines.push(''); } for (const m of modules) { for (const fileName of ['module_entry.dart', m.routes ? 'module_routes.dart' : null].filter(Boolean)) { if (emittedImports.has(`${m.category}/${m.id}/${fileName}`)) continue; - lines.push(`import '../../modules/${m.category}/${m.id}/${fileName}';`); + lines.push(`import '../modules/${m.category}/${m.id}/${fileName}';`); } } @@ -1048,7 +1108,7 @@ function writeRouteTable() { } lines.push('// ==================== 模块注册 ===================='); lines.push(''); - lines.push('final List _modules = ['); + lines.push('final List moduleManifest = ['); let previousCategory = null; for (const m of modules) { if (m.category !== previousCategory) { @@ -1065,23 +1125,30 @@ function writeRouteTable() { lines.push(` concepts: ${conceptsLiteral(m.concepts)},`); lines.push(` estimatedMinutes: ${m.estimatedMinutes},`); lines.push(` status: ModuleStatus.${m.status},`); - if (m.supportsWeb !== undefined) { - if (m.supportsWebComment) { - lines.push(` ${m.supportsWebComment}`); - } - lines.push(` supportsWeb: ${m.supportsWeb},`); - } + if (m.supportsWebComment) lines.push(` ${m.supportsWebComment}`); if (m.supportedPlatforms) { if (m.supportedPlatformsComment) { lines.push(` ${m.supportedPlatformsComment}`); } const platforms = m.supportedPlatforms.map((platform) => `TargetPlatform.${platform}`); - const inlinePlatforms = ` supportedPlatforms: {${platforms.join(', ')}},`; - if (inlinePlatforms.length <= 80) { - lines.push(inlinePlatforms); + const inlinePlatforms = ` nativePlatforms: {${platforms.join(', ')}},`; + if (platforms.length === 0) { + lines.push(' platformSupport: const ModulePlatformSupport('); + lines.push(' nativePlatforms: {},'); + lines.push(` web: ${m.supportsWeb ?? false},`); + lines.push(' ),'); } else { - lines.push(' supportedPlatforms: {', ...platforms.map((platform) => ` ${platform},`), ' },'); + lines.push(' platformSupport: const ModulePlatformSupport('); + if (inlinePlatforms.length <= 80) { + lines.push(inlinePlatforms); + } else { + lines.push(' nativePlatforms: {', ...platforms.map((platform) => ` ${platform},`), ' },'); + } + lines.push(` web: ${m.supportsWeb ?? false},`); + lines.push(' ),'); } + } else { + lines.push(` platformSupport: const ModulePlatformSupport(web: ${m.supportsWeb ?? true}),`); } lines.push(` builder: (context) => const ${m.entry}(),`); if (m.subRoutesExpander) { @@ -1093,27 +1160,42 @@ function writeRouteTable() { } lines.push('];'); lines.push(''); - lines.push('final List _routes = ['); - lines.push(' GoRoute('); - lines.push(" path: '/',"); - lines.push(' builder: (context, state) => ModuleHomePage(modules: _modules),'); - lines.push(' ),'); - lines.push(' for (final module in availableModules(_modules))'); - lines.push(' GoRoute('); - lines.push(' path: module.path,'); - lines.push(' builder: (context, state) => module.builder(context),'); - lines.push(' routes: module.routes,'); - lines.push(' ),'); - lines.push('];'); - lines.push(''); - lines.push('class AppRouteTable {'); - lines.push(' static List get routes => _routes;'); - lines.push(' static List get modules => _modules;'); - lines.push('}'); - lines.push(''); + + fs.mkdirSync(path.dirname(manifestFile), { recursive: true }); + fs.writeFileSync(manifestFile, lines.join('\n')); + + const file = path.join(appRoot, 'lib/app/router/app_route_table.dart'); + const routeLines = []; + routeLines.push('// GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT'); + routeLines.push(''); + routeLines.push("import 'package:go_router/go_router.dart';"); + routeLines.push(''); + routeLines.push("import '../module_home_page.dart';"); + routeLines.push("import '../../module_registry/module_catalog_utils.dart';"); + routeLines.push("import '../../module_registry/module_entry.dart';"); + routeLines.push("import '../../module_registry/module_manifest.dart';"); + routeLines.push(''); + routeLines.push('final List _routes = ['); + routeLines.push(' GoRoute('); + routeLines.push(" path: '/',"); + routeLines.push(' builder: (context, state) => ModuleHomePage(modules: moduleManifest),'); + routeLines.push(' ),'); + routeLines.push(' for (final module in availableModules(moduleManifest))'); + routeLines.push(' GoRoute('); + routeLines.push(' path: module.path,'); + routeLines.push(' builder: (context, state) => module.builder(context),'); + routeLines.push(' routes: module.routes,'); + routeLines.push(' ),'); + routeLines.push('];'); + routeLines.push(''); + routeLines.push('class AppRouteTable {'); + routeLines.push(' static List get routes => _routes;'); + routeLines.push(' static List get modules => moduleManifest;'); + routeLines.push('}'); + routeLines.push(''); fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, lines.join('\n')); + fs.writeFileSync(file, routeLines.join('\n')); } function writeRootIndexes() { @@ -1251,8 +1333,10 @@ function writeModuleContracts() { }, route, category, - ...(supportedPlatforms ? { supported_platforms: supportedPlatforms } : {}), - ...(supportsWeb !== undefined ? { supports_web: supportsWeb } : {}), + platform_support: { + native_platforms: supportedPlatforms ?? 'all', + web: supportsWeb ?? supportedPlatforms === undefined, + }, entrypoints: entrypoints.length ? entrypoints : ['module_entry.dart'], owns: ['module_entry', 'module_ui', 'module_docs'], depends, diff --git a/tool/validate_agent_docs.js b/tool/validate_agent_docs.js index ac573f6..3bb67df 100644 --- a/tool/validate_agent_docs.js +++ b/tool/validate_agent_docs.js @@ -148,11 +148,11 @@ if (moduleIndex) { if (contract.node?.status !== mod.status) { failures.push(`${mod.analysis}:index_mismatch:status`); } - if ('supports_web' in mod && typeof mod.supports_web !== 'boolean') { - failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:supports_web_not_boolean`); + if (typeof mod.platform_support?.web !== 'boolean') { + failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:platform_support_web_not_boolean`); } - if (contract.supports_web !== mod.supports_web) { - failures.push(`${mod.analysis}:index_mismatch:supports_web`); + if (JSON.stringify(contract.platform_support) !== JSON.stringify(mod.platform_support)) { + failures.push(`${mod.analysis}:index_mismatch:platform_support`); } } @@ -234,7 +234,7 @@ if (moduleIndex) { } } - // ── phase 5b: single-source consistency — generate source vs index vs route table ── + // ── phase 5b: single-source consistency — generate source vs index vs manifest ── function readGenerateSourceModules() { const source = fs.readFileSync(path.join(root, 'tool/generate_agent_indexes.js'), 'utf8'); @@ -253,10 +253,20 @@ if (moduleIndex) { const generateModules = readGenerateSourceModules(); const generateById = new Map(generateModules.map((m) => [m.id, m])); + const manifestContent = fileExists('lib/module_registry/module_manifest.dart') + ? fs.readFileSync(resolveProjectPath('lib/module_registry/module_manifest.dart'), 'utf8') + : ''; const routeTableContent = fileExists('lib/app/router/app_route_table.dart') ? fs.readFileSync(resolveProjectPath('lib/app/router/app_route_table.dart'), 'utf8') : ''; + if (routeTableContent.includes("../../modules/")) { + failures.push('app_route_table.dart:must_not_import_feature_modules'); + } + if (routeTableContent && !routeTableContent.includes('moduleManifest')) { + failures.push('app_route_table.dart:missing_module_manifest_composition'); + } + for (const mod of moduleIndex.modules ?? []) { const gen = generateById.get(mod.id); if (!gen) { @@ -268,8 +278,8 @@ if (moduleIndex) { failures.push(`generate source:${mod.id}:mismatch:${key} index=${mod[key]} source=${gen[key]}`); } } - if (routeTableContent && !routeTableContent.includes(`path: '${mod.route}'`)) { - failures.push(`app_route_table.dart:missing_module_path:${mod.route}`); + if (manifestContent && !manifestContent.includes(`path: '${mod.route}'`)) { + failures.push(`module_manifest.dart:missing_module_path:${mod.route}`); } } for (const gen of generateModules) { From 51271d4967e587e404a253e78ede52d636906c53 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 16:09:56 +0800 Subject: [PATCH 11/21] feat(video): add compact Android controls and release internet permission --- .../android/app/src/main/AndroidManifest.xml | 1 + .../widgets/video_player_controls.dart | 249 ++++++++++-------- 2 files changed, 145 insertions(+), 105 deletions(-) diff --git a/apps/flutter_forge/android/app/src/main/AndroidManifest.xml b/apps/flutter_forge/android/app/src/main/AndroidManifest.xml index 6a5fd61..5d6ec57 100644 --- a/apps/flutter_forge/android/app/src/main/AndroidManifest.xml +++ b/apps/flutter_forge/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/apps/flutter_forge/lib/modules/platform/online_video_player/widgets/video_player_controls.dart b/apps/flutter_forge/lib/modules/platform/online_video_player/widgets/video_player_controls.dart index ca9d6b9..37bf4ea 100644 --- a/apps/flutter_forge/lib/modules/platform/online_video_player/widgets/video_player_controls.dart +++ b/apps/flutter_forge/lib/modules/platform/online_video_player/widgets/video_player_controls.dart @@ -9,118 +9,91 @@ class VideoPlayerControls extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - Row( + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 520; + return Column( children: [ - ValueListenableBuilder( - valueListenable: adapter.uiState, - builder: (context, state, child) { - if (state == PlayerUiState.loading) { - return const SizedBox.square( - dimension: 48, - child: Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - } - final isPlaying = state == PlayerUiState.playing; - return IconButton( - key: const Key('video-play-pause'), - tooltip: isPlaying ? '暂停' : '播放', - onPressed: state == PlayerUiState.error - ? null - : adapter.togglePlayPause, - icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow), - ); - }, - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: adapter.duration, - builder: (context, total, child) { - return ValueListenableBuilder( - valueListenable: adapter.position, - builder: (context, current, child) { - final maxSeconds = total.inMilliseconds.toDouble(); - final currentMilliseconds = current.inMilliseconds - .toDouble() - .clamp(0.0, maxSeconds == 0 ? 0.0 : maxSeconds); - return Row( - children: [ - Text( - '${_formatDuration(current)} / ' - '${_formatDuration(total)}', - ), - Expanded( - child: Slider( - key: const Key('video-seek'), - value: currentMilliseconds, - max: maxSeconds == 0 ? 1 : maxSeconds, - onChanged: maxSeconds == 0 - ? null - : (value) => adapter.seek( - Duration(milliseconds: value.round()), - ), + Row( + children: [ + ValueListenableBuilder( + valueListenable: adapter.uiState, + builder: (context, state, child) { + if (state == PlayerUiState.loading) { + return const SizedBox.square( + dimension: 48, + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final isPlaying = state == PlayerUiState.playing; + return IconButton( + key: const Key('video-play-pause'), + tooltip: isPlaying ? '暂停' : '播放', + onPressed: state == PlayerUiState.error + ? null + : adapter.togglePlayPause, + icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow), + ); + }, + ), + Expanded( + child: _Timeline(adapter: adapter, compact: compact), + ), + ValueListenableBuilder( + valueListenable: adapter.rate, + builder: (context, rate, child) { + return DropdownButton( + key: const Key('video-rate'), + value: rate, + onChanged: (value) { + if (value != null) adapter.setRate(value); + }, + items: const [0.5, 1.0, 1.5, 2.0] + .map( + (value) => DropdownMenuItem( + value: value, + child: Text('${value.toStringAsFixed(1)}x'), ), - ), - ], + ) + .toList(), + ); + }, + ), + ], + ), + if (compact) _SeekBar(adapter: adapter), + Row( + children: [ + const Icon(Icons.volume_up), + Expanded( + child: ValueListenableBuilder( + valueListenable: adapter.volume, + builder: (context, volume, child) { + return Slider( + key: const Key('video-volume'), + value: volume, + onChanged: adapter.setVolume, ); }, - ); - }, - ), - ), - ValueListenableBuilder( - valueListenable: adapter.rate, - builder: (context, rate, child) { - return DropdownButton( - key: const Key('video-rate'), - value: rate, - onChanged: (value) { - if (value != null) adapter.setRate(value); + ), + ), + ValueListenableBuilder( + valueListenable: adapter.volume, + builder: (context, volume, child) { + return SizedBox( + width: 48, + child: Text('${(volume * 100).round()}%'), + ); }, - items: const [0.5, 1.0, 1.5, 2.0] - .map( - (value) => DropdownMenuItem( - value: value, - child: Text('${value.toStringAsFixed(1)}x'), - ), - ) - .toList(), - ); - }, + ), + ], ), ], - ), - Row( - children: [ - const Icon(Icons.volume_up), - Expanded( - child: ValueListenableBuilder( - valueListenable: adapter.volume, - builder: (context, volume, child) { - return Slider( - key: const Key('video-volume'), - value: volume, - onChanged: adapter.setVolume, - ); - }, - ), - ), - ValueListenableBuilder( - valueListenable: adapter.volume, - builder: (context, volume, child) { - return SizedBox( - width: 48, - child: Text('${(volume * 100).round()}%'), - ); - }, - ), - ], - ), - ], + ); + }, ); } @@ -130,3 +103,69 @@ class VideoPlayerControls extends StatelessWidget { return '$minutes:$seconds'; } } + +class _Timeline extends StatelessWidget { + const _Timeline({required this.adapter, required this.compact}); + + final VideoPlayerAdapter adapter; + final bool compact; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: adapter.duration, + builder: (context, total, child) { + return ValueListenableBuilder( + valueListenable: adapter.position, + builder: (context, current, child) { + final label = Text( + '${VideoPlayerControls._formatDuration(current)} / ' + '${VideoPlayerControls._formatDuration(total)}', + ); + if (compact) return Center(child: label); + return Row( + children: [ + label, + Expanded(child: _SeekBar(adapter: adapter)), + ], + ); + }, + ); + }, + ); + } +} + +class _SeekBar extends StatelessWidget { + const _SeekBar({required this.adapter}); + + final VideoPlayerAdapter adapter; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: adapter.duration, + builder: (context, total, child) { + return ValueListenableBuilder( + valueListenable: adapter.position, + builder: (context, current, child) { + final maxMilliseconds = total.inMilliseconds.toDouble(); + final currentMilliseconds = current.inMilliseconds.toDouble().clamp( + 0.0, + maxMilliseconds == 0 ? 0.0 : maxMilliseconds, + ); + return Slider( + key: const Key('video-seek'), + value: currentMilliseconds, + max: maxMilliseconds == 0 ? 1 : maxMilliseconds, + onChanged: maxMilliseconds == 0 + ? null + : (value) => + adapter.seek(Duration(milliseconds: value.round())), + ); + }, + ); + }, + ); + } +} From 03306bc4745fa7e3026a678081ad9fb1a549d5c6 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 19:55:33 +0800 Subject: [PATCH 12/21] feat(platform): snapshot host identity and guard module routes Capture one immutable AppPlatformSnapshot at bootstrap and inject it into the router and ProviderScope. Module support now declares excluded platforms against the five product targets, and every module path stays registered so unsupported platforms render a stable explanation page. --- apps/flutter_forge/lib/app/app.dart | 9 +- apps/flutter_forge/lib/app/app_bootstrap.dart | 21 ++++- .../lib/app/app_platform_provider.dart | 7 ++ .../lib/app/category_navigation.dart | 19 ++-- .../lib/app/category_window_app.dart | 34 +++++-- .../lib/app/module_home_page.dart | 26 ++++-- .../lib/app/navigation_policy.dart | 10 +- .../lib/app/router/app_route_table.dart | 15 ++- .../lib/app/router/app_router.dart | 4 +- .../lib/app/unsupported_module_page.dart | 68 ++++++++++++++ .../app_platform_snapshot.dart | 62 +++++++++++++ .../module_registry/module_catalog_utils.dart | 40 ++++---- .../lib/module_registry/module_entry.dart | 6 +- .../lib/module_registry/module_manifest.dart | 93 +++++++++---------- .../module_platform_support.dart | 18 ++-- 15 files changed, 304 insertions(+), 128 deletions(-) create mode 100644 apps/flutter_forge/lib/app/app_platform_provider.dart create mode 100644 apps/flutter_forge/lib/app/unsupported_module_page.dart create mode 100644 apps/flutter_forge/lib/module_registry/app_platform_snapshot.dart diff --git a/apps/flutter_forge/lib/app/app.dart b/apps/flutter_forge/lib/app/app.dart index 8c57c9d..a405786 100644 --- a/apps/flutter_forge/lib/app/app.dart +++ b/apps/flutter_forge/lib/app/app.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; - -import 'router/app_router.dart'; +import 'package:go_router/go_router.dart'; class App extends StatelessWidget { - const App({super.key}); + const App({super.key, required this.router}); + + final GoRouter router; @override Widget build(BuildContext context) { return MaterialApp.router( - routerConfig: AppRouter.router, + routerConfig: router, title: 'Flutter Forge', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), diff --git a/apps/flutter_forge/lib/app/app_bootstrap.dart b/apps/flutter_forge/lib/app/app_bootstrap.dart index 80d7c51..48f74e6 100644 --- a/apps/flutter_forge/lib/app/app_bootstrap.dart +++ b/apps/flutter_forge/lib/app/app_bootstrap.dart @@ -1,28 +1,39 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; +import '../module_registry/app_platform_snapshot.dart'; import '../shared/multi_window/multi_window_manager.dart'; import 'app.dart'; +import 'app_platform_provider.dart'; import 'category_window_app.dart'; +import 'router/app_router.dart'; /// Resolves the host-specific application shell before mounting Flutter. Future bootstrapFlutterForgeApp() async { WidgetsFlutterBinding.ensureInitialized(); + final platform = AppPlatformSnapshot.detect(); - Widget root = const App(); - if (!kIsWeb && MultiWindowManager.isSupported) { + Widget root = App(router: AppRouter.create(platform)); + if (!platform.isWeb && MultiWindowManager.isSupported) { final windowController = await WindowController.fromCurrentEngine(); final arguments = MultiWindowManager.parseArguments( windowController.arguments, ); if (arguments.type == WindowType.category && arguments.category != null) { - root = CategoryWindowApp(category: arguments.category!); + root = CategoryWindowApp( + category: arguments.category!, + platform: platform, + ); } else { await MultiWindowManager.instance.initialize(); } } - runApp(ProviderScope(child: root)); + runApp( + ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: root, + ), + ); } diff --git a/apps/flutter_forge/lib/app/app_platform_provider.dart b/apps/flutter_forge/lib/app/app_platform_provider.dart new file mode 100644 index 0000000..e43b4b9 --- /dev/null +++ b/apps/flutter_forge/lib/app/app_platform_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../module_registry/app_platform_snapshot.dart'; + +final appPlatformProvider = Provider( + (ref) => throw StateError('AppPlatformSnapshot was not initialized'), +); diff --git a/apps/flutter_forge/lib/app/category_navigation.dart b/apps/flutter_forge/lib/app/category_navigation.dart index ca45ac7..6aa3d43 100644 --- a/apps/flutter_forge/lib/app/category_navigation.dart +++ b/apps/flutter_forge/lib/app/category_navigation.dart @@ -1,6 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../module_registry/app_platform_snapshot.dart'; import '../module_registry/module_catalog_utils.dart'; import '../module_registry/module_category.dart'; import '../module_registry/module_entry.dart'; @@ -15,12 +15,14 @@ import 'navigation_policy.dart'; class CategoryNavigation { const CategoryNavigation._(); - static CategoryNavigationMode modeFor(BuildContext context) { + static CategoryNavigationMode modeFor( + BuildContext context, + AppPlatformSnapshot platform, + ) { return NavigationPolicy.resolve( - platform: defaultTargetPlatform, + platform: platform, width: MediaQuery.sizeOf(context).width, multiWindowSupported: MultiWindowManager.isSupported, - isWeb: kIsWeb, ); } @@ -28,8 +30,9 @@ class CategoryNavigation { BuildContext context, { required ModuleCategory category, required List modules, + required AppPlatformSnapshot platform, }) async { - if (modeFor(context) == CategoryNavigationMode.separateWindow) { + if (modeFor(context, platform) == CategoryNavigationMode.separateWindow) { await MultiWindowManager.instance.createCategoryWindow(category); return; } @@ -38,7 +41,11 @@ class CategoryNavigation { final filtered = filterModulesByCategory(modules, category); await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => CategoryHomePage(category: category, modules: filtered), + builder: (_) => CategoryHomePage( + category: category, + modules: filtered, + platform: platform, + ), ), ); } diff --git a/apps/flutter_forge/lib/app/category_window_app.dart b/apps/flutter_forge/lib/app/category_window_app.dart index 6a44dc2..589a078 100644 --- a/apps/flutter_forge/lib/app/category_window_app.dart +++ b/apps/flutter_forge/lib/app/category_window_app.dart @@ -4,25 +4,43 @@ import 'package:go_router/go_router.dart'; import '../module_registry/module_catalog_utils.dart'; import '../module_registry/module_category.dart'; import '../module_registry/module_entry.dart'; +import '../module_registry/app_platform_snapshot.dart'; import 'module_home_page.dart'; +import 'unsupported_module_page.dart'; import 'router/app_route_table.dart'; class CategoryWindowApp extends StatelessWidget { - const CategoryWindowApp({super.key, required this.category}); + const CategoryWindowApp({ + super.key, + required this.category, + required this.platform, + }); final ModuleCategory category; + final AppPlatformSnapshot platform; - static GoRouter createRouter(ModuleCategory category) { + static GoRouter createRouter( + ModuleCategory category, + AppPlatformSnapshot platform, + ) { final modules = filterModulesByCategory(AppRouteTable.modules, category); - final childRoutes = buildCategoryRoutes(modules); + final childRoutes = buildCategoryRoutes( + modules, + platform, + unsupportedBuilder: (_, module) => + UnsupportedModulePage(module: module, platform: platform), + ); return GoRouter( initialLocation: '/', routes: [ GoRoute( path: '/', - builder: (context, state) => - CategoryHomePage(category: category, modules: modules), + builder: (context, state) => CategoryHomePage( + category: category, + modules: modules, + platform: platform, + ), routes: childRoutes, ), ], @@ -32,7 +50,7 @@ class CategoryWindowApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( - routerConfig: CategoryWindowApp.createRouter(category), + routerConfig: CategoryWindowApp.createRouter(category, platform), title: category.label, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), @@ -47,10 +65,12 @@ class CategoryHomePage extends StatelessWidget { super.key, required this.category, required this.modules, + required this.platform, }); final ModuleCategory category; final List modules; + final AppPlatformSnapshot platform; @override Widget build(BuildContext context) { @@ -73,7 +93,7 @@ class CategoryHomePage extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8), itemCount: modules.length, itemBuilder: (context, index) => - ModuleListTile(module: modules[index]), + ModuleListTile(module: modules[index], platform: platform), ), ), ); diff --git a/apps/flutter_forge/lib/app/module_home_page.dart b/apps/flutter_forge/lib/app/module_home_page.dart index ad3c9b1..a438cf4 100644 --- a/apps/flutter_forge/lib/app/module_home_page.dart +++ b/apps/flutter_forge/lib/app/module_home_page.dart @@ -1,21 +1,24 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../module_registry/app_platform_snapshot.dart'; import '../module_registry/module_category.dart'; import '../module_registry/module_entry.dart'; import '../module_registry/module_catalog_utils.dart'; import 'category_navigation.dart'; import 'navigation_policy.dart'; +import 'app_platform_provider.dart'; -class ModuleHomePage extends StatelessWidget { +class ModuleHomePage extends ConsumerWidget { const ModuleHomePage({super.key, required this.modules}); final List modules; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { const categories = ModuleCategory.values; + final platform = ref.watch(appPlatformProvider); return Scaffold( appBar: AppBar(title: const Text('Flutter 学习实验室')), @@ -50,7 +53,7 @@ class ModuleHomePage extends StatelessWidget { ), IconButton( icon: Icon( - CategoryNavigation.modeFor(context) == + CategoryNavigation.modeFor(context, platform) == CategoryNavigationMode.separateWindow ? Icons.open_in_new : Icons.chevron_right, @@ -61,13 +64,15 @@ class ModuleHomePage extends StatelessWidget { context, category: category, modules: modules, + platform: platform, ), ), ], ), ), ...categoryModules.map( - (module) => ModuleListTile(module: module), + (module) => + ModuleListTile(module: module, platform: platform), ), const Divider(height: 1), ], @@ -80,9 +85,14 @@ class ModuleHomePage extends StatelessWidget { } class ModuleListTile extends StatelessWidget { - const ModuleListTile({super.key, required this.module}); + const ModuleListTile({ + super.key, + required this.module, + required this.platform, + }); final ModuleEntry module; + final AppPlatformSnapshot platform; Color _difficultyColor(Difficulty difficulty) { return switch (difficulty) { @@ -95,7 +105,7 @@ class ModuleListTile extends StatelessWidget { @override Widget build(BuildContext context) { final difficultyColor = _difficultyColor(module.difficulty); - final isAvailable = isModuleAvailable(module, defaultTargetPlatform); + final isAvailable = isModuleAvailable(module, platform); final textOpacity = isAvailable ? 1.0 : 0.55; return ListTile( @@ -166,7 +176,7 @@ class ModuleListTile extends StatelessWidget { isAvailable ? Icons.chevron_right : Icons.block, color: isAvailable ? null : Colors.grey, ), - onTap: isAvailable ? () => context.push(module.path) : null, + onTap: () => context.push(module.path), ); } } diff --git a/apps/flutter_forge/lib/app/navigation_policy.dart b/apps/flutter_forge/lib/app/navigation_policy.dart index 24086b9..e58389e 100644 --- a/apps/flutter_forge/lib/app/navigation_policy.dart +++ b/apps/flutter_forge/lib/app/navigation_policy.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import '../module_registry/app_platform_snapshot.dart'; enum CategoryNavigationMode { inApp, separateWindow } @@ -8,15 +8,11 @@ class NavigationPolicy { static const double compactWidthBreakpoint = 600; static CategoryNavigationMode resolve({ - required TargetPlatform platform, + required AppPlatformSnapshot platform, required double width, required bool multiWindowSupported, - bool isWeb = false, }) { - if (isWeb || - platform == TargetPlatform.android || - platform == TargetPlatform.iOS || - platform == TargetPlatform.fuchsia) { + if (platform.hostFamily != AppHostFamily.desktop) { return CategoryNavigationMode.inApp; } diff --git a/apps/flutter_forge/lib/app/router/app_route_table.dart b/apps/flutter_forge/lib/app/router/app_route_table.dart index 147b19a..e510310 100644 --- a/apps/flutter_forge/lib/app/router/app_route_table.dart +++ b/apps/flutter_forge/lib/app/router/app_route_table.dart @@ -3,24 +3,29 @@ import 'package:go_router/go_router.dart'; import '../module_home_page.dart'; +import '../unsupported_module_page.dart'; +import '../../module_registry/app_platform_snapshot.dart'; import '../../module_registry/module_catalog_utils.dart'; import '../../module_registry/module_entry.dart'; import '../../module_registry/module_manifest.dart'; -final List _routes = [ +List _routesFor(AppPlatformSnapshot platform) => [ GoRoute( path: '/', builder: (context, state) => ModuleHomePage(modules: moduleManifest), ), - for (final module in availableModules(moduleManifest)) + for (final module in moduleManifest) GoRoute( path: module.path, - builder: (context, state) => module.builder(context), - routes: module.routes, + builder: (context, state) => isModuleAvailable(module, platform) + ? module.builder(context) + : UnsupportedModulePage(module: module, platform: platform), + routes: isModuleAvailable(module, platform) ? module.routes : const [], ), ]; class AppRouteTable { - static List get routes => _routes; + static List routesFor(AppPlatformSnapshot platform) => + _routesFor(platform); static List get modules => moduleManifest; } diff --git a/apps/flutter_forge/lib/app/router/app_router.dart b/apps/flutter_forge/lib/app/router/app_router.dart index 22ef56d..951e6b0 100644 --- a/apps/flutter_forge/lib/app/router/app_router.dart +++ b/apps/flutter_forge/lib/app/router/app_router.dart @@ -1,9 +1,11 @@ import 'package:go_router/go_router.dart'; +import '../../module_registry/app_platform_snapshot.dart'; import 'app_route_table.dart'; class AppRouter { AppRouter._(); - static final GoRouter router = GoRouter(routes: AppRouteTable.routes); + static GoRouter create(AppPlatformSnapshot platform) => + GoRouter(routes: AppRouteTable.routesFor(platform)); } diff --git a/apps/flutter_forge/lib/app/unsupported_module_page.dart b/apps/flutter_forge/lib/app/unsupported_module_page.dart new file mode 100644 index 0000000..8ca9374 --- /dev/null +++ b/apps/flutter_forge/lib/app/unsupported_module_page.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../module_registry/app_platform_snapshot.dart'; +import '../module_registry/module_entry.dart'; + +class UnsupportedModulePage extends StatelessWidget { + const UnsupportedModulePage({ + super.key, + required this.module, + required this.platform, + }); + + final ModuleEntry module; + final AppPlatformSnapshot platform; + + @override + Widget build(BuildContext context) { + final supportedPlatforms = AppTargetPlatform.values + .where((candidate) => candidate != AppTargetPlatform.unsupported) + .where( + (candidate) => + !module.platformSupport.excludedPlatforms.contains(candidate), + ) + .map((candidate) => candidate.label) + .join('、'); + + return Scaffold( + appBar: AppBar(title: Text(module.title)), + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.block, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + '当前平台暂不支持此模块', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text('当前平台:${platform.platform.label}'), + const SizedBox(height: 8), + Text( + supportedPlatforms.isEmpty + ? '当前没有开放的目标平台' + : '支持平台:$supportedPlatforms', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () => context.go('/'), + child: const Text('返回模块目录'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/flutter_forge/lib/module_registry/app_platform_snapshot.dart b/apps/flutter_forge/lib/module_registry/app_platform_snapshot.dart new file mode 100644 index 0000000..ede2308 --- /dev/null +++ b/apps/flutter_forge/lib/module_registry/app_platform_snapshot.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; + +enum AppTargetPlatform { android, iOS, macOS, web, windows, unsupported } + +enum AppHostFamily { mobile, desktop, web, unsupported } + +extension AppTargetPlatformLabel on AppTargetPlatform { + String get label => switch (this) { + AppTargetPlatform.android => 'Android', + AppTargetPlatform.iOS => 'iOS', + AppTargetPlatform.macOS => 'macOS', + AppTargetPlatform.web => 'Web', + AppTargetPlatform.windows => 'Windows', + AppTargetPlatform.unsupported => '非产品目标平台', + }; +} + +/// Immutable host identity captured once for the current application process. +class AppPlatformSnapshot { + const AppPlatformSnapshot({required this.platform}); + + factory AppPlatformSnapshot.detect() { + return AppPlatformSnapshot.fromFlutter( + isWeb: kIsWeb, + targetPlatform: defaultTargetPlatform, + ); + } + + factory AppPlatformSnapshot.fromFlutter({ + required bool isWeb, + required TargetPlatform targetPlatform, + }) { + if (isWeb) { + return const AppPlatformSnapshot(platform: AppTargetPlatform.web); + } + + return AppPlatformSnapshot( + platform: switch (targetPlatform) { + TargetPlatform.android => AppTargetPlatform.android, + TargetPlatform.iOS => AppTargetPlatform.iOS, + TargetPlatform.macOS => AppTargetPlatform.macOS, + TargetPlatform.windows => AppTargetPlatform.windows, + TargetPlatform.linux || + TargetPlatform.fuchsia => AppTargetPlatform.unsupported, + }, + ); + } + + final AppTargetPlatform platform; + + bool get isWeb => platform == AppTargetPlatform.web; + + bool get isProductTarget => platform != AppTargetPlatform.unsupported; + + AppHostFamily get hostFamily => switch (platform) { + AppTargetPlatform.android || AppTargetPlatform.iOS => AppHostFamily.mobile, + AppTargetPlatform.macOS || + AppTargetPlatform.windows => AppHostFamily.desktop, + AppTargetPlatform.web => AppHostFamily.web, + AppTargetPlatform.unsupported => AppHostFamily.unsupported, + }; +} diff --git a/apps/flutter_forge/lib/module_registry/module_catalog_utils.dart b/apps/flutter_forge/lib/module_registry/module_catalog_utils.dart index 225157b..06bdf79 100644 --- a/apps/flutter_forge/lib/module_registry/module_catalog_utils.dart +++ b/apps/flutter_forge/lib/module_registry/module_catalog_utils.dart @@ -1,6 +1,7 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'app_platform_snapshot.dart'; import 'module_category.dart'; import 'module_entry.dart'; @@ -11,34 +12,33 @@ List filterModulesByCategory( return allModules.where((module) => module.category == category).toList(); } -bool isModuleAvailable( - ModuleEntry module, [ - TargetPlatform? platform, - bool? web, -]) { - return module.isSupportedOn( - platform ?? defaultTargetPlatform, - isWeb: web ?? kIsWeb, - ); -} +bool isModuleAvailable(ModuleEntry module, AppPlatformSnapshot platform) => + module.isSupportedOn(platform); List availableModules( - List modules, [ - TargetPlatform? platform, - bool? web, -]) { + List modules, + AppPlatformSnapshot platform, +) { return modules - .where((module) => isModuleAvailable(module, platform, web)) + .where((module) => isModuleAvailable(module, platform)) .toList(); } -List buildCategoryRoutes(List modules) { +List buildCategoryRoutes( + List modules, + AppPlatformSnapshot platform, { + required Widget Function(BuildContext, ModuleEntry) unsupportedBuilder, +}) { return [ - for (final module in availableModules(modules)) + for (final module in modules) GoRoute( path: _stripLeadingSlash(module.path), - builder: (context, state) => module.builder(context), - routes: _rebasedRoutes(module.routes), + builder: (context, state) => isModuleAvailable(module, platform) + ? module.builder(context) + : unsupportedBuilder(context, module), + routes: isModuleAvailable(module, platform) + ? _rebasedRoutes(module.routes) + : const [], ), ]; } diff --git a/apps/flutter_forge/lib/module_registry/module_entry.dart b/apps/flutter_forge/lib/module_registry/module_entry.dart index f9fa811..5f56943 100644 --- a/apps/flutter_forge/lib/module_registry/module_entry.dart +++ b/apps/flutter_forge/lib/module_registry/module_entry.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'app_platform_snapshot.dart'; import 'module_category.dart'; import 'module_platform_support.dart'; @@ -32,7 +33,6 @@ class ModuleEntry { final ModulePlatformSupport platformSupport; - bool isSupportedOn(TargetPlatform platform, {required bool isWeb}) { - return platformSupport.supports(platform, isWeb: isWeb); - } + bool isSupportedOn(AppPlatformSnapshot snapshot) => + platformSupport.supports(snapshot); } diff --git a/apps/flutter_forge/lib/module_registry/module_manifest.dart b/apps/flutter_forge/lib/module_registry/module_manifest.dart index 91bf8de..7746094 100644 --- a/apps/flutter_forge/lib/module_registry/module_manifest.dart +++ b/apps/flutter_forge/lib/module_registry/module_manifest.dart @@ -1,8 +1,8 @@ // GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT -import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; +import 'app_platform_snapshot.dart'; import 'module_category.dart'; import 'module_entry.dart'; import 'module_platform_support.dart'; @@ -69,7 +69,7 @@ final List moduleManifest = [ concepts: ['Widget 树', 'Element 树', 'RenderObject', '生命周期'], estimatedMinutes: 30, status: ModuleStatus.recommended, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const TreeStateEntry(), routes: TreeStateRoutes.routes, ), @@ -82,7 +82,7 @@ final List moduleManifest = [ concepts: ['Microtask', 'Event Queue', 'async/await', 'Zone'], estimatedMinutes: 25, status: ModuleStatus.recommended, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const MicrotaskEntry(), routes: MicrotaskRoutes.routes, ), @@ -95,7 +95,7 @@ final List moduleManifest = [ concepts: ['Debouncer', 'Throttle', 'Timer'], estimatedMinutes: 15, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const DebounceThrottleEntry(), ), @@ -109,7 +109,7 @@ final List moduleManifest = [ concepts: ['StreamController', '单订阅', '广播流', 'Stream 变换'], estimatedMinutes: 30, status: ModuleStatus.recommended, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const StreamSubscriptionEntry(), routes: StreamSubscriptionRoutes.routes, ), @@ -123,7 +123,9 @@ final List moduleManifest = [ estimatedMinutes: 20, status: ModuleStatus.ready, // Safari Web validation showed no usable progress lifecycle for Isolate.spawn. - platformSupport: const ModulePlatformSupport(web: false), + platformSupport: const ModulePlatformSupport( + excludedPlatforms: {AppTargetPlatform.web}, + ), builder: (context) => const IsolateTestEntry(), routes: IsolateTestRoutes.routes, ), @@ -137,7 +139,9 @@ final List moduleManifest = [ estimatedMinutes: 35, status: ModuleStatus.ready, // Safari Web validation showed tasks stalled at zero progress. - platformSupport: const ModulePlatformSupport(web: false), + platformSupport: const ModulePlatformSupport( + excludedPlatforms: {AppTargetPlatform.web}, + ), builder: (context) => const IsolateStreamEntry(), ), @@ -151,7 +155,7 @@ final List moduleManifest = [ concepts: ['Provider', 'Riverpod', 'Bloc', '状态提升', 'FutureProvider'], estimatedMinutes: 45, status: ModuleStatus.recommended, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const StatusManageEntry(), routes: _buildStatusManageRoutes(), ), @@ -164,7 +168,7 @@ final List moduleManifest = [ concepts: ['IoC', '依赖注入', '生命周期', '作用域'], estimatedMinutes: 30, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const FlutterIocEntry(), ), ModuleEntry( @@ -176,7 +180,7 @@ final List moduleManifest = [ concepts: ['shared_preferences', '键值存储', '异步读取', '状态恢复', '测试替身'], estimatedMinutes: 25, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const LocalPersistenceEntry(), ), @@ -192,8 +196,12 @@ final List moduleManifest = [ status: ModuleStatus.ready, // gcode_core v0.2.0-dev.1 validates macOS GPU rendering only. platformSupport: const ModulePlatformSupport( - nativePlatforms: {TargetPlatform.macOS}, - web: false, + excludedPlatforms: { + AppTargetPlatform.android, + AppTargetPlatform.iOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + }, ), builder: (context) => const GcodeVisualizerEntry(), ), @@ -206,7 +214,7 @@ final List moduleManifest = [ concepts: ['CustomPaint', '手势检测', '吸附算法', '状态管理'], estimatedMinutes: 40, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const AdsorptionLineEntry(), ), ModuleEntry( @@ -218,7 +226,7 @@ final List moduleManifest = [ concepts: ['Tween 动画', 'CustomPaint', 'OverlayEntry', '动画配置'], estimatedMinutes: 30, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const DownloadAnimationEntry(), routes: DownloadAnimationRoutes.routes, ), @@ -240,7 +248,7 @@ final List moduleManifest = [ ], estimatedMinutes: 30, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const FontPickerEntry(), routes: FontPickerRoutes.routes, ), @@ -255,12 +263,7 @@ final List moduleManifest = [ status: ModuleStatus.ready, // Android is view-only; Windows and Android host evidence remains pending. platformSupport: const ModulePlatformSupport( - nativePlatforms: { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }, - web: false, + excludedPlatforms: {AppTargetPlatform.iOS, AppTargetPlatform.web}, ), builder: (context) => const FlutterScene3dEntry(), ), @@ -275,7 +278,7 @@ final List moduleManifest = [ concepts: ['AlertDialog', 'BottomSheet', 'Overlay', 'ContextMenu'], estimatedMinutes: 20, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const PopWidgetEntry(), ), ModuleEntry( @@ -287,7 +290,7 @@ final List moduleManifest = [ concepts: ['Dialog', 'BottomSheet', 'Overlay', 'TableView', '二维滚动'], estimatedMinutes: 25, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const PopupListInteractionEntry(), routes: PopupListInteractionRoutes.routes, ), @@ -300,7 +303,7 @@ final List moduleManifest = [ concepts: ['TableView', '固定表头', '二维滚动'], estimatedMinutes: 15, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const ScrollTableEntry(), ), ModuleEntry( @@ -318,7 +321,7 @@ final List moduleManifest = [ ], estimatedMinutes: 30, status: ModuleStatus.ready, - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const OverlayFollowCompareEntry(), ), @@ -333,7 +336,7 @@ final List moduleManifest = [ estimatedMinutes: 35, status: ModuleStatus.ready, // Web uses an in-memory Dio adapter; native hosts keep the localhost mock server. - platformSupport: const ModulePlatformSupport(web: true), + platformSupport: const ModulePlatformSupport(), builder: (context) => const InterceptorTestEntry(), routes: InterceptorTestRoutes.routes, ), @@ -346,10 +349,15 @@ final List moduleManifest = [ concepts: ['Android USB', 'MethodChannel', 'Stream 广播', '设备扫描'], estimatedMinutes: 25, status: ModuleStatus.ready, - // Disabled on every host until the module is reframed as a concrete OTG workflow. + // Disabled on every target until the module is reframed as a concrete OTG workflow. platformSupport: const ModulePlatformSupport( - nativePlatforms: {}, - web: false, + excludedPlatforms: { + AppTargetPlatform.android, + AppTargetPlatform.iOS, + AppTargetPlatform.macOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + }, ), builder: (context) => const UsbDetectorEntry(), ), @@ -362,15 +370,9 @@ final List moduleManifest = [ concepts: ['FilePickerService', 'MethodChannel', '平台桥接', '扩展名过滤', '取消分支'], estimatedMinutes: 20, status: ModuleStatus.ready, - // Web accepts one user-selected file and exposes only its filename. - // Android uses file_selector and requires real-device provider validation. + // Android uses file_selector; Web exposes only the selected filename. platformSupport: const ModulePlatformSupport( - nativePlatforms: { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }, - web: true, + excludedPlatforms: {AppTargetPlatform.iOS}, ), builder: (context) => const FilePickerEntry(), ), @@ -390,15 +392,9 @@ final List moduleManifest = [ ], estimatedMinutes: 35, status: ModuleStatus.ready, - // Web uses a same-origin media asset and starts playback from a user gesture. - // Android/macOS use official video_player backends; Windows uses video_player_win. + // Web uses same-origin media; native targets use their registered backends. platformSupport: const ModulePlatformSupport( - nativePlatforms: { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }, - web: true, + excludedPlatforms: {AppTargetPlatform.iOS}, ), builder: (context) => const OnlineVideoPlayerEntry(), ), @@ -413,12 +409,7 @@ final List moduleManifest = [ status: ModuleStatus.ready, // Android/macOS use webview_flutter; Windows uses WebView2. platformSupport: const ModulePlatformSupport( - nativePlatforms: { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }, - web: false, + excludedPlatforms: {AppTargetPlatform.iOS, AppTargetPlatform.web}, ), builder: (context) => const WebViewEntry(), ), diff --git a/apps/flutter_forge/lib/module_registry/module_platform_support.dart b/apps/flutter_forge/lib/module_registry/module_platform_support.dart index ba3a167..4cf1881 100644 --- a/apps/flutter_forge/lib/module_registry/module_platform_support.dart +++ b/apps/flutter_forge/lib/module_registry/module_platform_support.dart @@ -1,18 +1,14 @@ -import 'package:flutter/foundation.dart'; +import 'app_platform_snapshot.dart'; /// Declares where a module is available without coupling routing to a host. class ModulePlatformSupport { - const ModulePlatformSupport({this.nativePlatforms, this.web = false}); + const ModulePlatformSupport({this.excludedPlatforms = const {}}); - /// A null set means every native Flutter host is supported. - final Set? nativePlatforms; + /// Product target platforms that this module explicitly does not support. + final Set excludedPlatforms; - /// Whether the browser implementation is supported. - final bool web; - - bool supports(TargetPlatform platform, {required bool isWeb}) { - if (isWeb) return web; - final platforms = nativePlatforms; - return platforms == null || platforms.contains(platform); + bool supports(AppPlatformSnapshot snapshot) { + return snapshot.isProductTarget && + !excludedPlatforms.contains(snapshot.platform); } } From 6cec52b4b978b8500420d3e38a72f257236df7ce Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 19:55:37 +0800 Subject: [PATCH 13/21] test(platform): cover snapshot mapping and guarded routes Add unit and widget coverage for platform identity mapping, excluded platform availability, and the guarded unsupported-module route, and adapt the module and integration harnesses to the injected snapshot. --- .../integration_test/app_test.dart | 28 +++- .../flutter_scene_3d_flow_test.dart | 18 ++- .../windows_acceptance_test.dart | 15 +- .../file_picker/file_picker_test.dart | 27 ++-- .../online_video_player_test.dart | 32 ++-- .../usb_detector/usb_detector_test.dart | 22 ++- .../platform/webview/webview_test.dart | 21 ++- .../popup_list_interaction_test.dart | 9 +- .../flutter_scene_3d_test.dart | 35 ++-- .../shared/app_platform_snapshot_test.dart | 44 +++++ .../shared/module_catalog_utils_test.dart | 151 ++++++++---------- .../test/shared/navigation_policy_test.dart | 19 ++- .../shared/platform_route_guard_test.dart | 58 +++++++ .../responsive_navigation_layout_test.dart | 29 +++- .../test/shared/web_compatibility_test.dart | 22 +-- 15 files changed, 354 insertions(+), 176 deletions(-) create mode 100644 apps/flutter_forge/test/shared/app_platform_snapshot_test.dart create mode 100644 apps/flutter_forge/test/shared/platform_route_guard_test.dart diff --git a/apps/flutter_forge/integration_test/app_test.dart b/apps/flutter_forge/integration_test/app_test.dart index 6571ffd..189d8c9 100644 --- a/apps/flutter_forge/integration_test/app_test.dart +++ b/apps/flutter_forge/integration_test/app_test.dart @@ -1,27 +1,39 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/app_platform_provider.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; import 'package:flutter_forge_app/app/router/app_router.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/modules/popup_table/popup_widgets/module_root.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late AppPlatformSnapshot platform; + late GoRouter router; setUp(() { - AppRouter.router.go('/'); + platform = AppPlatformSnapshot.detect(); + router = AppRouter.create(platform)..go('/'); }); + tearDown(() => router.dispose()); + + Widget testApp() => ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: App(router: router), + ); + testWidgets('available modules open and return', (tester) async { - await tester.pumpWidget(const App()); + await tester.pumpWidget(testApp()); await tester.pumpAndSettle(); expect(find.text('Flutter 学习实验室'), findsOneWidget); - final platform = defaultTargetPlatform; final modules = AppRouteTable.modules .where((module) => isModuleAvailable(module, platform)) .toList(); @@ -51,10 +63,10 @@ void main() { testWidgets('popup and list child routes open from the module page', ( tester, ) async { - await tester.pumpWidget(const App()); + await tester.pumpWidget(testApp()); await tester.pumpAndSettle(); - AppRouter.router.go('/popup-list-interaction'); + router.go('/popup-list-interaction'); await tester.pumpAndSettle(); final popupTile = find.widgetWithText(ListTile, '弹窗组件'); @@ -65,9 +77,9 @@ void main() { await tester.pageBack(); await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 300)); - AppRouter.router.go('/popup-list-interaction'); + router.go('/popup-list-interaction'); await tester.pumpAndSettle(); - AppRouter.router.go('/popup-list-interaction/list'); + router.go('/popup-list-interaction/list'); await tester.pumpAndSettle(); expect(find.text('二维滚动表格演示'), findsOneWidget); }); diff --git a/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart b/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart index f8624cf..dad7c1a 100644 --- a/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart +++ b/apps/flutter_forge/integration_test/flutter_scene_3d_flow_test.dart @@ -3,17 +3,26 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/app_platform_provider.dart'; import 'package:flutter_forge_app/app/router/app_router.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late AppPlatformSnapshot platform; + late GoRouter router; setUp(() { - AppRouter.router.go('/'); + platform = AppPlatformSnapshot.detect(); + router = AppRouter.create(platform)..go('/'); }); + tearDown(() => router.dispose()); + testWidgets('3D viewer desktop interaction flow', (tester) async { expect( {TargetPlatform.macOS, TargetPlatform.windows}, @@ -21,7 +30,12 @@ void main() { reason: 'This acceptance flow requires a desktop Flutter GPU host.', ); - await tester.pumpWidget(const App()); + await tester.pumpWidget( + ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: App(router: router), + ), + ); await _pumpFrames(tester, const Duration(milliseconds: 600)); expect(find.text('Flutter 学习实验室'), findsOneWidget); diff --git a/apps/flutter_forge/integration_test/windows_acceptance_test.dart b/apps/flutter_forge/integration_test/windows_acceptance_test.dart index aabb493..1b32d2a 100644 --- a/apps/flutter_forge/integration_test/windows_acceptance_test.dart +++ b/apps/flutter_forge/integration_test/windows_acceptance_test.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/app_platform_provider.dart'; import 'package:flutter_forge_app/app/router/app_router.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -11,10 +14,18 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('Windows file picker returns from native dialog', (tester) async { - await tester.pumpWidget(const App()); + const platform = AppPlatformSnapshot(platform: AppTargetPlatform.windows); + final router = AppRouter.create(platform); + addTearDown(router.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: App(router: router), + ), + ); await tester.pump(const Duration(milliseconds: 500)); - AppRouter.router.go(_filePickerRoute); + router.go(_filePickerRoute); await tester.pump(const Duration(milliseconds: 500)); // README.md is a valid text-mode target. The test must not claim that the diff --git a/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart b/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart index eafa199..61cfdda 100644 --- a/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart +++ b/apps/flutter_forge/test/modules/platform/file_picker/file_picker_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/modules/platform/file_picker/module_root.dart'; @@ -13,19 +14,25 @@ void main() { (item) => item.path == '/file-picker', ); - expect(module.platformSupport.nativePlatforms, { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }); + expect(module.platformSupport.excludedPlatforms, {AppTargetPlatform.iOS}); for (final platform in [ - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, + AppTargetPlatform.android, + AppTargetPlatform.macOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, ]) { - expect(isModuleAvailable(module, platform, false), isTrue); + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); } - expect(isModuleAvailable(module, TargetPlatform.iOS, false), isFalse); + expect( + isModuleAvailable( + module, + const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), + ), + isFalse, + ); }); testWidgets('picked file shows its name and path', (tester) async { diff --git a/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart b/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart index 14cba0f..e7da280 100644 --- a/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart +++ b/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart @@ -1,6 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/modules/platform/online_video_player/module_root.dart'; @@ -14,27 +14,23 @@ void main() { (item) => item.path == '/online-video-player', ); - expect(module.platformSupport.nativePlatforms, { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }); + expect(module.platformSupport.excludedPlatforms, {AppTargetPlatform.iOS}); for (final platform in [ - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, + AppTargetPlatform.android, + AppTargetPlatform.macOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, ]) { - expect(isModuleAvailable(module, platform, false), isTrue); + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); } expect( - AppRouteTable.routes.where((route) => route.path == module.path), - { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }.contains(defaultTargetPlatform) - ? isNotEmpty - : isEmpty, + AppRouteTable.routesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), + ).where((route) => route.path == module.path), + isNotEmpty, ); }); diff --git a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart index a0dd1b3..7018ac5 100644 --- a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart +++ b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/modules/platform/usb_detector/module_entry.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -14,13 +15,24 @@ void main() { (item) => item.path == '/usb-detector', ); - expect(module.platformSupport.nativePlatforms, isEmpty); - for (final platform in TargetPlatform.values) { - expect(isModuleAvailable(module, platform, false), isFalse); + expect(module.platformSupport.excludedPlatforms, { + AppTargetPlatform.android, + AppTargetPlatform.iOS, + AppTargetPlatform.macOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + }); + for (final platform in AppTargetPlatform.values) { + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isFalse, + ); } expect( - AppRouteTable.routes.where((route) => route.path == module.path), - isEmpty, + AppRouteTable.routesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.android), + ).where((route) => route.path == module.path), + isNotEmpty, ); }); diff --git a/apps/flutter_forge/test/modules/platform/webview/webview_test.dart b/apps/flutter_forge/test/modules/platform/webview/webview_test.dart index 4d4de02..edc443d 100644 --- a/apps/flutter_forge/test/modules/platform/webview/webview_test.dart +++ b/apps/flutter_forge/test/modules/platform/webview/webview_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/modules/platform/webview/module_root.dart'; import 'webview_session_test.dart' show FakeWebViewBackend; @@ -10,10 +11,22 @@ void main() { final module = AppRouteTable.modules.singleWhere( (m) => m.path == '/webview', ); - expect(isModuleAvailable(module, TargetPlatform.android), isTrue); - expect(isModuleAvailable(module, TargetPlatform.windows), isTrue); - expect(isModuleAvailable(module, TargetPlatform.macOS), isTrue); - expect(isModuleAvailable(module, TargetPlatform.iOS), isFalse); + for (final platform in [ + AppTargetPlatform.android, + AppTargetPlatform.macOS, + AppTargetPlatform.windows, + ]) { + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); + } + for (final platform in [AppTargetPlatform.iOS, AppTargetPlatform.web]) { + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isFalse, + ); + } }); testWidgets( 'teaching page fits compact viewport and rejects invalid navigation', diff --git a/apps/flutter_forge/test/modules/popup_table/popup_list_interaction/popup_list_interaction_test.dart b/apps/flutter_forge/test/modules/popup_table/popup_list_interaction/popup_list_interaction_test.dart index 5fbeece..b4a57fe 100644 --- a/apps/flutter_forge/test/modules/popup_table/popup_list_interaction/popup_list_interaction_test.dart +++ b/apps/flutter_forge/test/modules/popup_table/popup_list_interaction/popup_list_interaction_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/modules/popup_table/popup_list_interaction/module_entry.dart'; import 'package:flutter_forge_app/modules/popup_table/popup_widgets/module_root.dart'; import 'package:go_router/go_router.dart'; @@ -13,7 +14,9 @@ void main() { testWidgets('popup nested route opens from its module page', (tester) async { final router = GoRouter( initialLocation: '/popup-list-interaction', - routes: AppRouteTable.routes, + routes: AppRouteTable.routesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), + ), ); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); @@ -31,7 +34,9 @@ void main() { testWidgets('list nested route opens from its module page', (tester) async { final router = GoRouter( initialLocation: '/popup-list-interaction', - routes: AppRouteTable.routes, + routes: AppRouteTable.routesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), + ), ); await tester.pumpWidget(MaterialApp.router(routerConfig: router)); await tester.pumpAndSettle(); diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart index 46126dd..6a3f6d8 100644 --- a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; import 'package:flutter_forge_app/modules/ui/flutter_scene_3d/module_root.dart'; @@ -249,30 +249,25 @@ void main() { ); expect(module.status, ModuleStatus.ready); - expect(module.platformSupport.nativePlatforms, { - TargetPlatform.macOS, - TargetPlatform.windows, - TargetPlatform.android, + expect(module.platformSupport.excludedPlatforms, { + AppTargetPlatform.iOS, + AppTargetPlatform.web, }); - expect(isModuleAvailable(module, TargetPlatform.macOS), isTrue); - expect(isModuleAvailable(module, TargetPlatform.windows), isTrue); - expect(isModuleAvailable(module, TargetPlatform.android), isTrue); for (final platform in [ - TargetPlatform.iOS, - TargetPlatform.linux, - TargetPlatform.fuchsia, + AppTargetPlatform.macOS, + AppTargetPlatform.windows, + AppTargetPlatform.android, ]) { - expect(isModuleAvailable(module, platform), isFalse); + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); } expect( - AppRouteTable.routes.where((route) => route.path == module.path), - { - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, - }.contains(defaultTargetPlatform) - ? isNotEmpty - : isEmpty, + AppRouteTable.routesFor( + const AppPlatformSnapshot(platform: AppTargetPlatform.web), + ).where((route) => route.path == module.path), + isNotEmpty, ); }); } diff --git a/apps/flutter_forge/test/shared/app_platform_snapshot_test.dart b/apps/flutter_forge/test/shared/app_platform_snapshot_test.dart new file mode 100644 index 0000000..083c53d --- /dev/null +++ b/apps/flutter_forge/test/shared/app_platform_snapshot_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Web identity wins over the browser operating system', () { + final snapshot = AppPlatformSnapshot.fromFlutter( + isWeb: true, + targetPlatform: TargetPlatform.macOS, + ); + + expect(snapshot.platform, AppTargetPlatform.web); + expect(snapshot.hostFamily, AppHostFamily.web); + }); + + test('maps the five product target platforms', () { + const expected = { + TargetPlatform.android: AppTargetPlatform.android, + TargetPlatform.iOS: AppTargetPlatform.iOS, + TargetPlatform.macOS: AppTargetPlatform.macOS, + TargetPlatform.windows: AppTargetPlatform.windows, + }; + + for (final entry in expected.entries) { + final snapshot = AppPlatformSnapshot.fromFlutter( + isWeb: false, + targetPlatform: entry.key, + ); + expect(snapshot.platform, entry.value); + expect(snapshot.isProductTarget, isTrue); + } + }); + + test('maps Linux and Fuchsia outside the product target set', () { + for (final platform in [TargetPlatform.linux, TargetPlatform.fuchsia]) { + final snapshot = AppPlatformSnapshot.fromFlutter( + isWeb: false, + targetPlatform: platform, + ); + expect(snapshot.platform, AppTargetPlatform.unsupported); + expect(snapshot.isProductTarget, isFalse); + } + }); +} diff --git a/apps/flutter_forge/test/shared/module_catalog_utils_test.dart b/apps/flutter_forge/test/shared/module_catalog_utils_test.dart index ef318d4..628e8f3 100644 --- a/apps/flutter_forge/test/shared/module_catalog_utils_test.dart +++ b/apps/flutter_forge/test/shared/module_catalog_utils_test.dart @@ -1,36 +1,44 @@ import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; import 'package:flutter_forge_app/module_registry/module_entry.dart'; import 'package:flutter_forge_app/module_registry/module_platform_support.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; void main() { + const android = AppPlatformSnapshot(platform: AppTargetPlatform.android); + const macOS = AppPlatformSnapshot(platform: AppTargetPlatform.macOS); + const web = AppPlatformSnapshot(platform: AppTargetPlatform.web); + const windows = AppPlatformSnapshot(platform: AppTargetPlatform.windows); + const unsupported = AppPlatformSnapshot( + platform: AppTargetPlatform.unsupported, + ); + ModuleEntry createModule({ required String path, required ModuleCategory category, List routes = const [], - Set? nativePlatforms, - bool web = true, - }) { - return ModuleEntry( - title: path, - path: path, - subtitle: 'test', - category: category, - difficulty: Difficulty.beginner, - concepts: const ['test'], - estimatedMinutes: 1, - status: ModuleStatus.ready, - builder: (_) => const SizedBox.shrink(), - routes: routes, - platformSupport: ModulePlatformSupport( - nativePlatforms: nativePlatforms, - web: web, - ), - ); - } + Set excludedPlatforms = const {}, + }) => ModuleEntry( + title: path, + path: path, + subtitle: 'test', + category: category, + difficulty: Difficulty.beginner, + concepts: const ['test'], + estimatedMinutes: 1, + status: ModuleStatus.ready, + builder: (_) => const SizedBox.shrink(), + routes: routes, + platformSupport: ModulePlatformSupport( + excludedPlatforms: excludedPlatforms, + ), + ); + + Widget unsupportedBuilder(BuildContext context, ModuleEntry module) => + const Text('unsupported'); test('filters modules without changing catalog order', () { final modules = [ @@ -38,85 +46,68 @@ void main() { createModule(path: '/ui-a', category: ModuleCategory.ui), createModule(path: '/basic-b', category: ModuleCategory.basic), ]; - - final filtered = filterModulesByCategory(modules, ModuleCategory.basic); - - expect(filtered.map((module) => module.path), ['/basic-a', '/basic-b']); + expect( + filterModulesByCategory(modules, ModuleCategory.basic).map((m) => m.path), + ['/basic-a', '/basic-b'], + ); }); test('rebases module and child paths for a category window', () { - final modules = [ - createModule( - path: '/basic-a', - category: ModuleCategory.basic, - routes: [ - GoRoute( - path: '/details', - builder: (_, __) => const SizedBox.shrink(), - ), - ], - ), - ]; - - final routes = buildCategoryRoutes(modules); - - expect(routes.single.path, 'basic-a'); - expect((routes.single.routes.single as GoRoute).path, 'details'); - }); - - test('platform-neutral modules are available by default', () { final module = createModule( - path: '/platform-neutral', + path: '/basic-a', category: ModuleCategory.basic, + routes: [ + GoRoute(path: '/details', builder: (_, __) => const SizedBox.shrink()), + ], ); - - expect(isModuleAvailable(module, TargetPlatform.android, false), isTrue); - expect(isModuleAvailable(module, TargetPlatform.windows, false), isTrue); - expect(isModuleAvailable(module, TargetPlatform.macOS, true), isTrue); + final route = buildCategoryRoutes( + [module], + android, + unsupportedBuilder: unsupportedBuilder, + ).single; + expect(route.path, 'basic-a'); + expect((route.routes.single as GoRoute).path, 'details'); }); - test('Web does not inherit the browser operating system availability', () { + test('target platforms are available unless explicitly excluded', () { final module = createModule( - path: '/macos-only', - category: ModuleCategory.platform, - nativePlatforms: {TargetPlatform.macOS}, - web: false, - ); - - expect(isModuleAvailable(module, TargetPlatform.macOS, true), isFalse); - }); - - test('platform-restricted modules can explicitly opt in to Web', () { - final module = createModule( - path: '/web-capable', - category: ModuleCategory.platform, - nativePlatforms: {TargetPlatform.macOS}, + path: '/platform-neutral', + category: ModuleCategory.basic, ); - - expect(isModuleAvailable(module, TargetPlatform.windows, true), isTrue); + for (final platform in [android, macOS, web, windows]) { + expect(isModuleAvailable(module, platform), isTrue); + } + expect(isModuleAvailable(module, unsupported), isFalse); }); - test('platform-restricted modules only match declared platforms', () { + test('excluded platforms are unavailable', () { final module = createModule( path: '/macos-only', category: ModuleCategory.platform, - nativePlatforms: {TargetPlatform.macOS}, - web: false, + excludedPlatforms: { + AppTargetPlatform.android, + AppTargetPlatform.iOS, + AppTargetPlatform.web, + AppTargetPlatform.windows, + }, ); - - expect(isModuleAvailable(module, TargetPlatform.macOS, false), isTrue); - expect(isModuleAvailable(module, TargetPlatform.windows, false), isFalse); - expect(availableModules([module], TargetPlatform.windows, false), isEmpty); + expect(isModuleAvailable(module, macOS), isTrue); + expect(isModuleAvailable(module, windows), isFalse); + expect(availableModules([module], windows), isEmpty); }); - test('unavailable modules are excluded from category routes', () { + test('unavailable modules keep a guarded category route', () { final module = createModule( path: '/windows-only', category: ModuleCategory.platform, - nativePlatforms: {TargetPlatform.windows}, - web: false, + excludedPlatforms: {AppTargetPlatform.android}, ); - - expect(buildCategoryRoutes([module]), isEmpty); + final route = buildCategoryRoutes( + [module], + android, + unsupportedBuilder: unsupportedBuilder, + ).single; + expect(route.path, 'windows-only'); + expect(route.routes, isEmpty); }); } diff --git a/apps/flutter_forge/test/shared/navigation_policy_test.dart b/apps/flutter_forge/test/shared/navigation_policy_test.dart index bee80de..01456aa 100644 --- a/apps/flutter_forge/test/shared/navigation_policy_test.dart +++ b/apps/flutter_forge/test/shared/navigation_policy_test.dart @@ -1,12 +1,14 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_forge_app/app/navigation_policy.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('Android always uses in-app navigation', () { expect( NavigationPolicy.resolve( - platform: TargetPlatform.android, + platform: const AppPlatformSnapshot( + platform: AppTargetPlatform.android, + ), width: 1200, multiWindowSupported: true, ), @@ -17,7 +19,7 @@ void main() { test('iOS always uses in-app navigation', () { expect( NavigationPolicy.resolve( - platform: TargetPlatform.iOS, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), width: 1200, multiWindowSupported: true, ), @@ -28,10 +30,9 @@ void main() { test('Web always uses in-app navigation on a desktop browser', () { expect( NavigationPolicy.resolve( - platform: TargetPlatform.macOS, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.web), width: 1200, multiWindowSupported: true, - isWeb: true, ), CategoryNavigationMode.inApp, ); @@ -40,7 +41,7 @@ void main() { test('compact desktop windows use in-app navigation', () { expect( NavigationPolicy.resolve( - platform: TargetPlatform.macOS, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), width: 599, multiWindowSupported: true, ), @@ -51,7 +52,7 @@ void main() { test('600dp desktop windows can use separate windows', () { expect( NavigationPolicy.resolve( - platform: TargetPlatform.macOS, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), width: 600, multiWindowSupported: true, ), @@ -62,7 +63,9 @@ void main() { test('unsupported multi-window hosts fall back to in-app navigation', () { expect( NavigationPolicy.resolve( - platform: TargetPlatform.linux, + platform: const AppPlatformSnapshot( + platform: AppTargetPlatform.unsupported, + ), width: 1200, multiWindowSupported: false, ), diff --git a/apps/flutter_forge/test/shared/platform_route_guard_test.dart b/apps/flutter_forge/test/shared/platform_route_guard_test.dart new file mode 100644 index 0000000..bca17fb --- /dev/null +++ b/apps/flutter_forge/test/shared/platform_route_guard_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/app_platform_provider.dart'; +import 'package:flutter_forge_app/app/router/app_router.dart'; +import 'package:flutter_forge_app/app/unsupported_module_page.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future pumpAt( + WidgetTester tester, { + required AppPlatformSnapshot platform, + required String location, + }) async { + final router = AppRouter.create(platform)..go(location); + addTearDown(router.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [appPlatformProvider.overrideWithValue(platform)], + child: App(router: router), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('unsupported module path renders the stable guard page', ( + tester, + ) async { + await pumpAt( + tester, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), + location: '/file-picker', + ); + + expect(find.byType(UnsupportedModulePage), findsOneWidget); + expect(find.text('当前平台暂不支持此模块'), findsOneWidget); + expect(find.text('当前平台:iOS'), findsOneWidget); + expect(find.text('支持平台:Android、macOS、Web、Windows'), findsOneWidget); + + await tester.tap(find.text('返回模块目录')); + await tester.pumpAndSettle(); + expect(find.text('Flutter 学习实验室'), findsOneWidget); + }); + + testWidgets('supported module path renders its business page', ( + tester, + ) async { + await pumpAt( + tester, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.android), + location: '/file-picker', + ); + + expect(find.byType(UnsupportedModulePage), findsNothing); + expect(find.byKey(const Key('pick-file-button')), findsOneWidget); + }); +} diff --git a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart index 336b505..e598ef7 100644 --- a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart +++ b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart @@ -1,20 +1,30 @@ import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_forge_app/app/category_window_app.dart'; import 'package:flutter_forge_app/app/module_home_page.dart'; +import 'package:flutter_forge_app/app/app_platform_provider.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('module home fits a 360dp viewport', (tester) async { await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData( - size: Size(360, 800), - padding: EdgeInsets.only(top: 24, bottom: 24), - ), - child: MaterialApp( - home: ModuleHomePage(modules: AppRouteTable.modules), + ProviderScope( + overrides: [ + appPlatformProvider.overrideWithValue( + const AppPlatformSnapshot(platform: AppTargetPlatform.android), + ), + ], + child: MediaQuery( + data: const MediaQueryData( + size: Size(360, 800), + padding: EdgeInsets.only(top: 24, bottom: 24), + ), + child: MaterialApp( + home: ModuleHomePage(modules: AppRouteTable.modules), + ), ), ), ); @@ -34,6 +44,9 @@ void main() { home: CategoryHomePage( category: ModuleCategory.basic, modules: AppRouteTable.modules, + platform: const AppPlatformSnapshot( + platform: AppTargetPlatform.android, + ), ), ), ), diff --git a/apps/flutter_forge/test/shared/web_compatibility_test.dart b/apps/flutter_forge/test/shared/web_compatibility_test.dart index 62e909b..efba598 100644 --- a/apps/flutter_forge/test/shared/web_compatibility_test.dart +++ b/apps/flutter_forge/test/shared/web_compatibility_test.dart @@ -3,6 +3,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; import 'package:flutter_forge_app/modules/platform/dio_interceptor/module_entry.dart'; import 'package:flutter_forge_app/modules/platform/file_picker/module_entry.dart'; @@ -12,6 +13,8 @@ import 'package:flutter_forge_app/modules/ui/gcode_visualizer/module_entry.dart' import 'package:flutter_test/flutter_test.dart'; void main() { + const web = AppPlatformSnapshot(platform: AppTargetPlatform.web); + test('Web catalog keeps native-only capabilities unavailable', () { for (final path in [ '/isolate-basic', @@ -23,7 +26,7 @@ void main() { final module = AppRouteTable.modules.singleWhere( (entry) => entry.path == path, ); - expect(isModuleAvailable(module), isFalse, reason: path); + expect(isModuleAvailable(module, web), isFalse, reason: path); } }); @@ -36,8 +39,7 @@ void main() { final module = AppRouteTable.modules.singleWhere( (entry) => entry.path == path, ); - expect(isModuleAvailable(module), isTrue, reason: path); - expect(module.platformSupport.web, isTrue, reason: path); + expect(isModuleAvailable(module, web), isTrue, reason: path); } }); @@ -59,14 +61,16 @@ void main() { ); for (final platform in [ - TargetPlatform.android, - TargetPlatform.macOS, - TargetPlatform.windows, + AppTargetPlatform.android, + AppTargetPlatform.macOS, + AppTargetPlatform.windows, ]) { - expect(isModuleAvailable(module, platform, false), isTrue); + expect( + isModuleAvailable(module, AppPlatformSnapshot(platform: platform)), + isTrue, + ); } - expect(module.platformSupport.nativePlatforms, isNull); - expect(module.platformSupport.web, isTrue); + expect(module.platformSupport.excludedPlatforms, isEmpty); }); testWidgets('Web entries render safe compatibility states', (tester) async { From 50d127a06f4dcc096ffdeb263bca89f46571634c Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 19:55:41 +0800 Subject: [PATCH 14/21] chore(agents): regenerate contracts for platform snapshot Drive module platform contracts from excludedPlatforms in the generator, validate the five-target set and excluded platforms, and regenerate the module index, module contracts, project context and refactor plan. --- AI_PROJECT_CONTEXT.md | 14 +- REFACTOR_PLAN.md | 30 ++- apps/flutter_forge/lib/AI_MODULE_INDEX.md | 239 ++++++++++++++---- apps/flutter_forge/lib/app/AI_ANALYSIS.md | 4 + .../lib/app/router/AI_ANALYSIS.md | 1 + .../lib/module_registry/AI_ANALYSIS.md | 6 +- .../async/isolate_basic/AI_ANALYSIS.md | 12 +- .../async/isolate_task_manager/AI_ANALYSIS.md | 12 +- .../async/stream_subscription/AI_ANALYSIS.md | 10 +- .../basic/debounce_throttle/AI_ANALYSIS.md | 10 +- .../modules/basic/microtask/AI_ANALYSIS.md | 10 +- .../modules/basic/tree_state/AI_ANALYSIS.md | 10 +- .../platform/dio_interceptor/AI_ANALYSIS.md | 10 +- .../platform/file_picker/AI_ANALYSIS.md | 8 +- .../online_video_player/AI_ANALYSIS.md | 8 +- .../platform/usb_detector/AI_ANALYSIS.md | 16 +- .../modules/platform/webview/AI_ANALYSIS.md | 9 +- .../overlay_follow_compare/AI_ANALYSIS.md | 10 +- .../popup_list_interaction/AI_ANALYSIS.md | 10 +- .../popup_table/popup_widgets/AI_ANALYSIS.md | 10 +- .../popup_table/scroll_table/AI_ANALYSIS.md | 10 +- .../modules/state/flutter_ioc/AI_ANALYSIS.md | 10 +- .../state/local_persistence/AI_ANALYSIS.md | 10 +- .../state/status_management/AI_ANALYSIS.md | 10 +- .../modules/ui/adsorption_line/AI_ANALYSIS.md | 10 +- .../ui/download_animation/AI_ANALYSIS.md | 10 +- .../ui/flutter_scene_3d/AI_ANALYSIS.md | 9 +- .../lib/modules/ui/font_picker/AI_ANALYSIS.md | 10 +- .../ui/gcode_visualizer/AI_ANALYSIS.md | 15 +- tool/generate_agent_indexes.js | 157 +++++++----- tool/validate_agent_docs.js | 11 +- 31 files changed, 538 insertions(+), 163 deletions(-) diff --git a/AI_PROJECT_CONTEXT.md b/AI_PROJECT_CONTEXT.md index a43b439..20551f3 100644 --- a/AI_PROJECT_CONTEXT.md +++ b/AI_PROJECT_CONTEXT.md @@ -17,6 +17,7 @@ "next_host": "web", "target_hosts": [ "android", + "ios", "macos", "web", "windows" @@ -110,6 +111,7 @@ "path": "lib/module_registry", "owns": [ "module_metadata", + "platform_snapshot", "catalog_operations" ], "may_depend_on": [ @@ -169,7 +171,17 @@ "compact_width_breakpoint_dp": 600, "mobile_window_policy": "in_app_navigation_only", "web_window_policy": "in_app_navigation_only", - "web_platform_detection": "kIsWeb_before_defaultTargetPlatform", + "platform_snapshot": "bootstrap_initialized_process_immutable", + "target_platforms": [ + "android", + "ios", + "macos", + "web", + "windows" + ], + "non_target_platforms": "unsupported", + "module_platform_semantics": "target_platform_available_unless_excluded", + "unsupported_route": "stable_guarded_module_route", "web_release_build": "bash tool/build_web_release.sh", "web_startup_shell": "web/index.html + web/flutter_bootstrap.js", "platform_capability_contract": "business_neutral_interface" diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index dd5b9a8..4735fef 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -13,7 +13,8 @@ "agent_takeover_ready", "pc_window_lifecycle_baseline", "pc_build_matrix", - "flutter_scene_3d_macos_baseline" + "flutter_scene_3d_macos_baseline", + "platform_snapshot_guarded_routes" ], "dependency_migration": { "layout": "pub_workspace", @@ -433,6 +434,33 @@ "catalog and compact widget contracts cover Android entry", "Android real-device document provider behavior remains required" ] + }, + { + "id": "platform_snapshot_guarded_routes", + "priority": 20, + "status": "completed", + "targets": [ + "apps/flutter_forge/lib/module_registry", + "apps/flutter_forge/lib/app", + "apps/flutter_forge/test/shared" + ], + "acceptance": [ + "process_immutable_platform_snapshot", + "five_product_target_platforms", + "excluded_platform_contract", + "stable_guarded_module_routes", + "unsupported_module_explanation", + "android_debug_apk_build", + "web_release_build" + ], + "evidence": [ + "bootstrap injects one AppPlatformSnapshot into Router and ProviderScope", + "module contracts use target_platforms and excluded_platforms", + "guarded route tests cover supported and unsupported module paths", + "bare analyze and full test suite pass", + "Android debug APK and Web release builds pass", + "iOS is modeled but has no host directory or build evidence" + ] } ], "quality_gate": [ diff --git a/apps/flutter_forge/lib/AI_MODULE_INDEX.md b/apps/flutter_forge/lib/AI_MODULE_INDEX.md index 4ed5b3c..d2ae6fa 100644 --- a/apps/flutter_forge/lib/AI_MODULE_INDEX.md +++ b/apps/flutter_forge/lib/AI_MODULE_INDEX.md @@ -15,8 +15,14 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/basic/tree_state/AI_ANALYSIS.md" }, @@ -32,8 +38,14 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/basic/microtask/AI_ANALYSIS.md" }, @@ -48,8 +60,14 @@ "module_registry" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/basic/debounce_throttle/AI_ANALYSIS.md" }, @@ -65,8 +83,14 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/async/stream_subscription/AI_ANALYSIS.md" }, @@ -82,8 +106,16 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": false + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "web" + ] }, "analysis": "lib/modules/async/isolate_basic/AI_ANALYSIS.md" }, @@ -98,8 +130,16 @@ "module_registry" ], "platform_support": { - "native_platforms": "all", - "web": false + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "web" + ] }, "analysis": "lib/modules/async/isolate_task_manager/AI_ANALYSIS.md" }, @@ -118,8 +158,14 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/state/status_management/AI_ANALYSIS.md" }, @@ -136,8 +182,14 @@ "module_registry" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/state/flutter_ioc/AI_ANALYSIS.md" }, @@ -153,8 +205,14 @@ "module_registry" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/state/local_persistence/AI_ANALYSIS.md" }, @@ -171,10 +229,19 @@ "module_registry" ], "platform_support": { - "native_platforms": [ - "macOS" + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" ], - "web": false + "excluded_platforms": [ + "android", + "iOS", + "web", + "windows" + ] }, "analysis": "lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md" }, @@ -190,8 +257,14 @@ "module_registry" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/ui/adsorption_line/AI_ANALYSIS.md" }, @@ -207,8 +280,14 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/ui/download_animation/AI_ANALYSIS.md" }, @@ -225,8 +304,14 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/ui/font_picker/AI_ANALYSIS.md" }, @@ -243,12 +328,17 @@ "module_registry" ], "platform_support": { - "native_platforms": [ + "target_platforms": [ "android", + "iOS", "macOS", + "web", "windows" ], - "web": false + "excluded_platforms": [ + "iOS", + "web" + ] }, "analysis": "lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md" }, @@ -263,8 +353,14 @@ "module_registry" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md" }, @@ -280,8 +376,14 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md" }, @@ -297,8 +399,14 @@ "module_registry" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/popup_table/scroll_table/AI_ANALYSIS.md" }, @@ -313,8 +421,14 @@ "module_registry" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md" }, @@ -331,8 +445,14 @@ "go_router" ], "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "analysis": "lib/modules/platform/dio_interceptor/AI_ANALYSIS.md" }, @@ -348,8 +468,20 @@ "module_registry" ], "platform_support": { - "native_platforms": [], - "web": false + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ] }, "analysis": "lib/modules/platform/usb_detector/AI_ANALYSIS.md" }, @@ -365,12 +497,16 @@ "module_registry" ], "platform_support": { - "native_platforms": [ + "target_platforms": [ "android", + "iOS", "macOS", + "web", "windows" ], - "web": true + "excluded_platforms": [ + "iOS" + ] }, "analysis": "lib/modules/platform/file_picker/AI_ANALYSIS.md" }, @@ -389,12 +525,16 @@ "module_registry" ], "platform_support": { - "native_platforms": [ + "target_platforms": [ "android", + "iOS", "macOS", + "web", "windows" ], - "web": true + "excluded_platforms": [ + "iOS" + ] }, "analysis": "lib/modules/platform/online_video_player/AI_ANALYSIS.md" }, @@ -411,12 +551,17 @@ "webview_windows" ], "platform_support": { - "native_platforms": [ + "target_platforms": [ "android", + "iOS", "macOS", + "web", "windows" ], - "web": false + "excluded_platforms": [ + "iOS", + "web" + ] }, "analysis": "lib/modules/platform/webview/AI_ANALYSIS.md" } diff --git a/apps/flutter_forge/lib/app/AI_ANALYSIS.md b/apps/flutter_forge/lib/app/AI_ANALYSIS.md index 28f567c..dbc4282 100644 --- a/apps/flutter_forge/lib/app/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/app/AI_ANALYSIS.md @@ -11,7 +11,9 @@ "entrypoints": [ "app.dart", "app_bootstrap.dart", + "app_platform_provider.dart", "module_home_page.dart", + "unsupported_module_page.dart", "category_navigation.dart", "navigation_policy.dart", "category_window_app.dart", @@ -20,9 +22,11 @@ ], "owns": [ "host_bootstrap", + "platform_snapshot_injection", "material_app_router", "router", "module_home", + "unsupported_module_route", "responsive_navigation_policy", "adaptive_category_navigation", "desktop_category_window_shell" diff --git a/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md b/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md index c79b97b..19553dc 100644 --- a/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md @@ -14,6 +14,7 @@ ], "owns": [ "go_router_root", + "stable_guarded_module_routes", "module_route_aggregation", "module_catalog_composition" ], diff --git a/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md b/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md index 0d3c8fb..b69cfdd 100644 --- a/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md @@ -9,16 +9,20 @@ "status": "active" }, "entrypoints": [ + "app_platform_snapshot.dart", "module_entry.dart", "module_category.dart", + "module_platform_support.dart", "module_catalog_utils.dart" ], "owns": [ + "platform_snapshot", + "product_target_platforms", "module_entry_model", "module_category_enum", "difficulty_enum", "module_status_enum", - "native_web_availability", + "excluded_platform_availability", "module_catalog_filtering", "category_route_rebasing" ], diff --git a/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md index 4058d4d..24a66ac 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md @@ -11,8 +11,16 @@ "route": "/isolate-basic", "category": "async", "platform_support": { - "native_platforms": "all", - "web": false + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "web" + ] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md index cf411f2..5301ab3 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md @@ -11,8 +11,16 @@ "route": "/isolate-stream", "category": "async", "platform_support": { - "native_platforms": "all", - "web": false + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "web" + ] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md index 23c710d..14fef40 100644 --- a/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/stream-subscription", "category": "async", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md index 9ea2aa0..b32b45a 100644 --- a/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/debounce-throttle", "category": "basic", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md index e01fe76..a38789b 100644 --- a/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/microtask", "category": "basic", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md index c20b094..05ebe51 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/tree-state", "category": "basic", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md index 8c72b23..775e521 100644 --- a/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/dio-interceptor", "category": "platform", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md index 4c09ff9..23680d4 100644 --- a/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md @@ -11,12 +11,16 @@ "route": "/file-picker", "category": "platform", "platform_support": { - "native_platforms": [ + "target_platforms": [ "android", + "iOS", "macOS", + "web", "windows" ], - "web": true + "excluded_platforms": [ + "iOS" + ] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md index 6c0560e..49f767e 100644 --- a/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md @@ -11,12 +11,16 @@ "route": "/online-video-player", "category": "platform", "platform_support": { - "native_platforms": [ + "target_platforms": [ "android", + "iOS", "macOS", + "web", "windows" ], - "web": true + "excluded_platforms": [ + "iOS" + ] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md index 6fe2925..7285575 100644 --- a/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md @@ -11,8 +11,20 @@ "route": "/usb-detector", "category": "platform", "platform_support": { - "native_platforms": [], - "web": false + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md index c1add7c..4da1a8f 100644 --- a/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md @@ -11,12 +11,17 @@ "route": "/webview", "category": "platform", "platform_support": { - "native_platforms": [ + "target_platforms": [ "android", + "iOS", "macOS", + "web", "windows" ], - "web": false + "excluded_platforms": [ + "iOS", + "web" + ] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md index 77db7d6..7dabf02 100644 --- a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/overlay-compare", "category": "popup_table", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md index a1e201b..2e0e78b 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/popup-list-interaction", "category": "popup_table", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md index 64af072..40c21b1 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/popup-widgets", "category": "popup_table", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md index 70eb212..df7e809 100644 --- a/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/scroll-table", "category": "popup_table", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md index b8326ce..5c594a1 100644 --- a/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/flutter-ioc", "category": "state", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md index 738fde0..726cbe0 100644 --- a/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/local-persistence", "category": "state", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md index 4c255b7..6044e52 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/status-management", "category": "state", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md index 317812f..b289d22 100644 --- a/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/adsorption-line", "category": "ui", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md index e5a13d6..89d7469 100644 --- a/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/download-animation", "category": "ui", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md index 528b72f..7f52b90 100644 --- a/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/flutter_scene_3d/AI_ANALYSIS.md @@ -11,12 +11,17 @@ "route": "/flutter-scene-3d", "category": "ui", "platform_support": { - "native_platforms": [ + "target_platforms": [ "android", + "iOS", "macOS", + "web", "windows" ], - "web": false + "excluded_platforms": [ + "iOS", + "web" + ] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md index 24ce93f..a2b8b7d 100644 --- a/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md @@ -11,8 +11,14 @@ "route": "/font-picker", "category": "ui", "platform_support": { - "native_platforms": "all", - "web": true + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" + ], + "excluded_platforms": [] }, "entrypoints": [ "module_entry.dart", diff --git a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md index 4f991e9..65d8b68 100644 --- a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md @@ -11,10 +11,19 @@ "route": "/gcode-visualizer", "category": "ui", "platform_support": { - "native_platforms": [ - "macOS" + "target_platforms": [ + "android", + "iOS", + "macOS", + "web", + "windows" ], - "web": false + "excluded_platforms": [ + "android", + "iOS", + "web", + "windows" + ] }, "entrypoints": [ "module_entry.dart", diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index f517ae3..d9ac5d2 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -79,8 +79,8 @@ const modules = [ estimatedMinutes: 20, entry: 'IsolateTestEntry', routes: 'IsolateTestRoutes', - supportsWeb: false, - supportsWebComment: '// Safari Web validation showed no usable progress lifecycle for Isolate.spawn.', + excludedPlatforms: ['web'], + platformSupportComment: '// Safari Web validation showed no usable progress lifecycle for Isolate.spawn.', }, { category: 'async', @@ -94,8 +94,8 @@ const modules = [ concepts: ['Isolate.spawn', '多任务', '进度上报', '暂停/恢复'], estimatedMinutes: 35, entry: 'IsolateStreamEntry', - supportsWeb: false, - supportsWebComment: '// Safari Web validation showed tasks stalled at zero progress.', + excludedPlatforms: ['web'], + platformSupportComment: '// Safari Web validation showed tasks stalled at zero progress.', }, { category: 'state', @@ -143,8 +143,8 @@ const modules = [ id: 'gcode_visualizer', route: '/gcode-visualizer', status: 'ready', - supportedPlatforms: ['macOS'], - supportedPlatformsComment: '// gcode_core v0.2.0-dev.1 validates macOS GPU rendering only.', + excludedPlatforms: ['android', 'iOS', 'web', 'windows'], + platformSupportComment: '// gcode_core v0.2.0-dev.1 validates macOS GPU rendering only.', depends: ['shared_learning', 'gcode_core', 'file_picker_bridge', 'module_registry'], title: 'G-code 解析与轨迹动画', subtitle: '解析 G-code 指令,绘制刀路轨迹并用动画展示执行过程', @@ -206,8 +206,8 @@ const modules = [ concepts: ['轨道相机', 'Scene Raycast', '部件聚焦', 'Reduced Motion'], estimatedMinutes: 35, entry: 'FlutterScene3dEntry', - supportedPlatforms: ['android', 'macOS', 'windows'], - supportedPlatformsComment: '// Android is view-only; Windows and Android host evidence remains pending.', + excludedPlatforms: ['iOS', 'web'], + platformSupportComment: '// Android is view-only; Windows and Android host evidence remains pending.', }, { category: 'popup_table', @@ -275,8 +275,8 @@ const modules = [ estimatedMinutes: 35, entry: 'InterceptorTestEntry', routes: 'InterceptorTestRoutes', - supportsWeb: true, - supportsWebComment: '// Web uses an in-memory Dio adapter; native hosts keep the localhost mock server.', + excludedPlatforms: [], + platformSupportComment: '// Web uses an in-memory Dio adapter; native hosts keep the localhost mock server.', }, { category: 'platform', @@ -290,8 +290,8 @@ const modules = [ concepts: ['Android USB', 'MethodChannel', 'Stream 广播', '设备扫描'], estimatedMinutes: 25, entry: 'UsbDetectorEntry', - supportedPlatforms: [], - supportedPlatformsComment: '// Disabled on every host until the module is reframed as a concrete OTG workflow.', + excludedPlatforms: ['android', 'iOS', 'macOS', 'web', 'windows'], + platformSupportComment: '// Disabled on every target until the module is reframed as a concrete OTG workflow.', }, { category: 'platform', @@ -305,10 +305,8 @@ const modules = [ concepts: ['FilePickerService', 'MethodChannel', '平台桥接', '扩展名过滤', '取消分支'], estimatedMinutes: 20, entry: 'FilePickerEntry', - supportedPlatforms: ['android', 'macOS', 'windows'], - supportedPlatformsComment: '// Android uses file_selector and requires real-device provider validation.', - supportsWeb: true, - supportsWebComment: '// Web accepts one user-selected file and exposes only its filename.', + excludedPlatforms: ['iOS'], + platformSupportComment: '// Android uses file_selector; Web exposes only the selected filename.', }, { category: 'platform', @@ -322,10 +320,8 @@ const modules = [ concepts: ['video_player', '平台播放器', 'HTTP 流', '播放控制', '倍速', 'Controller 生命周期'], estimatedMinutes: 35, entry: 'OnlineVideoPlayerEntry', - supportedPlatforms: ['android', 'macOS', 'windows'], - supportedPlatformsComment: '// Android/macOS use official video_player backends; Windows uses video_player_win.', - supportsWeb: true, - supportsWebComment: '// Web uses a same-origin media asset and starts playback from a user gesture.', + excludedPlatforms: ['iOS'], + platformSupportComment: '// Web uses same-origin media; native targets use their registered backends.', }, { category: 'platform', @@ -339,12 +335,18 @@ const modules = [ concepts: ['WebView', 'WebView2', '加载进度', '生命周期'], estimatedMinutes: 30, entry: 'WebViewEntry', - supportedPlatforms: ['android', 'macOS', 'windows'], - supportedPlatformsComment: '// Android/macOS use webview_flutter; Windows uses WebView2.', + excludedPlatforms: ['iOS', 'web'], + platformSupportComment: '// Android/macOS use webview_flutter; Windows uses WebView2.', }, ]; +for (const module of modules) { + if (module.category === 'platform' && module.excludedPlatforms === undefined) { + throw new Error(`Platform module ${module.id} must declare excludedPlatforms`); + } +} + const categoryComments = { basic: '基础机制', async: '异步并发', @@ -556,7 +558,7 @@ function writeProjectContext() { platform: { current_hosts: ['macos', 'windows'], next_host: 'web', - target_hosts: ['android', 'macos', 'web', 'windows'], + target_hosts: ['android', 'ios', 'macos', 'web', 'windows'], }, entrypoints: { process: 'lib/main.dart', @@ -591,7 +593,7 @@ function writeProjectContext() { { id: 'module_registry', path: 'lib/module_registry', - owns: ['module_metadata', 'catalog_operations'], + owns: ['module_metadata', 'platform_snapshot', 'catalog_operations'], may_depend_on: ['flutter', 'go_router'], }, { @@ -623,7 +625,11 @@ function writeProjectContext() { compact_width_breakpoint_dp: 600, mobile_window_policy: 'in_app_navigation_only', web_window_policy: 'in_app_navigation_only', - web_platform_detection: 'kIsWeb_before_defaultTargetPlatform', + platform_snapshot: 'bootstrap_initialized_process_immutable', + target_platforms: ['android', 'ios', 'macos', 'web', 'windows'], + non_target_platforms: 'unsupported', + module_platform_semantics: 'target_platform_available_unless_excluded', + unsupported_route: 'stable_guarded_module_route', web_release_build: 'bash tool/build_web_release.sh', web_startup_shell: 'web/index.html + web/flutter_bootstrap.js', platform_capability_contract: 'business_neutral_interface', @@ -686,6 +692,7 @@ function writeRefactorPlan() { 'pc_window_lifecycle_baseline', 'pc_build_matrix', 'flutter_scene_3d_macos_baseline', + 'platform_snapshot_guarded_routes', ], dependency_migration: { layout: 'pub_workspace', @@ -1014,6 +1021,33 @@ function writeRefactorPlan() { 'Android real-device document provider behavior remains required', ], }, + { + id: 'platform_snapshot_guarded_routes', + priority: 20, + status: 'completed', + targets: [ + 'apps/flutter_forge/lib/module_registry', + 'apps/flutter_forge/lib/app', + 'apps/flutter_forge/test/shared', + ], + acceptance: [ + 'process_immutable_platform_snapshot', + 'five_product_target_platforms', + 'excluded_platform_contract', + 'stable_guarded_module_routes', + 'unsupported_module_explanation', + 'android_debug_apk_build', + 'web_release_build', + ], + evidence: [ + 'bootstrap injects one AppPlatformSnapshot into Router and ProviderScope', + 'module contracts use target_platforms and excluded_platforms', + 'guarded route tests cover supported and unsupported module paths', + 'bare analyze and full test suite pass', + 'Android debug APK and Web release builds pass', + 'iOS is modeled but has no host directory or build evidence', + ], + }, ], quality_gate: [ 'node tool/validate_agent_docs.js', @@ -1039,7 +1073,7 @@ function writeModuleIndex() { schema: 'flutter_forge.agent_docs.module_index.v1', registry: 'lib/module_registry/module_manifest.dart', count: modules.length, - modules: modules.map(({ category, id, route, status, depends, supportedPlatforms, supportsWeb }) => ({ + modules: modules.map(({ category, id, route, status, depends, excludedPlatforms = [] }) => ({ id, category, path: `lib/modules/${category}/${id}`, @@ -1047,8 +1081,8 @@ function writeModuleIndex() { status, depends, platform_support: { - native_platforms: supportedPlatforms ?? 'all', - web: supportsWeb ?? supportedPlatforms === undefined, + target_platforms: ['android', 'iOS', 'macOS', 'web', 'windows'], + excluded_platforms: excludedPlatforms, }, analysis: `lib/modules/${category}/${id}/AI_ANALYSIS.md`, })), @@ -1068,9 +1102,9 @@ function writeRouteTable() { lines.push('// GENERATED by tool/generate_agent_indexes.js - DO NOT EDIT'); lines.push(''); - lines.push("import 'package:flutter/foundation.dart';"); lines.push("import 'package:go_router/go_router.dart';"); lines.push(''); + lines.push("import 'app_platform_snapshot.dart';"); lines.push("import 'module_category.dart';"); lines.push("import 'module_entry.dart';"); lines.push("import 'module_platform_support.dart';"); @@ -1125,30 +1159,20 @@ function writeRouteTable() { lines.push(` concepts: ${conceptsLiteral(m.concepts)},`); lines.push(` estimatedMinutes: ${m.estimatedMinutes},`); lines.push(` status: ModuleStatus.${m.status},`); - if (m.supportsWebComment) lines.push(` ${m.supportsWebComment}`); - if (m.supportedPlatforms) { - if (m.supportedPlatformsComment) { - lines.push(` ${m.supportedPlatformsComment}`); - } - const platforms = m.supportedPlatforms.map((platform) => `TargetPlatform.${platform}`); - const inlinePlatforms = ` nativePlatforms: {${platforms.join(', ')}},`; - if (platforms.length === 0) { - lines.push(' platformSupport: const ModulePlatformSupport('); - lines.push(' nativePlatforms: {},'); - lines.push(` web: ${m.supportsWeb ?? false},`); - lines.push(' ),'); - } else { - lines.push(' platformSupport: const ModulePlatformSupport('); - if (inlinePlatforms.length <= 80) { + if (m.platformSupportComment) lines.push(` ${m.platformSupportComment}`); + const excludedPlatforms = m.excludedPlatforms ?? []; + const platforms = excludedPlatforms.map((platform) => `AppTargetPlatform.${platform}`); + if (platforms.length === 0) { + lines.push(' platformSupport: const ModulePlatformSupport(),'); + } else { + const inlinePlatforms = ` excludedPlatforms: {${platforms.join(', ')}},`; + lines.push(' platformSupport: const ModulePlatformSupport('); + if (inlinePlatforms.length <= 80) { lines.push(inlinePlatforms); - } else { - lines.push(' nativePlatforms: {', ...platforms.map((platform) => ` ${platform},`), ' },'); - } - lines.push(` web: ${m.supportsWeb ?? false},`); - lines.push(' ),'); + } else { + lines.push(' excludedPlatforms: {', ...platforms.map((platform) => ` ${platform},`), ' },'); } - } else { - lines.push(` platformSupport: const ModulePlatformSupport(web: ${m.supportsWeb ?? true}),`); + lines.push(' ),'); } lines.push(` builder: (context) => const ${m.entry}(),`); if (m.subRoutesExpander) { @@ -1171,25 +1195,30 @@ function writeRouteTable() { routeLines.push("import 'package:go_router/go_router.dart';"); routeLines.push(''); routeLines.push("import '../module_home_page.dart';"); + routeLines.push("import '../unsupported_module_page.dart';"); + routeLines.push("import '../../module_registry/app_platform_snapshot.dart';"); routeLines.push("import '../../module_registry/module_catalog_utils.dart';"); routeLines.push("import '../../module_registry/module_entry.dart';"); routeLines.push("import '../../module_registry/module_manifest.dart';"); routeLines.push(''); - routeLines.push('final List _routes = ['); + routeLines.push('List _routesFor(AppPlatformSnapshot platform) => ['); routeLines.push(' GoRoute('); routeLines.push(" path: '/',"); routeLines.push(' builder: (context, state) => ModuleHomePage(modules: moduleManifest),'); routeLines.push(' ),'); - routeLines.push(' for (final module in availableModules(moduleManifest))'); + routeLines.push(' for (final module in moduleManifest)'); routeLines.push(' GoRoute('); routeLines.push(' path: module.path,'); - routeLines.push(' builder: (context, state) => module.builder(context),'); - routeLines.push(' routes: module.routes,'); + routeLines.push(' builder: (context, state) => isModuleAvailable(module, platform)'); + routeLines.push(' ? module.builder(context)'); + routeLines.push(' : UnsupportedModulePage(module: module, platform: platform),'); + routeLines.push(' routes: isModuleAvailable(module, platform) ? module.routes : const [],'); routeLines.push(' ),'); routeLines.push('];'); routeLines.push(''); routeLines.push('class AppRouteTable {'); - routeLines.push(' static List get routes => _routes;'); + routeLines.push(' static List routesFor(AppPlatformSnapshot platform) =>'); + routeLines.push(' _routesFor(platform);'); routeLines.push(' static List get modules => moduleManifest;'); routeLines.push('}'); routeLines.push(''); @@ -1238,8 +1267,8 @@ function writeLayerIndexes() { rel: 'lib/app/AI_ANALYSIS.md', id: 'flutter_forge_app.app', kind: 'app_index', - entrypoints: ['app.dart', 'app_bootstrap.dart', 'module_home_page.dart', 'category_navigation.dart', 'navigation_policy.dart', 'category_window_app.dart', 'router/app_router.dart', 'router/app_route_table.dart'], - owns: ['host_bootstrap', 'material_app_router', 'router', 'module_home', 'responsive_navigation_policy', 'adaptive_category_navigation', 'desktop_category_window_shell'], + entrypoints: ['app.dart', 'app_bootstrap.dart', 'app_platform_provider.dart', 'module_home_page.dart', 'unsupported_module_page.dart', 'category_navigation.dart', 'navigation_policy.dart', 'category_window_app.dart', 'router/app_router.dart', 'router/app_route_table.dart'], + owns: ['host_bootstrap', 'platform_snapshot_injection', 'material_app_router', 'router', 'module_home', 'unsupported_module_route', 'responsive_navigation_policy', 'adaptive_category_navigation', 'desktop_category_window_shell'], depends: ['go_router', 'module_registry', 'shared/multi_window', 'modules'], children: ['router/AI_ANALYSIS.md'], }); @@ -1248,15 +1277,15 @@ function writeLayerIndexes() { id: 'flutter_forge_app.app.router', kind: 'router_index', entrypoints: ['app_router.dart', 'app_route_table.dart'], - owns: ['go_router_root', 'module_route_aggregation', 'module_catalog_composition'], + owns: ['go_router_root', 'stable_guarded_module_routes', 'module_route_aggregation', 'module_catalog_composition'], depends: ['app/module_home_page', 'module_registry', 'modules'], }); writeIndex({ rel: 'lib/module_registry/AI_ANALYSIS.md', id: 'flutter_forge_app.module_registry', kind: 'registry_index', - entrypoints: ['module_entry.dart', 'module_category.dart', 'module_catalog_utils.dart'], - owns: ['module_entry_model', 'module_category_enum', 'difficulty_enum', 'module_status_enum', 'native_web_availability', 'module_catalog_filtering', 'category_route_rebasing'], + entrypoints: ['app_platform_snapshot.dart', 'module_entry.dart', 'module_category.dart', 'module_platform_support.dart', 'module_catalog_utils.dart'], + owns: ['platform_snapshot', 'product_target_platforms', 'module_entry_model', 'module_category_enum', 'difficulty_enum', 'module_status_enum', 'excluded_platform_availability', 'module_catalog_filtering', 'category_route_rebasing'], depends: ['flutter_material', 'go_router'], }); writeIndex({ @@ -1312,7 +1341,7 @@ function writeModuleIndexes() { } function writeModuleContracts() { - for (const { category, id: module, route, status, depends, supportedPlatforms, supportsWeb } of modules) { + for (const { category, id: module, route, status, depends, excludedPlatforms = [] } of modules) { const dir = path.join(appRoot, 'lib/modules', category, module); const entrypoints = []; for (const item of ['module_entry.dart', 'module_root.dart', 'module_routes.dart']) { @@ -1334,8 +1363,8 @@ function writeModuleContracts() { route, category, platform_support: { - native_platforms: supportedPlatforms ?? 'all', - web: supportsWeb ?? supportedPlatforms === undefined, + target_platforms: ['android', 'iOS', 'macOS', 'web', 'windows'], + excluded_platforms: excludedPlatforms, }, entrypoints: entrypoints.length ? entrypoints : ['module_entry.dart'], owns: ['module_entry', 'module_ui', 'module_docs'], diff --git a/tool/validate_agent_docs.js b/tool/validate_agent_docs.js index 3bb67df..99d38ea 100644 --- a/tool/validate_agent_docs.js +++ b/tool/validate_agent_docs.js @@ -148,8 +148,15 @@ if (moduleIndex) { if (contract.node?.status !== mod.status) { failures.push(`${mod.analysis}:index_mismatch:status`); } - if (typeof mod.platform_support?.web !== 'boolean') { - failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:platform_support_web_not_boolean`); + const targetPlatforms = ['android', 'iOS', 'macOS', 'web', 'windows']; + if (JSON.stringify(mod.platform_support?.target_platforms) !== JSON.stringify(targetPlatforms)) { + failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:platform_target_set_invalid`); + } + const excludedPlatforms = mod.platform_support?.excluded_platforms; + if (!Array.isArray(excludedPlatforms)) { + failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:excluded_platforms_not_array`); + } else if (excludedPlatforms.some((platform) => !targetPlatforms.includes(platform))) { + failures.push(`lib/AI_MODULE_INDEX.md:${mod.id}:excluded_platform_outside_target_set`); } if (JSON.stringify(contract.platform_support) !== JSON.stringify(mod.platform_support)) { failures.push(`${mod.analysis}:index_mismatch:platform_support`); From 4d58960f40797db8a93d269aa5fe5de0f8d4b164 Mon Sep 17 00:00:00 2001 From: lizy Date: Sun, 20 Sep 2026 19:55:43 +0800 Subject: [PATCH 15/21] docs(adr): record platform snapshot and guarded route decision Add ADR 0011 for the process-immutable platform snapshot and stable guarded module routes, supersede the affected parts of ADR 0003, and add platform support, target, restriction and acceptance terms to the glossary. --- CONTEXT.md | 16 ++++++++++++++++ docs/adr/0003-platform-module-availability.md | 4 +++- .../0011-platform-snapshot-and-guarded-routes.md | 12 ++++++++++++ docs/adr/README.md | 3 ++- 4 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0011-platform-snapshot-and-guarded-routes.md diff --git a/CONTEXT.md b/CONTEXT.md index 781a813..e470aa9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,6 +24,22 @@ _Avoid_: 业务窗口、模块窗口 新业务模块进入主分支前必须满足模块契约、测试和 Agent Hub 路径治理要求。 _Avoid_: 直接注册、手工接入 +**平台支持**: +模块在目标平台已完成后端接入、插件注册、路由开放、自动化测试和目标平台构建,可以向使用者开放;它不等同于已取得真机或真实主机证据。 +_Avoid_: 平台验收、仅能编译 + +**产品目标平台**: +平台判定模型覆盖 Android、iOS、macOS、Web 和 Windows;进入目标集合只表示契约可以判定该平台,不证明对应宿主已经建立或通过验收。 +_Avoid_: Flutter 全平台、宿主已完成 + +**平台限制**: +模块契约中明确排除的产品目标平台集合;目标集合内未被排除的平台视为支持,Linux 与 Fuchsia 等目标外平台一律不支持。 +_Avoid_: 支持平台清单、插件能力自动推断 + +**平台验收**: +模块在指定模拟器、真机或真实主机上取得的运行证据;它用于记录交付可信度,不形成额外的运行时支持状态。 +_Avoid_: 平台支持、接入完成 + **内嵌网页模块**: 在学习页面内展示网页、提供网页导航与加载反馈的学习单元;由应用统一管理入口与平台可用性。 _Avoid_: 内置浏览器应用、webview_flutter 模块、业务窗口 diff --git a/docs/adr/0003-platform-module-availability.md b/docs/adr/0003-platform-module-availability.md index 61d5184..6e2faca 100644 --- a/docs/adr/0003-platform-module-availability.md +++ b/docs/adr/0003-platform-module-availability.md @@ -2,7 +2,7 @@ | 属性 | 值 | |------|-----| -| 状态 | accepted | +| 状态 | accepted;平台声明与路由注册部分被 0011 取代 | | 日期 | 2026-08-25 | | 决策者 | forest | @@ -17,3 +17,5 @@ ## 范围 第一阶段按 `TargetPlatform` 隔离,不引入独立能力矩阵或动态插件系统。 + +平台限制的反向声明、启动平台快照和不支持模块的守卫路由由 ADR 0011 接续规定。 diff --git a/docs/adr/0011-platform-snapshot-and-guarded-routes.md b/docs/adr/0011-platform-snapshot-and-guarded-routes.md new file mode 100644 index 0000000..24dbe85 --- /dev/null +++ b/docs/adr/0011-platform-snapshot-and-guarded-routes.md @@ -0,0 +1,12 @@ +# ADR 0011: 平台快照与守卫路由 + +状态:accepted + +Flutter Forge 在进程启动时建立不可变宿主平台快照,平台判定模型仅覆盖 Android、iOS、macOS、Web 和 Windows。模块以 `excludedPlatforms` 声明限制,目标集合内未排除即支持,目标外平台一律不支持;所有模块路径保持注册,不支持的平台进入统一说明页而不是业务页面或 404。窗口尺寸与设备能力不进入快照,平台验收也不形成额外运行时状态,以保持路由稳定并降低普通模块的声明维护成本。 + +## 后果 + +- Router、目录和导航策略共享同一启动快照,不再分别读取全局平台变量。 +- 平台类模块必须显式确认排除集合,普通模块可以省略空集合。 +- 权限、USB、GPU、文件提供器和插件运行结果由模块适配器处理,不动态增删路由。 +- ADR 0003 的“不支持模块不注册路由”和支持列表表达方式被本决策取代;学习质量状态仍与平台支持分离。 diff --git a/docs/adr/README.md b/docs/adr/README.md index e967f8b..a22891b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,7 +8,7 @@ |------|------|------| | 0001 | 单仓布局 (Pub Workspace) | accepted | | 0002 | Agent 契约生成源 | accepted | -| 0003 | 平台模块可用性 | accepted | +| 0003 | 平台模块可用性 | accepted;平台声明与路由注册部分被 0011 取代 | | 0004 | 质量门禁策略 | proposed | | 0005 | 移动端单窗口与桌面响应式导航 | accepted | | 0006 | PC 可维护性封板与 Android 兼容轨道 | accepted | @@ -16,6 +16,7 @@ | 0008 | Flutter Scene 依赖与平台证据边界 | accepted;Windows 部分被 0009 取代 | | 0009 | Flutter Scene Windows 逻辑准入 | accepted | | 0010 | Flutter Scene Android 只读准入 | accepted | +| 0011 | 平台快照与守卫路由 | accepted | ## 状态定义 From d00cf6f9c58a3bd45ea74ae198d1b84a2aef51f5 Mon Sep 17 00:00:00 2001 From: lizy Date: Mon, 21 Sep 2026 15:44:32 +0800 Subject: [PATCH 16/21] =?UTF-8?q?feat(app):=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=88=86=E7=B1=BB=E6=8A=BD=E5=B1=89=E5=AF=BC?= =?UTF-8?q?=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Android/iOS/fuchsia 上通过抽屉打开顶层分类,桌面端保持原有布局; 补充 NavigationPolicy.usesMobileCategoryDrawer 及对应单元/集成测试。 --- .../integration_test/app_test.dart | 23 +++++++ .../lib/app/module_home_page.dart | 63 ++++++++++++++++++- .../lib/app/navigation_policy.dart | 3 + .../test/shared/navigation_policy_test.dart | 21 +++++++ .../responsive_navigation_layout_test.dart | 36 +++++++++++ 5 files changed, 143 insertions(+), 3 deletions(-) diff --git a/apps/flutter_forge/integration_test/app_test.dart b/apps/flutter_forge/integration_test/app_test.dart index 189d8c9..0cca8ad 100644 --- a/apps/flutter_forge/integration_test/app_test.dart +++ b/apps/flutter_forge/integration_test/app_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/app.dart'; import 'package:flutter_forge_app/app/app_platform_provider.dart'; +import 'package:flutter_forge_app/app/category_window_app.dart'; +import 'package:flutter_forge_app/app/navigation_policy.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; import 'package:flutter_forge_app/app/router/app_router.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; @@ -83,4 +85,25 @@ void main() { await tester.pumpAndSettle(); expect(find.text('二维滚动表格演示'), findsOneWidget); }); + + testWidgets('mobile category drawer opens a top-level category', ( + tester, + ) async { + if (!NavigationPolicy.usesMobileCategoryDrawer(platform)) { + return; + } + + await tester.pumpWidget(testApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Open navigation menu')); + await tester.pumpAndSettle(); + expect(find.text('学习目录'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('category-drawer:platform'))); + await tester.pumpAndSettle(); + + expect(find.byType(CategoryHomePage), findsOneWidget); + expect(find.text('网络与平台'), findsOneWidget); + }); } diff --git a/apps/flutter_forge/lib/app/module_home_page.dart b/apps/flutter_forge/lib/app/module_home_page.dart index a438cf4..db2d80b 100644 --- a/apps/flutter_forge/lib/app/module_home_page.dart +++ b/apps/flutter_forge/lib/app/module_home_page.dart @@ -15,13 +15,72 @@ class ModuleHomePage extends ConsumerWidget { final List modules; + void _openCategory( + BuildContext context, + ModuleCategory category, + AppPlatformSnapshot platform, + ) { + Navigator.of(context).pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + CategoryNavigation.open( + context, + category: category, + modules: modules, + platform: platform, + ); + }); + } + @override Widget build(BuildContext context, WidgetRef ref) { - const categories = ModuleCategory.values; final platform = ref.watch(appPlatformProvider); + final useCategoryDrawer = NavigationPolicy.usesMobileCategoryDrawer( + platform, + ); + final categories = ModuleCategory.values + .where( + (category) => modules.any((module) => module.category == category), + ) + .toList(growable: false); return Scaffold( appBar: AppBar(title: const Text('Flutter 学习实验室')), + drawer: useCategoryDrawer + ? Drawer( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), + child: Text( + '学习目录', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const Divider(height: 1), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + for (final category in categories) + ListTile( + key: ValueKey('category-drawer:${category.name}'), + leading: const Icon(Icons.book_outlined), + title: Text(category.label), + trailing: const Icon(Icons.chevron_right), + onTap: () => + _openCategory(context, category, platform), + ), + ], + ), + ), + ], + ), + ), + ) + : null, body: SafeArea( child: ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), @@ -32,8 +91,6 @@ class ModuleHomePage extends ConsumerWidget { .where((module) => module.category == category) .toList(); - if (categoryModules.isEmpty) return const SizedBox.shrink(); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/apps/flutter_forge/lib/app/navigation_policy.dart b/apps/flutter_forge/lib/app/navigation_policy.dart index e58389e..ef19b21 100644 --- a/apps/flutter_forge/lib/app/navigation_policy.dart +++ b/apps/flutter_forge/lib/app/navigation_policy.dart @@ -7,6 +7,9 @@ class NavigationPolicy { static const double compactWidthBreakpoint = 600; + static bool usesMobileCategoryDrawer(AppPlatformSnapshot platform) => + platform.hostFamily == AppHostFamily.mobile; + static CategoryNavigationMode resolve({ required AppPlatformSnapshot platform, required double width, diff --git a/apps/flutter_forge/test/shared/navigation_policy_test.dart b/apps/flutter_forge/test/shared/navigation_policy_test.dart index 01456aa..2d80cfd 100644 --- a/apps/flutter_forge/test/shared/navigation_policy_test.dart +++ b/apps/flutter_forge/test/shared/navigation_policy_test.dart @@ -3,6 +3,27 @@ import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('mobile platforms use the category drawer', () { + expect( + NavigationPolicy.usesMobileCategoryDrawer( + const AppPlatformSnapshot(platform: AppTargetPlatform.android), + ), + isTrue, + ); + expect( + NavigationPolicy.usesMobileCategoryDrawer( + const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), + ), + isTrue, + ); + expect( + NavigationPolicy.usesMobileCategoryDrawer( + const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), + ), + isFalse, + ); + }); + test('Android always uses in-app navigation', () { expect( NavigationPolicy.resolve( diff --git a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart index e598ef7..8ac9bff 100644 --- a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart +++ b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart @@ -33,6 +33,42 @@ void main() { expect(find.text('Flutter 学习实验室'), findsOneWidget); }); + testWidgets('mobile category drawer opens a top-level category', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + appPlatformProvider.overrideWithValue( + const AppPlatformSnapshot(platform: AppTargetPlatform.android), + ), + ], + child: MediaQuery( + data: const MediaQueryData(size: Size(360, 800)), + child: MaterialApp( + home: ModuleHomePage(modules: AppRouteTable.modules), + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Open navigation menu')); + await tester.pumpAndSettle(); + + expect(find.text('学习目录'), findsOneWidget); + expect( + find.byKey(const ValueKey('category-drawer:platform')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('category-drawer:platform'))); + await tester.pumpAndSettle(); + + expect(find.byType(CategoryHomePage), findsOneWidget); + expect(find.text('网络与平台'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('category home fits a 360dp viewport', (tester) async { await tester.pumpWidget( MediaQuery( From fde3c649bdcc7b9f6934e3672d2b503c8e8a1150 Mon Sep 17 00:00:00 2001 From: lizy Date: Tue, 22 Sep 2026 10:18:23 +0800 Subject: [PATCH 17/21] =?UTF-8?q?feat:=E6=9B=B4=E6=96=B0=E5=B1=82=E7=BA=A7?= =?UTF-8?q?agnet=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/README.md | 176 ++++-------------------- AGENTS.md | 176 +++++++----------------- README.md | 334 +++++++++++++++++++++------------------------- 3 files changed, 229 insertions(+), 457 deletions(-) diff --git a/.hermes/README.md b/.hermes/README.md index e0856e7..517718e 100644 --- a/.hermes/README.md +++ b/.hermes/README.md @@ -1,162 +1,36 @@ -# Hermes Agent 项目架构记录 +# Flutter Forge Agent 入口 -> flutter_forge_workspace Hermes Agent 托管架构 — 完全托管模式 -> 里程碑: online_video_player_landed | 阶段: agent_managed | 更新: 2026-08-02 +本目录只保存任务级 Agent 输入;项目规则与事实不在此重复维护。 -## 项目概述 +## 阅读顺序 -flutter_forge_workspace 是一个 Flutter 模块化学习工作区,涵盖基础机制、异步并发、状态管理、UI 动效、弹窗列表、网络平台六大分类共 18 个学习模块。通过 Dart Pub Workspace 管理 4 个内部共享包。 +1. `AGENTS.md`:行为、修改、Git 与发布规则。 +2. `AI_PROJECT_CONTEXT.md`:架构、入口、平台和验证契约。 +3. `REFACTOR_PLAN.md`:当前任务队列与验收条件。 +4. 目标目录最近的 `AI_ANALYSIS.md`:局部所有权与依赖。 +5. 平台或导航任务再读 `CONTEXT.md` 和相关 `docs/adr/`。 -## Agent 文档体系 +## 项目模型 -``` -flutter_forge_workspace/ -├── AGENTS.md # Agent 行为契约(入口) -├── AI_ANALYSIS_SCHEMA.json # 文档 schema 定义 -├── AI_PROJECT_CONTEXT.md # 项目架构上下文(JSON 机器契约) -├── REFACTOR_PLAN.md # 任务队列与里程碑 -├── AI_ANALYSIS.md # 工作区根索引 -├── lib/ -│ ├── AI_ANALYSIS.md # lib 层索引 -│ ├── AI_MODULE_INDEX.md # 模块索引(生成物) -│ ├── app/AI_ANALYSIS.md # 应用壳层 -│ ├── app/router/AI_ANALYSIS.md # 路由层 -│ ├── module_registry/AI_ANALYSIS.md # 模块注册表 -│ ├── shared/AI_ANALYSIS.md # 共享层 -│ ├── modules/AI_ANALYSIS.md # 模块根索引 -│ └── modules/{category}/{module}/AI_ANALYSIS.md # 18个模块契约 -├── packages/ -│ ├── gcode_core/AI_ANALYSIS.md # G-code 解析包 -│ ├── flutter_study_learning/AI_ANALYSIS.md # 教学模板包 -│ ├── file_picker_bridge/AI_ANALYSIS.md # 文件选择桥接 -│ └── flutter_ioc_core/AI_ANALYSIS.md # IoC 容器 -├── tool/ -│ ├── generate_agent_indexes.js # Agent 文档生成器(唯一生成源) -│ ├── validate_agent_docs.js # Agent 文档校验器 -│ ├── generate_harness_ai_analysis.sh # 生成+校验入口 -│ ├── quality_gate.sh # 全量质量门禁(统一入口) -│ ├── bootstrap.sh # 环境自举 -│ ├── check_environment.sh # 环境检查 -│ └── test_all.sh # 全量测试 -├── docs/ -│ ├── DEVELOPMENT.md # 开发指南 -│ ├── TESTING.md # 测试指南 -│ ├── agent/ -│ │ ├── TASK_SCHEMA.json # Agent 任务输入格式 -│ │ ├── CHANGE_REPORT_SCHEMA.json # Agent 变更报告格式 -│ │ └── COMMANDS.json # 项目命令清单 -│ └── adr/ -│ ├── README.md # ADR 索引 -│ ├── 0001-repository-layout.md # 单仓布局决策 -│ └── 0002-agent-contract-source-of-truth.md # 契约生成源决策 -├── .github/workflows/ci.yml # CI 流水线(FlutterGuard 固定版本) -├── .fvmrc # Flutter 3.44.6 -├── .nvmrc # Node 20.20.2 -└── .hermes/ - ├── README.md # 本文档 - └── .codex.json # 单次任务 JSON 提词(Codex 执行依据) -``` - -总计: 37 个 lib AI_ANALYSIS.md + 4 个 packages AI_ANALYSIS.md = 41 个验证通过。 +```text +apps/flutter_forge/lib/ +├── app/ 启动、应用壳、路由、NavigationPolicy +├── module_registry/ 模块元数据、平台快照、目录判定 +├── shared/ 教学模板和业务无关能力 +└── modules/ 六类学习模块;模块之间禁止直接依赖 -## 分层架构 - -``` -app/ ← 宿主引导 + 路由组装(禁止被 modules/ 依赖) -module_registry/ ← 模块元数据与分类(仅依赖 Flutter + go_router) -shared/ ← 业务无关能力(禁止依赖 app/ 和 modules/) -modules/ ← 学习模块叶子节点(禁止互相依赖) -packages/ ← 工作区共享包(Dart Pub Workspace,独立可测) +packages/ Pub Workspace 内部能力包 +tool/ 契约生成、测试和质量门禁 ``` -## 依赖方向 - -``` -app → module_registry, shared, modules -shared → 仅 Flutter SDK -modules → module_registry, flutter_study_learning, packages/* -packages/* → 仅 Flutter/Dart SDK(独立包,workspace 内互不可见) -``` - -## 质量门禁(单一入口) - -```bash -bash tool/quality_gate.sh -``` - -内部 5 阶段: -1. Agent 文档生成 + 校验 + 漂移检测 (41 contracts) -2. dart format + git diff (格式不漂移) -3. flutter analyze (0 errors) -4. test_all.sh (5/5 packages) -5. flutterguard --fail-on high (0 HIGH) - -## 当前基线 (2026-08-02 — online_video_player_landed) - -| 项目 | 状态 | -|------|------| -| Agent 文档 | ✅ 41 契约验证通过,生成源已修正 | -| Pub Workspace | ✅ 4 包,resolution_status=active | -| dart format | ✅ 0 changed | -| flutter analyze | ✅ 0 errors, 198 info | -| flutterguard | ✅ 0 HIGH, 5 MEDIUM(既有) | -| 测试 | ✅ 5/5 通过(含 online_video_player 3 用例) | -| CI | ✅ .github/workflows/ci.yml 已配置(FlutterGuard 固定版本) | -| 工具链锁定 | ✅ .fvmrc (Flutter 3.44.6), .nvmrc (Node 20.20.2) | -| 质量门禁脚本 | ✅ quality_gate.sh, bootstrap.sh, test_all.sh, check_environment.sh | -| 人类文档 | ✅ CONTRIBUTING.md, docs/DEVELOPMENT.md, docs/TESTING.md | -| Agent 协议 | ✅ TASK_SCHEMA.json, CHANGE_REPORT_SCHEMA.json, COMMANDS.json | -| ADR | ✅ 0001-repository-layout, 0002-agent-contract-source-of-truth | -| 托管模式 | ✅ agent_managed (REFACTOR_PLAN.active_phase) | -| 在线视频播放模块 | ✅ lib/modules/platform/online_video_player(media_kit,3 测试全过) | - -## 已完成的里程碑 - -1. directory_layers — 分层目录结构 -2. shared_package_extraction — 共享包提取 -3. module_analysis_coverage — 模块分析覆盖 -4. app_navigation_boundary — 应用导航边界 -5. host_bootstrap_boundary — 宿主引导边界 -6. workspace_package_import — 工作区包导入 -7. agent_takeover_ready — Agent 完全托管就绪 -8. online_video_player_landed — 在线视频播放模块落地(media_kit,macOS 优先)← 当前 - -## 最近进度(2026-07-25 → 2026-08-02) - -| 日期 | 事项 | -|------|------| -| 2026-07-25 | quality_gate 5/5 通过,agent_takeover_ready 达成 | -| 2026-08-02 | 修复 tool/test_agent_tools.sh 用例(19/19 全绿) | -| 2026-08-02 | 新增「在线视频播放」模块:JSON 提词 → Codex 落地 → Hermes 验收 | -| 2026-08-02 | media_kit macOS 集成:entitlements 补 network.client、ensureInitialized 时序 | -| 2026-08-02 | 解决 libmpv xcframework 下载不可达(ghfast 镜像 + SHA256 校验) | -| 2026-08-02 | flutter build macos --debug 成功,quality_gate 5/5 通过 | -| 2026-08-02 | Notion 指导文档审查修正 + 配套实操示例页 | - -## 待推进 (P1-P2) - -| 项目 | 优先级 | 备注 | -|------|--------|------| -| Android 平台适配 (module_platform_contract → android_host) | P1 | REFACTOR_PLAN 中 blocked_by_dependencies | -| mobile_layout_baseline(移动端布局基线) | P1 | REFACTOR_PLAN 中 pending | -| platform_plugin_audit(平台插件审计) | P2 | REFACTOR_PLAN 中 pending | -| FlutterGuard MEDIUM 消减 (5 issues) | P2 | 既有问题,非本模块引入 | -| 教学页视觉证据(截图/golden) | P2 | 人工验收依赖 | - -## 后续演进方向 +模块、路由、平台限制和 Agent 契约统一从 `tool/generate_agent_indexes.js` 生成。不要手改生成文件,也不要在本文件记录模块数量、版本、历史进度或测试快照。 -1. **平台扩展**:在线视频播放模块当前 macOS 优先;后续按 REFACTOR_PLAN 推进 android_host,media_kit 三件套已支持 Android,预计补充网络权限声明与真机验证即可复用。 -2. **多模块模式沉淀**:online_video_player 已验证「JSON 提词 → Codex → 独立验收」闭环,可作为新平台/新模块的标准执行范式。 -3. **CI 强化**:ci.yml 已固定 FlutterGuard 版本;后续可补充 macOS 构建 job(media_kit 原生依赖需可下载的 CI 环境)。 -4. **构建环境注意项**:libmpv 依赖 GitHub releases 下载;若网络受限需走镜像(ghfast.top),SHA256 校验 84d2ad98... 已固化在 pub-cache 缓存。 -5. **Notion 文档体系**:指导文档 + 实操示例页已关联,后续每个新模块可在实操页追加一节约 1 屏的落地记录。 +## 执行约定 -## Agent 执行约定 +- 开始前检查 `git status`,不覆盖无关改动。 +- 任务范围以用户请求和任务 JSON 为准,不自行扩大权限。 +- 修改模块前读取模块契约;结构变化同时更新生成源。 +- 验证统一执行 `bash tool/quality_gate.sh`。 +- 未经明确授权,不提交、不推送、不合并。 -1. 修改代码前: read AGENTS.md + .hermes/README.md + 目标 AI_ANALYSIS.md -2. 修改生成源: 编辑 tool/generate_agent_indexes.js,不要手改生成物 -3. 验证: bash tool/generate_harness_ai_analysis.sh -4. 门禁: bash tool/quality_gate.sh -5. 禁止: 提交/推送/合并(除非用户明确授权) -6. 禁止: 手改 AI_MODULE_INDEX.md / AI_PROJECT_CONTEXT.md / REFACTOR_PLAN.md / packages/*/AI_ANALYSIS.md -7. 任务提词: 写入 .hermes/.codex.json(schema: flutter_forge.agent_task.v1),Codex 只执行提词,Hermes 独立验收 +任务 JSON 如存在,放在 `.hermes/.codex.json`;它描述单次任务,不替代 `AGENTS.md` 或机器契约。 diff --git a/AGENTS.md b/AGENTS.md index 729ec97..8bac47a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,148 +1,72 @@ -# AGENTS.md - Agent 维护契约 +# AGENTS.md — Agent 开发规则 -> 本文档约束所有 AI agent 对本项目的修改行为。修改代码前必须阅读。 +本文件是项目级 Agent 入口。以可执行规则为主;项目事实以机器契约和代码为准。 -## 前置阅读 +## 修改前必读 -执行任何修改前,agent 必须读取: -1. `AI_ANALYSIS_SCHEMA.json` - agent 文档 schema -2. `AI_PROJECT_CONTEXT.md` - 机器可解析项目契约 -3. `REFACTOR_PLAN.md` - 机器可解析任务队列 -4. 目标模块的 `AI_ANALYSIS.md` - 机器可解析模块契约 +1. `AI_ANALYSIS_SCHEMA.json` +2. `AI_PROJECT_CONTEXT.md` +3. `REFACTOR_PLAN.md` +4. 目标目录最近的 `AI_ANALYSIS.md` -涉及平台、导航拓扑或模块准入时,同时读取 `CONTEXT.md` 和相关 `docs/adr/`。 +涉及平台、导航、模块准入或发布时,再读 `CONTEXT.md` 与相关 `docs/adr/`。`AI_*.md` 和 `REFACTOR_PLAN.md` 是 JSON 机器契约,禁止加入 Markdown 段落。 -以上 agent 文档均为 JSON,禁止加入 Markdown、自然语言段落或手工文件清单。 +## 事实源与边界 -## 新增模块规则 +- 模块、路由、平台限制及 Agent 文档的生成源:`tool/generate_agent_indexes.js`。 +- 生成物禁止手改;修改生成源后运行 `bash tool/generate_harness_ai_analysis.sh`。 +- `app/` 负责启动、应用壳和导航;`module_registry/` 负责模块元数据与平台判定。 +- `shared/` 只放业务无关能力,不依赖 `app/` 或 `modules/`。 +- `modules/` 是学习模块叶子节点,模块之间禁止直接依赖。 +- 工作区内部包位于 `packages/`;禁止引用仓库外 `path: ../...` 依赖。 -新模块 **必须** 包含以下内容,否则视为不合规: +## 模块规则 -| 必需项 | 说明 | -|--------|------| -| `module_entry.dart` | 导出 `*Entry` Widget,作为模块入口 | -| `AI_ANALYSIS.md` | 模块机器契约:route、category、status、entrypoints、owns、depends、analysis_parent、validation | -| 路由注册 | 在 `apps/flutter_forge/lib/app/router/app_route_table.dart` 的 `_modules` 中注册 | -| 模块元数据 | `ModuleEntry` 必须填写 `category`、`difficulty`、`concepts`、`estimatedMinutes`、`status`、`subtitle` | -| 教学页面 | 至少 1 个页面使用 `lib/shared/learning` 中的教学模板组件(`LearningScaffold` 等) | +新增模块必须同时具备: -平台可用性规则: +- `module_entry.dart`,导出 `*Entry` Widget。 +- `AI_ANALYSIS.md` 模块契约。 +- 生成器中的模块注册、路由和完整 `ModuleEntry` 元数据。 +- 至少一个 `lib/shared/learning` 教学模板组件。 +- 对应模块测试;涉及状态、异步或平台能力时补行为测试。 -- `ModuleEntry.supportedPlatforms == null` 表示模块默认支持所有平台。 -- 平台相关模块必须显式填写 `supportedPlatforms`,并在字段旁保留语义清晰的注释。 -- 平台判定必须通过 `module_registry/module_catalog_utils.dart` 集中执行,模块页面不得自行调用 `Platform` API 决定目录可见性。 -- 不支持的模块保留在目录中,显示不可用状态;其模块路由不注册。 -- 平台可用性与 `ModuleStatus`(学习质量状态)分离,不能用 `pending/ready/recommended` 表达平台限制。 +标题使用中文学习语义,禁止只有工程目录名或无解释、无交互的孤立 Demo。 -导航拓扑规则: +## 平台与导航 -- Android、iOS 和 Web 永远使用应用内导航,不创建业务多窗口。 -- 小于 `600dp` 的紧凑窗口使用应用内导航。 -- 只有支持多窗口的桌面平台且窗口宽度不小于 `600dp` 时,才允许创建分类窗口。 -- UI 必须通过 `NavigationPolicy` 获取导航模式,不能直接用 `Platform.isMacOS` 等条件决定业务导航。 -- `MultiWindowManager` 只负责桌面窗口生命周期,不负责移动端导航策略。 +- 平台限制用 `ModulePlatformSupport.excludedPlatforms` 表达;普通模块使用空排除集合。 +- 平台模块必须在生成源中显式声明排除集合并说明依据。 +- 目录和路由统一通过 `module_registry` 判定;模块页面不得自行决定目录可见性。 +- 不支持的平台保留稳定模块路径,并进入统一不可用说明页。 +- 平台可用性与 `ModuleStatus.pending/ready/recommended` 相互独立。 +- Android、iOS、Web 和小于 `600dp` 的窗口使用应用内导航。 +- 仅桌面宽窗口且多窗口能力可用时创建分类窗口。 +- UI 通过 `NavigationPolicy` 选择导航模式;`MultiWindowManager` 只管理桌面窗口生命周期。 +- 平台接入、构建通过和真机/真实主机验收必须分开描述;不得扩大证据范围。 -## 初版封板与业务准入 +## 修改流程 -- 当前封板目标是 PC 基线(macOS/Windows);Android 是非阻塞兼容轨道,不得被描述为已完成的全平台封板。 -- 在 PC 封板完成前,禁止新增依赖多窗口、原生插件或复杂跨平台状态的新业务模块。 -- PC 封板至少需要三分类窗口创建、重复打开复用、关闭后重开、无黑屏、无 `Invalid engine handle`,以及 macOS/Windows 构建证据。 -- Windows 构建必须在 Windows CI 或 Windows 主机完成;macOS 主机上的拒绝结果不能作为 Windows 通过证据。 -- Android 业务必须保持单窗口;Android 平台能力未完成时,模块必须声明平台限制并提供明确不可用状态。 -- 新业务模块必须通过 Agent Hub 冻结任务、模块契约、Tier 1 测试;涉及状态、异步或平台能力时必须补充行为测试。 -- 模块脚手架属于封板后的效率建设,不作为当前封板前置条件。 +1. 检查 `git status`,保留用户已有改动。 +2. 读取目标契约和代码,修改最小必要范围。 +3. 模块、依赖、路由、层级或平台声明变化时,同步更新生成源。 +4. 逻辑变化补定向测试;教学 UI 变化补人工验收或截图说明。 +5. 执行 `bash tool/quality_gate.sh`。 -## 修改模块规则 +质量门禁必须通过:Agent 文档无漂移、Dart 格式无漂移、bare `flutter analyze` 无 issue、全量测试通过、测试布局合规、FlutterGuard 无 HIGH。 -1. 修改前先读取该模块的 `AI_ANALYSIS.md` -2. 修改模块、依赖、路由或层级时,更新 `tool/generate_agent_indexes.js` 中的生成源 -3. 执行 `bash tool/generate_harness_ai_analysis.sh` 重新生成并校验 agent 文档 -4. 如果修改了路由注册,同步更新元数据字段 +## Git 与发布 -## 验收规则 +- `dev` 是开发分支;功能、修复、文档和发版准备先进入 `dev`。 +- `master` 只能通过从 `dev` 发起的 Pull Request 合入;禁止直接或强制推送。 +- 未经用户明确授权,不提交、不推送、不合并,不处理无关工作树改动。 +- 禁止修改 `.github/workflows/ci.yml`、`tool/quality_gate.sh` 或其他门禁脚本语义,除非任务显式授权 `packaging_change` 并要求人工验收。 +- 业务仓库 CI 只能构建暂存产物;GitHub Release 必须由 Agent Hub `release_hosting` 的 `release-plan` / `release-run --execute` 流程执行。 -每次代码修改后 **必须** 执行: -```bash -bash tool/quality_gate.sh -``` - -等效手动步骤(quality_gate.sh 内部执行顺序): -1. `bash tool/generate_harness_ai_analysis.sh` + `git diff --exit-code` (文档不漂移) -2. `dart format .` + `git diff --exit-code -- '*.dart'` (格式不漂移) -3. `flutter analyze` (bare:info/warning 同样视为失败) -4. `bash tool/test_all.sh` (全部测试通过) -5. `bash tool/verify_test_layout.sh` (测试目录布局合规并输出模块测试覆盖报告) -6. `cd apps/flutter_forge && dart run flutterguard_cli:flutterguard scan . --fail-on high` (无 HIGH 问题,仅本地执行) - -- `flutter analyze` 必须通过(bare,不允许任何 issue,含 info) -- `flutterguard scan --fail-on high` 必须通过,不允许引入高优问题(仅本地;CI 不运行 flutterguard) -- 远端打包门禁为 `.github/workflows/ci.yml`,同样使用 bare `flutter analyze`,是唯一权威的远端验收 -- 涉及逻辑代码时补充测试 -- 涉及 UI 教学页时进行人工验收或截图说明 - -## 禁止事项 - -| 禁止 | 说明 | -|------|------| -| 孤立 demo | 禁止新增无解释、无交互的粗糙 demo 页面 | -| 纯工程名 | 禁止首页出现纯工程目录名(如 `tree_state`),必须使用中文学习语义标题 | -| 跳过分析文档 | 禁止修改模块后不更新 `AI_ANALYSIS.md` | -| 绕过分析 | 禁止绕过 `flutter analyze` 直接提交 | -| 破坏元数据 | 禁止注册 `ModuleEntry` 时省略 `subtitle`、`category`、`difficulty` 等字段 | -| 修改打包门禁 | 禁止修改 `.github/workflows/ci.yml`、`tool/quality_gate.sh` 及其余 `tool/*.sh` 门禁脚本的语义,除非任务显式声明并经人工验收(agent-hub 侧同样受 `packaging_change` 保护路径守卫约束)。`.github/workflows/release.yml` 是首个安装器发布任务明确授权的打包工作流;后续修改仍需显式 `packaging_change` 任务和人工验收。 | - -发布工作流规则:业务仓库 CI 只能构建并上传暂存产物,禁止直接创建、编辑或上传 GitHub Release。所有发布必须由 Agent Hub 的 `release_hosting` 图通过 `release-plan` 冻结 `ReleaseProgram`,再由 `release-run --execute` 执行;绕过 Agent Hub 的发布入口视为不合规。 - -## Git 分支规则 - -- `dev` 是持续开发与日常推送分支,所有功能、修复、文档和发版准备改动先进入 `dev`。 -- `master` 是受保护的稳定分支,禁止直接推送、强制推送或删除,只能通过从 `dev` 发起的 Pull Request 合入。 -- 合入 `master` 前必须通过仓库 CI 的 `quality-gate`,所有 review thread 必须解决。当前仓库只有单一管理员,强制批准数为 0,避免 PR 作者无法自审形成死锁;新增具备 write 权限的维护者后再恢复至少 1 次批准。 -- `master` 合入后,应将其合并拓扑同步回 `dev`,避免 GitHub 因 PR merge commit 显示 `master` 虚假领先;不得通过重写 `dev` 历史处理该差异。 -- Agent 不得使用管理员绕过权限直接更新 `master`。标签与 Release 仍遵循 Agent Hub `release_hosting` 规则。 - -## Harless 巡检职责 - -定期执行以下检查: - -1. 扫描 `apps/flutter_forge/lib/modules/` 下所有模块目录,检查是否都在 `_modules` 中注册 -2. 检查每个模块是否有 `AI_ANALYSIS.md` -3. 检查重点模块是否使用教学模板(`lib/shared/learning`) -4. 检查 `ModuleEntry` 元数据是否完整(所有必填字段) -5. 标记低质量模块的 `status` 为 `ModuleStatus.pending` -6. 检查 `flutter analyze` 和 `dart format` 是否通过 - -## 启用预提交钩子 +## 常用命令 ```bash +bash tool/generate_harness_ai_analysis.sh +bash tool/quality_gate.sh +bash tool/build_web_release.sh git config core.hooksPath .githooks ``` - -这会在每次 `git commit` 前自动执行 FlutterGuard 扫描,阻止引入高优问题的提交。 - -## 模块分类枚举 - -```dart -ModuleCategory.basic // 基础机制 -ModuleCategory.async // 异步并发 -ModuleCategory.state // 状态管理 -ModuleCategory.ui // UI 与动效 -ModuleCategory.popupTable // 弹窗与列表 -ModuleCategory.platform // 网络与平台 -``` - -## 难度等级枚举 - -```dart -Difficulty.beginner // 入门 -Difficulty.intermediate // 进阶 -Difficulty.advanced // 实战 -``` - -## 模块状态枚举 - -```dart -ModuleStatus.pending // 待整改 -ModuleStatus.ready // 可学习 -ModuleStatus.recommended // 推荐 -``` diff --git a/README.md b/README.md index fa01c0c..6711961 100644 --- a/README.md +++ b/README.md @@ -1,212 +1,186 @@ -# Flutter 学习实验室 - -本项目是面向 Flutter 初学者的学习项目集合,所有学习模块位于 `apps/flutter_forge/lib/modules/` 目录下,按主题分类,通过路由切换进入不同模块。项目同时维护 `apps/flutter_forge/lib/app/` 应用壳、`apps/flutter_forge/lib/module_registry/` 模块元数据和 `apps/flutter_forge/lib/shared/` 共享能力,让示例既能独立学习,也能按真实工程方式演进。 - -在线体验:[Flutter Forge Web](https://lizy-coding.github.io/flutter_forge/) - -![demo](https://raw.githubusercontent.com/lizy-coding/flutter_forge/master/assets/demo.gif) - -Full video: https://github.com/user-attachments/assets/6af279c0-7d82-42bc-81b1-624071b0e2ea - -## 推荐学习顺序 - -1. **三棵树与生命周期** - 理解 Flutter 渲染基础 -2. **事件循环与微任务** - 掌握异步执行顺序 -3. **Stream 订阅机制** - 学习流式数据处理 -4. **Isolate 并发** - 理解多线程与性能优化 -5. **状态管理演进** - 对比 Provider/Riverpod/Bloc -6. **UI 动效与绘制** - 学习动画与自定义绘制 -7. **网络与平台能力** - 实战拦截器、USB 设备等 - -## 支持平台 - -- macOS -- Windows -- 移动端(iOS / Android) - -平台相关模块通过注册表中的 `supportedPlatforms` 判定可用性。未支持当前平台的模块仍显示在目录中,但会标记为不可用且不会注册可进入的路由;未填写该字段的模块默认支持所有平台。 - -分类导航采用响应式策略:Android、iOS 和 Web 使用应用内导航;小于 `600dp` 的紧凑窗口使用应用内导航;只有支持多窗口的桌面大窗口才创建独立分类窗口。 - -## 环境与快速开始 - -- Flutter 3.x / Dart 3.x -- 应用目录执行: - ```bash - cd apps/flutter_forge - flutter pub get - flutter run [-d ] - ``` -- 代码检查与格式化: - ```bash - flutter analyze - dart format . - ``` -- 标准验收(全量质量门禁): - ```bash - bash tool/quality_gate.sh - ``` - 分层验收标准与自动化计划见 `docs/QUALITY_ACCEPTANCE.md`。 - -## 项目结构 - -``` -apps/flutter_forge/lib/ -├── main.dart # 入口 -├── app/ # 应用壳 -│ ├── app.dart # MaterialApp.router -│ └── router/ # go_router 路由配置 -├── module_registry/ # 模块元数据(入口模型、枚举) -├── shared/ # 共享能力迁移后的边界文档与过渡层 -│ ├── AI_ANALYSIS.md -│ └── platform/AI_ANALYSIS.md -└── modules/ # 学习模块分区 - ├── basic/ # 基础机制 - ├── async/ # 异步并发 - ├── state/ # 状态管理 - ├── ui/ # UI 与动效 - ├── popup_table/ # 弹窗与列表 - └── platform/ # 网络与平台 -``` - -同级能力包: - -``` -apps/flutter_forge/lib/shared/learning # 应用内教学模板组件 -packages/file_picker_bridge # 文件选择 Dart API / MethodChannel client -packages/flutter_ioc_core # 纯 Dart IoC 容器核心 +# Flutter Forge + +> 把 Flutter 的底层机制、工程架构与跨平台能力,变成可以运行、交互、对照和验证的学习现场。 + +Flutter Forge 不是零散 Demo 的陈列柜,而是一座面向真实工程的 Flutter 学习工坊。项目将三棵树、事件循环、Isolate、状态管理、动效、3D、网络、文件选择、视频与原生能力组织为 **23 个可注册学习模块**;每个模块都带有学习语义、平台边界和独立测试,并运行在同一套响应式导航、模块注册与质量门禁之上。 + +[在线体验 Flutter Forge Web](https://lizy-coding.github.io/flutter_forge/) + +![Flutter Forge 演示](https://raw.githubusercontent.com/lizy-coding/flutter_forge/master/assets/demo.gif) + +完整演示视频:https://github.com/user-attachments/assets/6af279c0-7d82-42bc-81b1-624071b0e2ea + +## 为什么是 Flutter Forge + +- **从“看代码”到“看见机制”**:用可操作页面展示重建、队列调度、流订阅、并发、状态流转、绘制与平台调用。 +- **从单页示例到工程系统**:模块通过统一注册表进入目录与路由,共享能力有明确边界,不靠复制粘贴拼装功能。 +- **从“能运行”到“知道在哪里能运行”**:平台支持与学习质量分开建模;不支持的能力仍保留在目录中,并给出明确说明。 +- **从一次演示到可持续演进**:机器可解析的 Agent 契约、生成校验、静态分析、全量测试和 FlutterGuard 共同守住变更质量。 + +## 系统全景 + +```mermaid +flowchart TB + User[学习者] --> Host[Flutter Host
Android · macOS · Web · Windows] + + subgraph App[应用壳 apps/flutter_forge/lib/app] + Bootstrap[App Bootstrap
初始化平台快照] + Shell[MaterialApp.router
目录与学习入口] + Policy[NavigationPolicy
响应式导航决策] + Router[GoRouter
稳定路由与不可用守卫] + end + + subgraph Registry[模块注册层 module_registry] + Manifest[Module Manifest
23 个模块的元数据] + Catalog[Catalog Utils
分类、检索与平台判定] + Snapshot[AppPlatformSnapshot
进程级不可变平台模型] + end + + subgraph Modules[六类学习模块] + Basic[基础机制] + Async[异步并发] + State[架构与状态] + UI[UI 与动效] + Popup[弹窗与列表] + Platform[网络与平台] + end + + subgraph Shared[共享能力与工作区包] + Learning[LearningScaffold
教学页面模板] + MultiWindow[MultiWindowManager
桌面窗口生命周期] + Packages[file_picker_bridge
flutter_ioc_core
desktop_multi_window] + end + + Host --> Bootstrap --> Shell + Bootstrap --> Snapshot + Shell --> Policy + Shell --> Router + Policy --> Snapshot + Policy --> MultiWindow + Router --> Manifest --> Catalog + Catalog --> Snapshot + Catalog --> Modules + Modules --> Learning + Modules --> Packages ``` -## 维护文档 - -修改代码前先读根目录的: - -- `AGENTS.md`:agent 维护契约(修改前必读) -- `CONTEXT.md`:PC 封板、Android 兼容轨道和模块准入术语 -- `AI_PROJECT_CONTEXT.md`:项目整体上下文与目录约定 -- `REFACTOR_PLAN.md`:阶段性归拢重构计划 -- 目标模块或共享能力的 `AI_ANALYSIS.md` -- 质量验收标准与自动化测试计划:`docs/QUALITY_ACCEPTANCE.md` -- 专项演进计划:`docs/GCODE_VISUALIZER_EVOLUTION_PLAN.md` -- 历史验收报告:`docs/reports/` - -新增或迁移能力时需要同步更新对应层级的 `AI_ANALYSIS.md`。例如本轮同级包迁移后,相关文档为: +这套结构把职责切成四层:应用壳负责启动与导航,注册层负责模块事实与平台判定,模块层只承载学习内容和局部状态,共享层提供业务无关能力。普通模块不直接决定平台目录可见性,也不拥有多窗口策略。 -- `apps/flutter_forge/lib/shared/AI_ANALYSIS.md` -- `apps/flutter_forge/lib/shared/platform/AI_ANALYSIS.md` -- `packages/file_picker_bridge/AI_ANALYSIS.md` -- `packages/flutter_ioc_core/AI_ANALYSIS.md` -- `apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md` +### 导航与平台决策 -## 贡献指南 - -### 分支策略 - -- `dev` — 开发主分支 -- `feat/` — 功能分支 -- `fix/` — 修复分支 - -### 提交规范 +```mermaid +flowchart LR + Open[打开学习分类] --> Desktop{桌面平台?} + Desktop -- 否:Android / iOS / Web --> InApp[当前应用内导航] + Desktop -- 是 --> Width{窗口宽度 ≥ 600dp?} + Width -- 否 --> InApp + Width -- 是 --> Capability{多窗口能力可用?} + Capability -- 否 --> InApp + Capability -- 是 --> Window[创建或复用分类窗口] + Route[进入模块路由] --> Supported{当前目标平台可用?} + Supported -- 是 --> Lesson[加载教学模块] + Supported -- 否 --> Guard[显示统一不可用说明] ``` -(): -type: feat, fix, chore, docs, refactor, test -scope: 受影响的模块名或包名 -``` +Android、iOS 与 Web 始终使用应用内导航;桌面端只有在宽窗口且多窗口能力可用时才创建分类窗口。模块路径保持稳定,平台受限模块会进入统一说明页,而不是消失或落入 404。 -示例: -``` -feat(tree_state): add repaint boundary demo page -fix(gcode_core): resolve toolpath offset calculation -chore(packages): update agent doc schema -``` - -### PR 流程 - -1. 从 `dev` 创建功能分支 -2. 修改代码,遵循 AGENTS.md 规则 -3. 执行 `bash tool/quality_gate.sh`,确保通过 -4. 更新 AI_ANALYSIS.md(如适用) -5. 运行 `bash tool/generate_harness_ai_analysis.sh` -6. 提交 PR,填写 PR 模板 +## 学习地图 -### Definition of Done +| 主题 | 代表模块 | 你会观察到什么 | +|------|----------|----------------| +| 基础机制 | 三棵树与生命周期、事件循环、防抖与节流 | Widget/Element/RenderObject 关系、任务队列与调用时序 | +| 异步并发 | Stream 订阅、Isolate 对比、多任务管理器 | 背压、生命周期、主线程响应与跨 Isolate 通信 | +| 架构与状态 | 状态管理演进、Flutter IoC、本地持久化 | setState 到 Bloc 的取舍、依赖作用域与状态恢复 | +| UI 与动效 | 智能吸附线、下载动效、字体选择器、3D 查看器、G-code | 绘制、手势、Overlay、相机控制与 GPU 场景 | +| 弹窗与列表 | 弹窗合集、二维滚动表格、Overlay 跟随对照 | 浮层定位、嵌套路由与二维滚动布局 | +| 网络与平台 | Dio 拦截器、文件选择、在线视频、WebView、USB | 请求链路、原生边界、媒体生命周期与不可用降级 | -- [ ] 代码通过 `dart format .`(0 changed) -- [ ] 代码通过 `flutter analyze`(0 errors) -- [ ] 新增逻辑有测试覆盖 -- [ ] Agent 契约已更新(如适用) -- [ ] 质量门禁通过 -- [ ] 教学 UI 变更附截图/说明 +推荐从“三棵树与生命周期”开始,依次进入事件循环、Stream、Isolate、状态管理,再探索 UI/3D 与平台能力。目录中的难度、预计用时、概念标签和学习状态可以帮助你自行调整路线。 -### 禁止事项 +## 平台边界 -- 禁止 `path: ../...` 外部依赖 -- 禁止孤立 demo 页面(无解释无交互) -- 禁止首页出现纯工程目录名 -- 禁止修改生成物而不更新生成源 -- 禁止直接提交到 protected 分支 +Flutter Forge 的目标平台模型覆盖 Android、iOS、macOS、Web 和 Windows,但“进入目标集合”不等于所有能力都已完成真机验收。 -## 共享能力 +| 平台 | 当前仓库能力边界 | +|------|------------------| +| macOS | 桌面主线;支持宽窗口分类多窗口,部分 GPU/原生模块以 macOS 为已接入基线 | +| Windows | 桌面主线;核心真机能力已有记录,多窗口专项仍以验收报告为准 | +| Android | 单窗口兼容轨道;构建、模拟器遍历与基础原生通道已有证据,USB/键盘等真机行为仍需设备验收 | +| Web | 单窗口浏览器交付;Safari/Chrome 已覆盖主体模块,原生 WebView、USB、G-code 与 Isolate 模块保留不可用态 | +| iOS | 已进入平台判定模型;当前仓库没有 iOS Host 与构建证据,不应视为已交付 | -### 教学模板 +运行时以 `ModulePlatformSupport` 的排除集合描述平台限制,目录与路由统一通过 `module_registry` 判定。学习质量状态(`pending` / `ready` / `recommended`)不用于表达平台支持。 -`apps/flutter_forge/lib/shared/learning` 提供统一教学页面骨架,模块可以用学习目标、概念标签、代码片段、常见坑和练习卡片组织内容。 +详细证据见 [`docs/reports/`](docs/reports/);分层验收规则见 [`docs/QUALITY_ACCEPTANCE.md`](docs/QUALITY_ACCEPTANCE.md)。 -### 模块平台可用性 +## 快速开始 -平台限制只在模块注册表声明。非平台模块默认可用,平台模块通过 `supportedPlatforms` 显式列出支持平台;目录和路由层统一使用注册表判定结果。 +环境要求:Flutter `>=3.47.2`、Dart `^3.11.5`。 -### 平台文件选择 +```bash +# 在仓库根目录解析 Pub Workspace +flutter pub get -`packages/file_picker_bridge` 提供业务无关的文件选择接口: +# 启动主应用 +cd apps/flutter_forge +flutter run -d +``` -- Dart 接口:`FilePickerService` -- 默认实现:`MethodChannelFilePicker` -- macOS 通道:`file_picker_bridge/file_picker` -- 当前使用方:`modules/ui/gcode_visualizer` +Web Release 使用仓库提供的受检脚本,确保本地 CanvasKit、启动壳与同源媒体契约一致: -模块只传入允许的扩展名、标题和提示文案;文件读取、解析和错误展示仍由模块自己负责。 +```bash +bash tool/build_web_release.sh +``` -### G-code 核心 +提交变更前执行完整门禁: -[`gcode_core`](https://github.com/lizy-coding/gcode_core) 在独立仓库维护,应用通过 Git 依赖固定完整 commit,锁文件记录解析版本。升级时同步生成器中的外部依赖契约并执行完整质量门禁。该 Flutter 包提供: +```bash +bash tool/quality_gate.sh +``` -- 逐行读取抽象 -- G0/G1 解析 -- 错误收集 -- 轨迹段构建 +门禁会依次检查 Agent 文档漂移、Dart 格式、bare `flutter analyze`、全部测试、测试目录布局和 FlutterGuard HIGH 问题。 + +## 仓库结构 + +```text +flutter_forge/ +├── apps/flutter_forge/ # Flutter 应用与各平台 Host +│ ├── lib/app/ # 启动、应用壳、路由与导航策略 +│ ├── lib/module_registry/ # 模块元数据、平台快照与目录操作 +│ ├── lib/shared/ # 教学模板与业务无关的共享能力 +│ └── lib/modules/ # 六类、23 个学习模块 +├── packages/ +│ ├── file_picker_bridge/ # 跨平台文件选择边界 +│ ├── flutter_ioc_core/ # 纯 Dart IoC 核心 +│ └── desktop_multi_window/ # 桌面多窗口插件与本地修复 +├── docs/adr/ # 架构决策记录 +├── docs/reports/ # 分平台验收证据 +├── tool/ # 契约生成、测试与质量门禁 +├── AI_PROJECT_CONTEXT.md # 机器可解析项目契约 +└── REFACTOR_PLAN.md # 机器可解析演进队列 +``` -Canvas、时间线和播放控件由该包提供;教学页面、文件选择编排及播放状态保留在 `modules/ui/gcode_visualizer`。当前 macOS 最低支持版本为 12.0。 +`gcode_core` 作为独立 Git 依赖维护;Canvas、时间线和播放控件由它提供,教学编排与文件选择留在 Flutter Forge。工作区包禁止使用指向仓库外部的 `path: ../...` 依赖。 -## 示例索引(按主题) +## 如何新增或修改模块 -### UI 与动效 -- `modules/ui/adsorption_line`:智能吸附线画板,矩形/圆形/线条创建与拖拽,对齐辅助线、工具栏和快捷键。 -- `modules/ui/download_animation`:下载飞入动效三种实现(自定义 View / CustomPaint / Overlay),带参数调节面板。 -- `modules/ui/gcode_visualizer`:G-code 解析与轨迹动画,支持 G0/G1 指令解析、路径输入和系统文件选择、刀路轨迹绘制、播放进度控制与指令高亮,使用 `LearningScaffold` 教学模板。 +每个学习模块都不是孤立页面,而是一个受契约约束的可注册单元: -### 弹窗与列表 -- `modules/popup_table/popup_widgets`:常见弹窗合集,包含顺序链式 Overlay 演示与多种触发方式。 -- `modules/popup_table/popup_list_interaction`:弹窗与列表交互组合入口,集成弹窗演示与二维表格。 -- `modules/popup_table/scroll_table`:二维滚动表格,固定表头/行头,基于 `two_dimensional_scrollables`。 -- `modules/popup_table/overlay_follow_compare`:Overlay 跟随方案对照组,对比 `CompositedTransformFollower` 与 `markNeedsBuild` 两种浮层跟随机制。 +1. 使用 `module_entry.dart` 暴露 `*Entry` Widget。 +2. 在模块注册源中补全标题、副标题、分类、难度、概念、预计用时和状态。 +3. 至少使用一个 `lib/shared/learning` 教学模板组件。 +4. 维护模块 `AI_ANALYSIS.md`,并通过生成脚本校验机器契约。 +5. 为逻辑、交互和平台分支补充对应测试。 +6. 执行 `bash tool/quality_gate.sh`,涉及教学 UI 时补充人工验收或截图说明。 -### 基础机制 -- `modules/basic/tree_state`:Widget/Element/RenderObject 三棵树与生命周期、CustomPainter、RepaintBoundary 重绘范围示例。 -- `modules/basic/microtask`:事件循环演示,微任务队列与事件队列的执行顺序可视化。 -- `modules/basic/debounce_throttle`:防抖与节流执行时序对比。 +完整规则以 [`AGENTS.md`](AGENTS.md) 为准。架构与平台变更还应先阅读 [`CONTEXT.md`](CONTEXT.md) 和相关 [`docs/adr/`](docs/adr/)。 -### 异步与并发 -- `modules/async/isolate_basic`:主线程 vs Isolate 执行耗时计算的 UI 流畅度对比。 -- `modules/async/isolate_task_manager`:多任务 Isolate 管理器,支持进度上报、暂停/恢复/停止。 -- `modules/async/stream_subscription`:单订阅/广播流示例,包含暂停/恢复/取消与变换工具。 +## 协作与分支 -### 架构与状态 -- `modules/state/status_management`:状态管理演进链路,串联 setState、Provider、Riverpod、Bloc,附时间轴动画与测试用例。 -- `modules/state/flutter_ioc`:Provider + 自研 IoC 容器示例,支持注册/作用域/属性注入,含 `test/ioc_container_test.dart`。 +- `dev` 是持续开发分支,功能、修复、文档与发版准备先进入 `dev`。 +- `master` 是受保护稳定分支,只能通过从 `dev` 发起的 Pull Request 合入。 +- 不直接推送、强制推送或删除 `master`;合入后将合并拓扑同步回 `dev`。 +- 提交信息采用 `(): `,常用类型包括 `feat`、`fix`、`docs`、`refactor`、`test` 和 `chore`。 -### 网络与平台 -- `modules/platform/dio_interceptor`:Dio 拦截器链路(Auth/Error/Retry/Log)+ 内置本地 Mock Server,覆盖登录/列表/Token 刷新。 -- `modules/platform/usb_detector`:跨平台 USB 设备检测,周期扫描与手动刷新,使用 Stream 推送设备与状态消息。 +发布不由业务仓库 CI 直接创建 GitHub Release;发布计划与执行统一经 Agent Hub 的 `release_hosting` 流程完成。 From 1231b5588dc6fdbb85f9f611ecc5b7198a0e80b9 Mon Sep 17 00:00:00 2001 From: lizy Date: Tue, 22 Sep 2026 20:52:35 +0800 Subject: [PATCH 18/21] feat(app): add adaptive navigation shell --- CONTEXT.md | 14 +- .../integration_test/app_test.dart | 7 +- apps/flutter_forge/lib/app/AI_ANALYSIS.md | 11 +- .../lib/app/adaptive_app_shell.dart | 297 ++++++++++++++++ .../lib/app/category_navigation.dart | 25 +- .../lib/app/category_window_app.dart | 42 +-- .../lib/app/creator_home_page.dart | 78 +++++ .../lib/app/creator_profile.dart | 27 ++ .../lib/app/module_home_page.dart | 323 +++++++----------- .../lib/app/navigation_policy.dart | 25 +- .../lib/app/router/AI_ANALYSIS.md | 5 + .../lib/app/router/app_route_table.dart | 42 ++- .../lib/module_registry/module_category.dart | 17 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 + apps/flutter_forge/macos/Podfile.lock | 6 + .../macos/Runner/MainFlutterWindow.swift | 1 + apps/flutter_forge/pubspec.yaml | 1 + .../online_video_player_test.dart | 2 +- .../usb_detector/usb_detector_test.dart | 2 +- .../flutter_scene_3d_test.dart | 2 +- .../test/shared/navigation_policy_test.dart | 88 +---- .../shared/platform_route_guard_test.dart | 2 +- .../responsive_navigation_layout_test.dart | 127 ++++--- .../windows/runner/flutter_window.cpp | 7 + docs/adr/0012-adaptive-navigation-shell.md | 11 + docs/adr/README.md | 1 + .../macos/Classes/FlutterWindow.swift | 1 + .../windows/flutter_window.cc | 7 + pubspec.lock | 64 ++++ tool/generate_agent_indexes.js | 55 ++- 30 files changed, 837 insertions(+), 455 deletions(-) create mode 100644 apps/flutter_forge/lib/app/adaptive_app_shell.dart create mode 100644 apps/flutter_forge/lib/app/creator_home_page.dart create mode 100644 apps/flutter_forge/lib/app/creator_profile.dart create mode 100644 docs/adr/0012-adaptive-navigation-shell.md diff --git a/CONTEXT.md b/CONTEXT.md index e470aa9..06906c7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,9 +16,17 @@ _Avoid_: Android 封板 一个具有学习入口、课程元数据、教学内容和独立测试的可注册学习单元。 _Avoid_: demo、页面示例 -**分类窗口**: -桌面大屏上承载一个学习分类的独立窗口;它属于导航基础设施,不属于业务模块。 -_Avoid_: 业务窗口、模块窗口 +**自适应导航外壳**: +桌面端与移动端共享信息架构和路由语义,但根据可用空间采用不同导航呈现的应用框架。 +_Avoid_: 两套产品、等比缩放布局、最小差异 + +**主窗口侧栏**: +桌面主窗口中用于总览、分类发现和模块搜索的可折叠导航区域;移动端按需呈现其等价入口。 +_Avoid_: 模块树、业务菜单、移动端常驻侧栏 + +**分类专注窗口**: +桌面端由使用者显式打开、只承载一个学习分类的独立窗口;它不重复主窗口侧栏,属于导航基础设施而非业务模块。 +_Avoid_: 分类窗口、业务窗口、模块窗口 **模块准入**: 新业务模块进入主分支前必须满足模块契约、测试和 Agent Hub 路径治理要求。 diff --git a/apps/flutter_forge/integration_test/app_test.dart b/apps/flutter_forge/integration_test/app_test.dart index 0cca8ad..77215f5 100644 --- a/apps/flutter_forge/integration_test/app_test.dart +++ b/apps/flutter_forge/integration_test/app_test.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_forge_app/app/app.dart'; import 'package:flutter_forge_app/app/app_platform_provider.dart'; -import 'package:flutter_forge_app/app/category_window_app.dart'; -import 'package:flutter_forge_app/app/navigation_policy.dart'; +import 'package:flutter_forge_app/app/module_home_page.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; import 'package:flutter_forge_app/app/router/app_router.dart'; import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; @@ -89,7 +88,7 @@ void main() { testWidgets('mobile category drawer opens a top-level category', ( tester, ) async { - if (!NavigationPolicy.usesMobileCategoryDrawer(platform)) { + if (platform.hostFamily != AppHostFamily.mobile) { return; } @@ -98,7 +97,7 @@ void main() { await tester.tap(find.byTooltip('Open navigation menu')); await tester.pumpAndSettle(); - expect(find.text('学习目录'), findsOneWidget); + expect(find.text('创作者主页'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('category-drawer:platform'))); await tester.pumpAndSettle(); diff --git a/apps/flutter_forge/lib/app/AI_ANALYSIS.md b/apps/flutter_forge/lib/app/AI_ANALYSIS.md index dbc4282..849c0e3 100644 --- a/apps/flutter_forge/lib/app/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/app/AI_ANALYSIS.md @@ -12,6 +12,9 @@ "app.dart", "app_bootstrap.dart", "app_platform_provider.dart", + "adaptive_app_shell.dart", + "creator_home_page.dart", + "creator_profile.dart", "module_home_page.dart", "unsupported_module_page.dart", "category_navigation.dart", @@ -25,14 +28,18 @@ "platform_snapshot_injection", "material_app_router", "router", - "module_home", + "creator_home", + "module_catalog", + "title_search", "unsupported_module_route", - "responsive_navigation_policy", + "responsive_navigation_shell", "adaptive_category_navigation", "desktop_category_window_shell" ], "depends": [ "go_router", + "flutter_riverpod", + "url_launcher", "module_registry", "shared/multi_window", "modules" diff --git a/apps/flutter_forge/lib/app/adaptive_app_shell.dart b/apps/flutter_forge/lib/app/adaptive_app_shell.dart new file mode 100644 index 0000000..141dc0a --- /dev/null +++ b/apps/flutter_forge/lib/app/adaptive_app_shell.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../module_registry/module_category.dart'; +import '../module_registry/module_entry.dart'; +import 'navigation_policy.dart'; + +class AdaptiveAppShell extends StatefulWidget { + const AdaptiveAppShell({ + super.key, + required this.modules, + required this.child, + required this.location, + }); + + final List modules; + final Widget child; + final String location; + + @override + State createState() => _AdaptiveAppShellState(); +} + +class _AdaptiveAppShellState extends State { + bool _sidebarExpanded = true; + + int _selectedIndex() { + if (widget.location == '/') return 0; + for (var index = 0; index < ModuleCategory.values.length; index++) { + final category = ModuleCategory.values[index]; + if (widget.location == '/category/${category.name}' || + widget.modules.any( + (module) => + module.category == category && + widget.location.startsWith(module.path), + )) { + return index + 1; + } + } + return 0; + } + + void _goTo(int index) { + if (index == 0) { + context.go('/'); + return; + } + context.go('/category/${ModuleCategory.values[index - 1].name}'); + } + + Future _showSearch() async { + await showSearch( + context: context, + delegate: _ModuleSearchDelegate(widget.modules), + ); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final layout = NavigationPolicy.layoutFor( + width: constraints.maxWidth, + sidebarExpanded: _sidebarExpanded, + ); + final compact = layout == AppNavigationLayout.compact; + return Scaffold( + key: const ValueKey('adaptive-app-shell'), + appBar: + compact && + (widget.location == '/' || + widget.location.startsWith('/category/')) + ? AppBar( + title: const Text('Flutter Forge'), + actions: [ + IconButton( + tooltip: '搜索模块', + onPressed: _showSearch, + icon: const Icon(Icons.search), + ), + ], + ) + : null, + drawer: compact + ? NavigationDrawer( + selectedIndex: _selectedIndex(), + onDestinationSelected: (index) { + Navigator.of(context).pop(); + _goTo(index); + }, + children: _destinations + .map( + (destination) => NavigationDrawerDestination( + key: ValueKey( + 'category-drawer:${_destinations.indexOf(destination) == 0 ? 'home' : ModuleCategory.values[_destinations.indexOf(destination) - 1].name}', + ), + icon: Icon(destination.icon), + label: Text(destination.label), + ), + ) + .toList(), + ) + : null, + body: compact + ? widget.child + : Row( + children: [ + if (layout == AppNavigationLayout.sidebar) + _Sidebar( + selectedIndex: _selectedIndex(), + onDestinationSelected: _goTo, + onSearch: _showSearch, + onCollapse: () => + setState(() => _sidebarExpanded = false), + ) + else + NavigationRail( + key: const ValueKey('navigation-rail'), + selectedIndex: _selectedIndex(), + onDestinationSelected: _goTo, + leading: Column( + children: [ + IconButton( + tooltip: '展开侧栏', + onPressed: () => + setState(() => _sidebarExpanded = true), + icon: const Icon(Icons.menu_open), + ), + IconButton( + tooltip: '搜索模块', + onPressed: _showSearch, + icon: const Icon(Icons.search), + ), + ], + ), + destinations: _destinations + .map( + (destination) => NavigationRailDestination( + icon: Icon(destination.icon), + label: Text(destination.label), + ), + ) + .toList(), + ), + const VerticalDivider(width: 1), + Expanded(child: widget.child), + ], + ), + ); + }, + ); + } +} + +class _Sidebar extends StatelessWidget { + const _Sidebar({ + required this.selectedIndex, + required this.onDestinationSelected, + required this.onSearch, + required this.onCollapse, + }); + + final int selectedIndex; + final ValueChanged onDestinationSelected; + final VoidCallback onSearch; + final VoidCallback onCollapse; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const ValueKey('expanded-sidebar'), + width: 264, + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + title: const Text('Flutter Forge'), + subtitle: const Text('可交互的 Flutter 学习现场'), + trailing: IconButton( + tooltip: '收起侧栏', + onPressed: onCollapse, + icon: const Icon(Icons.menu_open), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: TextField( + key: const ValueKey('sidebar-search'), + readOnly: true, + onTap: onSearch, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: '搜索模块标题', + border: OutlineInputBorder(), + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: _destinations.length, + itemBuilder: (context, index) { + final destination = _destinations[index]; + return ListTile( + key: ValueKey('sidebar-destination:$index'), + selected: index == selectedIndex, + leading: Icon(destination.icon), + title: Text(destination.label), + onTap: () => onDestinationSelected(index), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _Destination { + const _Destination(this.label, this.icon); + + final String label; + final IconData icon; +} + +final List<_Destination> _destinations = [ + const _Destination('创作者主页', Icons.home_outlined), + for (final category in ModuleCategory.values) + _Destination(category.label, category.icon), +]; + +class _ModuleSearchDelegate extends SearchDelegate { + _ModuleSearchDelegate(this.modules); + + final List modules; + + @override + String get searchFieldLabel => '搜索模块标题'; + + @override + List buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton( + tooltip: '清空搜索', + onPressed: () => query = '', + icon: const Icon(Icons.clear), + ), + ]; + + @override + Widget buildLeading(BuildContext context) => IconButton( + tooltip: '返回', + onPressed: () => close(context, null), + icon: const Icon(Icons.arrow_back), + ); + + @override + Widget buildResults(BuildContext context) => _results(context); + + @override + Widget buildSuggestions(BuildContext context) => _results(context); + + Widget _results(BuildContext context) { + final normalized = query.trim().toLowerCase(); + final matches = normalized.isEmpty + ? const [] + : modules + .where( + (module) => module.title.toLowerCase().contains(normalized), + ) + .toList(growable: false); + if (normalized.isNotEmpty && matches.isEmpty) { + return Center( + child: TextButton( + onPressed: () => query = '', + child: const Text('没有匹配模块,清空搜索'), + ), + ); + } + return ListView.builder( + itemCount: matches.length, + itemBuilder: (context, index) { + final module = matches[index]; + return ListTile( + title: Text(module.title), + subtitle: Text(module.category.label), + onTap: () { + close(context, null); + context.push(module.path); + }, + ); + }, + ); + } +} diff --git a/apps/flutter_forge/lib/app/category_navigation.dart b/apps/flutter_forge/lib/app/category_navigation.dart index 6aa3d43..2b735db 100644 --- a/apps/flutter_forge/lib/app/category_navigation.dart +++ b/apps/flutter_forge/lib/app/category_navigation.dart @@ -5,8 +5,7 @@ import '../module_registry/module_catalog_utils.dart'; import '../module_registry/module_category.dart'; import '../module_registry/module_entry.dart'; import '../shared/multi_window/multi_window_manager.dart'; -import 'category_window_app.dart'; -import 'navigation_policy.dart'; +import 'module_home_page.dart'; /// Selects the platform-appropriate way to open a module category. /// @@ -15,29 +14,12 @@ import 'navigation_policy.dart'; class CategoryNavigation { const CategoryNavigation._(); - static CategoryNavigationMode modeFor( - BuildContext context, - AppPlatformSnapshot platform, - ) { - return NavigationPolicy.resolve( - platform: platform, - width: MediaQuery.sizeOf(context).width, - multiWindowSupported: MultiWindowManager.isSupported, - ); - } - - static Future open( + static Future openInApp( BuildContext context, { required ModuleCategory category, required List modules, required AppPlatformSnapshot platform, }) async { - if (modeFor(context, platform) == CategoryNavigationMode.separateWindow) { - await MultiWindowManager.instance.createCategoryWindow(category); - return; - } - - if (!context.mounted) return; final filtered = filterModulesByCategory(modules, category); await Navigator.of(context).push( MaterialPageRoute( @@ -49,4 +31,7 @@ class CategoryNavigation { ), ); } + + static Future openInNewWindow(ModuleCategory category) => + MultiWindowManager.instance.createCategoryWindow(category); } diff --git a/apps/flutter_forge/lib/app/category_window_app.dart b/apps/flutter_forge/lib/app/category_window_app.dart index 589a078..a57315b 100644 --- a/apps/flutter_forge/lib/app/category_window_app.dart +++ b/apps/flutter_forge/lib/app/category_window_app.dart @@ -3,7 +3,6 @@ import 'package:go_router/go_router.dart'; import '../module_registry/module_catalog_utils.dart'; import '../module_registry/module_category.dart'; -import '../module_registry/module_entry.dart'; import '../module_registry/app_platform_snapshot.dart'; import 'module_home_page.dart'; import 'unsupported_module_page.dart'; @@ -40,6 +39,7 @@ class CategoryWindowApp extends StatelessWidget { category: category, modules: modules, platform: platform, + focusedWindow: true, ), routes: childRoutes, ), @@ -59,43 +59,3 @@ class CategoryWindowApp extends StatelessWidget { ); } } - -class CategoryHomePage extends StatelessWidget { - const CategoryHomePage({ - super.key, - required this.category, - required this.modules, - required this.platform, - }); - - final ModuleCategory category; - final List modules; - final AppPlatformSnapshot platform; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(category.label), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - Navigator.of(context).maybePop(); - } - }, - ), - ), - body: SafeArea( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: modules.length, - itemBuilder: (context, index) => - ModuleListTile(module: modules[index], platform: platform), - ), - ), - ); - } -} diff --git a/apps/flutter_forge/lib/app/creator_home_page.dart b/apps/flutter_forge/lib/app/creator_home_page.dart new file mode 100644 index 0000000..3744d4a --- /dev/null +++ b/apps/flutter_forge/lib/app/creator_home_page.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'creator_profile.dart'; + +class CreatorHomePage extends StatelessWidget { + const CreatorHomePage({super.key}); + + @override + Widget build(BuildContext context) { + final profile = flutterForgeCreator; + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Card( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 42, + child: Text( + profile.name.substring(0, 1).toUpperCase(), + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + const SizedBox(height: 24), + Text( + profile.name, + style: Theme.of(context).textTheme.headlineLarge, + ), + const SizedBox(height: 6), + Text( + profile.headline, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 24), + Text( + profile.summary, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final area in profile.focusAreas) + Chip(label: Text(area)), + ], + ), + const SizedBox(height: 28), + FilledButton.icon( + key: const ValueKey('creator-github-link'), + onPressed: () => launchUrl( + profile.githubUri, + mode: LaunchMode.externalApplication, + ), + icon: const Icon(Icons.code), + label: const Text('访问 GitHub'), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/flutter_forge/lib/app/creator_profile.dart b/apps/flutter_forge/lib/app/creator_profile.dart new file mode 100644 index 0000000..834b285 --- /dev/null +++ b/apps/flutter_forge/lib/app/creator_profile.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +@immutable +class CreatorProfile { + const CreatorProfile({ + required this.name, + required this.headline, + required this.summary, + required this.focusAreas, + required this.githubUri, + }); + + final String name; + final String headline; + final String summary; + final List focusAreas; + final Uri githubUri; +} + +final CreatorProfile flutterForgeCreator = CreatorProfile( + name: 'Lizy', + headline: 'IoT · Flutter · AI Agent Engineering', + summary: + '长期从事 IoT 与 Flutter 跨平台开发,关注设备连接、平台能力、复杂状态治理,以及可控制、可验证、可持续的 Agent 工程流程。', + focusAreas: const ['IoT 设备与系统能力', 'Flutter 跨平台与 Plugin', 'AI Agent 编排与工程治理'], + githubUri: Uri.parse('https://github.com/lizy-coding'), +); diff --git a/apps/flutter_forge/lib/app/module_home_page.dart b/apps/flutter_forge/lib/app/module_home_page.dart index db2d80b..cb77e15 100644 --- a/apps/flutter_forge/lib/app/module_home_page.dart +++ b/apps/flutter_forge/lib/app/module_home_page.dart @@ -1,239 +1,164 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../module_registry/app_platform_snapshot.dart'; import '../module_registry/module_category.dart'; import '../module_registry/module_entry.dart'; import '../module_registry/module_catalog_utils.dart'; -import 'category_navigation.dart'; -import 'navigation_policy.dart'; -import 'app_platform_provider.dart'; +import '../shared/multi_window/multi_window_manager.dart'; -class ModuleHomePage extends ConsumerWidget { - const ModuleHomePage({super.key, required this.modules}); +class CategoryHomePage extends StatelessWidget { + const CategoryHomePage({ + super.key, + required this.category, + required this.modules, + required this.platform, + this.focusedWindow = false, + }); + final ModuleCategory category; final List modules; - - void _openCategory( - BuildContext context, - ModuleCategory category, - AppPlatformSnapshot platform, - ) { - Navigator.of(context).pop(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - CategoryNavigation.open( - context, - category: category, - modules: modules, - platform: platform, - ); - }); - } + final AppPlatformSnapshot platform; + final bool focusedWindow; @override - Widget build(BuildContext context, WidgetRef ref) { - final platform = ref.watch(appPlatformProvider); - final useCategoryDrawer = NavigationPolicy.usesMobileCategoryDrawer( - platform, - ); - final categories = ModuleCategory.values - .where( - (category) => modules.any((module) => module.category == category), - ) + Widget build(BuildContext context) { + final categoryModules = modules + .where((module) => module.category == category) .toList(growable: false); - - return Scaffold( - appBar: AppBar(title: const Text('Flutter 学习实验室')), - drawer: useCategoryDrawer - ? Drawer( - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), - child: Text( - '学习目录', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - const Divider(height: 1), - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - for (final category in categories) - ListTile( - key: ValueKey('category-drawer:${category.name}'), - leading: const Icon(Icons.book_outlined), - title: Text(category.label), - trailing: const Icon(Icons.chevron_right), - onTap: () => - _openCategory(context, category, platform), - ), - ], - ), - ), - ], - ), - ), - ) - : null, - body: SafeArea( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: categories.length, - itemBuilder: (context, index) { - final category = categories[index]; - final categoryModules = modules - .where((module) => module.category == category) - .toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 8, 8), + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: CustomScrollView( + key: ValueKey('category-page:${category.name}'), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 12), + sliver: SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), child: Row( children: [ + Icon(category.icon, size: 32), + const SizedBox(width: 12), Expanded( - child: Text( - category.label, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.label, + style: Theme.of(context).textTheme.headlineMedium, + ), + Text('${categoryModules.length} 个学习模块'), + ], ), ), - IconButton( - icon: Icon( - CategoryNavigation.modeFor(context, platform) == - CategoryNavigationMode.separateWindow - ? Icons.open_in_new - : Icons.chevron_right, - size: 20, - ), - tooltip: '打开分类', - onPressed: () => CategoryNavigation.open( - context, - category: category, - modules: modules, - platform: platform, + if (!focusedWindow && + platform.hostFamily == AppHostFamily.desktop && + MultiWindowManager.isSupported) + OutlinedButton.icon( + key: ValueKey('open-window:${category.name}'), + onPressed: () => MultiWindowManager.instance + .createCategoryWindow(category), + icon: const Icon(Icons.open_in_new), + label: const Text('在新窗口打开'), ), - ), ], ), ), - ...categoryModules.map( - (module) => - ModuleListTile(module: module, platform: platform), - ), - const Divider(height: 1), - ], - ); - }, - ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final columns = constraints.crossAxisExtent >= 820 ? 2 : 1; + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisExtent: 260, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => ModuleCard( + module: categoryModules[index], + platform: platform, + ), + childCount: categoryModules.length, + ), + ); + }, + ), + ), + ], ), ); } } -class ModuleListTile extends StatelessWidget { - const ModuleListTile({ - super.key, - required this.module, - required this.platform, - }); +class ModuleCard extends StatelessWidget { + const ModuleCard({super.key, required this.module, required this.platform}); final ModuleEntry module; final AppPlatformSnapshot platform; - Color _difficultyColor(Difficulty difficulty) { - return switch (difficulty) { - Difficulty.beginner => Colors.green, - Difficulty.intermediate => Colors.orange, - Difficulty.advanced => Colors.red, - }; - } - @override Widget build(BuildContext context) { - final difficultyColor = _difficultyColor(module.difficulty); - final isAvailable = isModuleAvailable(module, platform); - final textOpacity = isAvailable ? 1.0 : 0.55; - - return ListTile( - key: ValueKey('module:${module.path}'), - title: Row( - children: [ - Expanded( - child: Opacity(opacity: textOpacity, child: Text(module.title)), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: difficultyColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - module.difficulty.label, - style: TextStyle( - fontSize: 11, - color: difficultyColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isAvailable ? module.subtitle : '当前平台暂不支持此模块', - style: TextStyle( - fontSize: 12, - color: Colors.black.withValues(alpha: textOpacity), - ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 4, - runSpacing: 4, - children: module.concepts - .map( - (concept) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, + final available = isModuleAvailable(module, platform); + final concepts = module.concepts.take(3); + return Card( + child: InkWell( + key: ValueKey('module:${module.path}'), + borderRadius: BorderRadius.circular(12), + onTap: () => context.push(module.path), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + module.title, + style: Theme.of(context).textTheme.titleMedium, ), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(4), - ), - child: Text(concept, style: const TextStyle(fontSize: 10)), ), - ) - .toList(), - ), - const SizedBox(height: 4), - Text( - '预计 ${module.estimatedMinutes} 分钟 · ${module.status.label}', - style: TextStyle( - fontSize: 11, - color: Colors.grey.shade600.withValues(alpha: textOpacity), - ), + Chip( + visualDensity: VisualDensity.compact, + label: Text(module.difficulty.label), + ), + ], + ), + const SizedBox(height: 6), + Text( + available ? module.subtitle : '当前平台暂不支持此模块', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + for (final concept in concepts) + Chip( + visualDensity: VisualDensity.compact, + label: Text(concept), + ), + ], + ), + const SizedBox(height: 6), + Text( + '预计 ${module.estimatedMinutes} 分钟 · ${module.status.label}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - ], - ), - trailing: Icon( - isAvailable ? Icons.chevron_right : Icons.block, - color: isAvailable ? null : Colors.grey, + ), ), - onTap: () => context.push(module.path), ); } } diff --git a/apps/flutter_forge/lib/app/navigation_policy.dart b/apps/flutter_forge/lib/app/navigation_policy.dart index ef19b21..c7d505b 100644 --- a/apps/flutter_forge/lib/app/navigation_policy.dart +++ b/apps/flutter_forge/lib/app/navigation_policy.dart @@ -1,28 +1,19 @@ -import '../module_registry/app_platform_snapshot.dart'; - -enum CategoryNavigationMode { inApp, separateWindow } +enum AppNavigationLayout { compact, rail, sidebar } class NavigationPolicy { const NavigationPolicy._(); static const double compactWidthBreakpoint = 600; + static const double sidebarWidthBreakpoint = 1024; - static bool usesMobileCategoryDrawer(AppPlatformSnapshot platform) => - platform.hostFamily == AppHostFamily.mobile; - - static CategoryNavigationMode resolve({ - required AppPlatformSnapshot platform, + static AppNavigationLayout layoutFor({ required double width, - required bool multiWindowSupported, + required bool sidebarExpanded, }) { - if (platform.hostFamily != AppHostFamily.desktop) { - return CategoryNavigationMode.inApp; + if (width < compactWidthBreakpoint) return AppNavigationLayout.compact; + if (width < sidebarWidthBreakpoint || !sidebarExpanded) { + return AppNavigationLayout.rail; } - - if (width < compactWidthBreakpoint || !multiWindowSupported) { - return CategoryNavigationMode.inApp; - } - - return CategoryNavigationMode.separateWindow; + return AppNavigationLayout.sidebar; } } diff --git a/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md b/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md index 19553dc..4a09fbf 100644 --- a/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md @@ -14,11 +14,16 @@ ], "owns": [ "go_router_root", + "adaptive_shell_route", + "creator_home_route", + "category_route", "stable_guarded_module_routes", "module_route_aggregation", "module_catalog_composition" ], "depends": [ + "app/adaptive_app_shell", + "app/creator_home_page", "app/module_home_page", "module_registry", "modules" diff --git a/apps/flutter_forge/lib/app/router/app_route_table.dart b/apps/flutter_forge/lib/app/router/app_route_table.dart index e510310..e041174 100644 --- a/apps/flutter_forge/lib/app/router/app_route_table.dart +++ b/apps/flutter_forge/lib/app/router/app_route_table.dart @@ -2,18 +2,17 @@ import 'package:go_router/go_router.dart'; +import '../adaptive_app_shell.dart'; +import '../creator_home_page.dart'; import '../module_home_page.dart'; import '../unsupported_module_page.dart'; import '../../module_registry/app_platform_snapshot.dart'; import '../../module_registry/module_catalog_utils.dart'; import '../../module_registry/module_entry.dart'; +import '../../module_registry/module_category.dart'; import '../../module_registry/module_manifest.dart'; -List _routesFor(AppPlatformSnapshot platform) => [ - GoRoute( - path: '/', - builder: (context, state) => ModuleHomePage(modules: moduleManifest), - ), +List _moduleRoutesFor(AppPlatformSnapshot platform) => [ for (final module in moduleManifest) GoRoute( path: module.path, @@ -24,8 +23,39 @@ List _routesFor(AppPlatformSnapshot platform) => [ ), ]; +List _routesFor(AppPlatformSnapshot platform) => [ + ShellRoute( + builder: (context, state, child) => AdaptiveAppShell( + modules: moduleManifest, + location: state.uri.path, + child: child, + ), + routes: [ + GoRoute(path: '/', builder: (context, state) => const CreatorHomePage()), + GoRoute( + path: '/category/:category', + builder: (context, state) { + final name = state.pathParameters['category']; + final category = ModuleCategory.values.firstWhere( + (candidate) => candidate.name == name, + orElse: () => ModuleCategory.basic, + ); + return CategoryHomePage( + category: category, + modules: moduleManifest, + platform: platform, + ); + }, + ), + ..._moduleRoutesFor(platform), + ], + ), +]; + class AppRouteTable { - static List routesFor(AppPlatformSnapshot platform) => + static List routesFor(AppPlatformSnapshot platform) => _routesFor(platform); + static List moduleRoutesFor(AppPlatformSnapshot platform) => + _moduleRoutesFor(platform); static List get modules => moduleManifest; } diff --git a/apps/flutter_forge/lib/module_registry/module_category.dart b/apps/flutter_forge/lib/module_registry/module_category.dart index b0de7f8..ba19c50 100644 --- a/apps/flutter_forge/lib/module_registry/module_category.dart +++ b/apps/flutter_forge/lib/module_registry/module_category.dart @@ -1,14 +1,17 @@ +import 'package:flutter/material.dart'; + /// 模块分类 enum ModuleCategory { - basic('基础机制'), - async('异步并发'), - state('状态管理'), - ui('UI 与动效'), - popupTable('弹窗与列表'), - platform('网络与平台'); + basic('基础机制', Icons.account_tree_outlined), + async('异步并发', Icons.bolt_outlined), + state('架构与状态', Icons.hub_outlined), + ui('UI 与动效', Icons.palette_outlined), + popupTable('弹窗与列表', Icons.table_chart_outlined), + platform('网络与平台', Icons.devices_outlined); - const ModuleCategory(this.label); + const ModuleCategory(this.label, this.icon); final String label; + final IconData icon; } /// 难度等级 diff --git a/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift index 0154f02..600635d 100644 --- a/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import desktop_multi_window import device_info_plus import file_selector_macos import shared_preferences_foundation +import url_launcher_macos import video_player_avfoundation import webview_flutter_wkwebview @@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/apps/flutter_forge/macos/Podfile.lock b/apps/flutter_forge/macos/Podfile.lock index 6e23284..b3b6287 100644 --- a/apps/flutter_forge/macos/Podfile.lock +++ b/apps/flutter_forge/macos/Podfile.lock @@ -9,6 +9,8 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS - video_player_avfoundation (0.0.1): - Flutter - FlutterMacOS @@ -22,6 +24,7 @@ DEPENDENCIES: - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) @@ -36,6 +39,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos video_player_avfoundation: :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin webview_flutter_wkwebview: @@ -47,6 +52,7 @@ SPEC CHECKSUMS: file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 FlutterMacOS: c232990155153907050900a2e175c7773903ba4e shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d diff --git a/apps/flutter_forge/macos/Runner/MainFlutterWindow.swift b/apps/flutter_forge/macos/Runner/MainFlutterWindow.swift index 74d5506..26aa1b9 100644 --- a/apps/flutter_forge/macos/Runner/MainFlutterWindow.swift +++ b/apps/flutter_forge/macos/Runner/MainFlutterWindow.swift @@ -7,6 +7,7 @@ class MainFlutterWindow: NSWindow { let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + self.contentMinSize = NSSize(width: 800, height: 600) precondition( flutterViewController.engine.run(withEntrypoint: nil), diff --git a/apps/flutter_forge/pubspec.yaml b/apps/flutter_forge/pubspec.yaml index 79a1bda..c363c44 100644 --- a/apps/flutter_forge/pubspec.yaml +++ b/apps/flutter_forge/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: video_player_web: ^2.4.0 video_player_win: ^3.2.2 shared_preferences: ^2.5.5 + url_launcher: ^6.3.2 webview_flutter: ^4.9.0 webview_windows: ^0.4.0 flutter_scene: 0.23.0 diff --git a/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart b/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart index e7da280..47bf016 100644 --- a/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart +++ b/apps/flutter_forge/test/modules/platform/online_video_player/online_video_player_test.dart @@ -27,7 +27,7 @@ void main() { ); } expect( - AppRouteTable.routesFor( + AppRouteTable.moduleRoutesFor( const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), ).where((route) => route.path == module.path), isNotEmpty, diff --git a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart index 7018ac5..8dd6ef5 100644 --- a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart +++ b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart @@ -29,7 +29,7 @@ void main() { ); } expect( - AppRouteTable.routesFor( + AppRouteTable.moduleRoutesFor( const AppPlatformSnapshot(platform: AppTargetPlatform.android), ).where((route) => route.path == module.path), isNotEmpty, diff --git a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart index 6a3f6d8..9dfb9a0 100644 --- a/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart +++ b/apps/flutter_forge/test/modules/ui/flutter_scene_3d/flutter_scene_3d_test.dart @@ -264,7 +264,7 @@ void main() { ); } expect( - AppRouteTable.routesFor( + AppRouteTable.moduleRoutesFor( const AppPlatformSnapshot(platform: AppTargetPlatform.web), ).where((route) => route.path == module.path), isNotEmpty, diff --git a/apps/flutter_forge/test/shared/navigation_policy_test.dart b/apps/flutter_forge/test/shared/navigation_policy_test.dart index 2d80cfd..ec7b145 100644 --- a/apps/flutter_forge/test/shared/navigation_policy_test.dart +++ b/apps/flutter_forge/test/shared/navigation_policy_test.dart @@ -1,96 +1,36 @@ import 'package:flutter_forge_app/app/navigation_policy.dart'; -import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('mobile platforms use the category drawer', () { + test('compact windows use hidden navigation', () { expect( - NavigationPolicy.usesMobileCategoryDrawer( - const AppPlatformSnapshot(platform: AppTargetPlatform.android), - ), - isTrue, - ); - expect( - NavigationPolicy.usesMobileCategoryDrawer( - const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), - ), - isTrue, - ); - expect( - NavigationPolicy.usesMobileCategoryDrawer( - const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), - ), - isFalse, - ); - }); - - test('Android always uses in-app navigation', () { - expect( - NavigationPolicy.resolve( - platform: const AppPlatformSnapshot( - platform: AppTargetPlatform.android, - ), - width: 1200, - multiWindowSupported: true, - ), - CategoryNavigationMode.inApp, + NavigationPolicy.layoutFor(width: 599, sidebarExpanded: true), + AppNavigationLayout.compact, ); }); - test('iOS always uses in-app navigation', () { + test('medium windows use a navigation rail', () { expect( - NavigationPolicy.resolve( - platform: const AppPlatformSnapshot(platform: AppTargetPlatform.iOS), - width: 1200, - multiWindowSupported: true, - ), - CategoryNavigationMode.inApp, + NavigationPolicy.layoutFor(width: 600, sidebarExpanded: true), + AppNavigationLayout.rail, ); - }); - - test('Web always uses in-app navigation on a desktop browser', () { - expect( - NavigationPolicy.resolve( - platform: const AppPlatformSnapshot(platform: AppTargetPlatform.web), - width: 1200, - multiWindowSupported: true, - ), - CategoryNavigationMode.inApp, - ); - }); - - test('compact desktop windows use in-app navigation', () { expect( - NavigationPolicy.resolve( - platform: const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), - width: 599, - multiWindowSupported: true, - ), - CategoryNavigationMode.inApp, + NavigationPolicy.layoutFor(width: 1023, sidebarExpanded: true), + AppNavigationLayout.rail, ); }); - test('600dp desktop windows can use separate windows', () { + test('expanded windows use a sidebar', () { expect( - NavigationPolicy.resolve( - platform: const AppPlatformSnapshot(platform: AppTargetPlatform.macOS), - width: 600, - multiWindowSupported: true, - ), - CategoryNavigationMode.separateWindow, + NavigationPolicy.layoutFor(width: 1024, sidebarExpanded: true), + AppNavigationLayout.sidebar, ); }); - test('unsupported multi-window hosts fall back to in-app navigation', () { + test('manual collapse keeps a wide window on the rail', () { expect( - NavigationPolicy.resolve( - platform: const AppPlatformSnapshot( - platform: AppTargetPlatform.unsupported, - ), - width: 1200, - multiWindowSupported: false, - ), - CategoryNavigationMode.inApp, + NavigationPolicy.layoutFor(width: 1440, sidebarExpanded: false), + AppNavigationLayout.rail, ); }); } diff --git a/apps/flutter_forge/test/shared/platform_route_guard_test.dart b/apps/flutter_forge/test/shared/platform_route_guard_test.dart index bca17fb..e93a715 100644 --- a/apps/flutter_forge/test/shared/platform_route_guard_test.dart +++ b/apps/flutter_forge/test/shared/platform_route_guard_test.dart @@ -40,7 +40,7 @@ void main() { await tester.tap(find.text('返回模块目录')); await tester.pumpAndSettle(); - expect(find.text('Flutter 学习实验室'), findsOneWidget); + expect(find.text('Lizy'), findsOneWidget); }); testWidgets('supported module path renders its business page', ( diff --git a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart index 8ac9bff..044b9ee 100644 --- a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart +++ b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart @@ -1,94 +1,87 @@ import 'package:flutter/material.dart'; -import 'package:flutter_forge_app/app/category_window_app.dart'; +import 'package:flutter_forge_app/app/adaptive_app_shell.dart'; +import 'package:flutter_forge_app/app/creator_home_page.dart'; import 'package:flutter_forge_app/app/module_home_page.dart'; -import 'package:flutter_forge_app/app/app_platform_provider.dart'; import 'package:flutter_forge_app/app/router/app_route_table.dart'; import 'package:flutter_forge_app/module_registry/app_platform_snapshot.dart'; import 'package:flutter_forge_app/module_registry/module_category.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +const _android = AppPlatformSnapshot(platform: AppTargetPlatform.android); + void main() { - testWidgets('module home fits a 360dp viewport', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - appPlatformProvider.overrideWithValue( - const AppPlatformSnapshot(platform: AppTargetPlatform.android), - ), - ], - child: MediaQuery( - data: const MediaQueryData( - size: Size(360, 800), - padding: EdgeInsets.only(top: 24, bottom: 24), - ), - child: MaterialApp( - home: ModuleHomePage(modules: AppRouteTable.modules), - ), - ), - ), - ); + testWidgets('compact layout uses a drawer and creator home', (tester) async { + await _pumpShell(tester, size: const Size(360, 800)); + + expect(find.byType(NavigationRail), findsNothing); + expect(find.byKey(const ValueKey('expanded-sidebar')), findsNothing); + expect(find.text('Lizy'), findsOneWidget); + expect(find.text('IoT · Flutter · AI Agent Engineering'), findsOneWidget); + expect(find.byKey(const ValueKey('creator-github-link')), findsOneWidget); + await tester.tap(find.byTooltip('Open navigation menu')); + await tester.pumpAndSettle(); + expect(find.byType(NavigationDrawer), findsOneWidget); + expect(find.text('架构与状态'), findsOneWidget); expect(tester.takeException(), isNull); - expect(find.text('Flutter 学习实验室'), findsOneWidget); }); - testWidgets('mobile category drawer opens a top-level category', ( - tester, - ) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - appPlatformProvider.overrideWithValue( - const AppPlatformSnapshot(platform: AppTargetPlatform.android), - ), - ], - child: MediaQuery( - data: const MediaQueryData(size: Size(360, 800)), - child: MaterialApp( - home: ModuleHomePage(modules: AppRouteTable.modules), - ), - ), - ), - ); + testWidgets('medium layout uses a navigation rail', (tester) async { + await _pumpShell(tester, size: const Size(800, 700)); - await tester.tap(find.byTooltip('Open navigation menu')); - await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('navigation-rail')), findsOneWidget); + expect(find.byKey(const ValueKey('expanded-sidebar')), findsNothing); + expect(tester.takeException(), isNull); + }); - expect(find.text('学习目录'), findsOneWidget); - expect( - find.byKey(const ValueKey('category-drawer:platform')), - findsOneWidget, - ); + testWidgets('expanded layout uses a searchable sidebar', (tester) async { + await _pumpShell(tester, size: const Size(1200, 800)); - await tester.tap(find.byKey(const ValueKey('category-drawer:platform'))); - await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('expanded-sidebar')), findsOneWidget); + expect(find.byKey(const ValueKey('sidebar-search')), findsOneWidget); - expect(find.byType(CategoryHomePage), findsOneWidget); - expect(find.text('网络与平台'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('sidebar-search'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, '三棵树'); + await tester.pump(); + expect(find.text('三棵树与生命周期'), findsOneWidget); expect(tester.takeException(), isNull); }); - testWidgets('category home fits a 360dp viewport', (tester) async { + testWidgets('category content changes from one to two columns', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(700, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData( - size: Size(360, 800), - padding: EdgeInsets.only(top: 24, bottom: 24), - ), - child: MaterialApp( - home: CategoryHomePage( - category: ModuleCategory.basic, - modules: AppRouteTable.modules, - platform: const AppPlatformSnapshot( - platform: AppTargetPlatform.android, - ), - ), + MaterialApp( + home: CategoryHomePage( + category: ModuleCategory.basic, + modules: AppRouteTable.modules, + platform: _android, ), ), ); + expect(find.byKey(const ValueKey('category-page:basic')), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.binding.setSurfaceSize(const Size(1100, 800)); + await tester.pump(); expect(tester.takeException(), isNull); - expect(find.text('基础机制'), findsOneWidget); }); } + +Future _pumpShell(WidgetTester tester, {required Size size}) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + home: AdaptiveAppShell( + modules: AppRouteTable.modules, + location: '/', + child: const CreatorHomePage(), + ), + ), + ); + await tester.pump(); +} diff --git a/apps/flutter_forge/windows/runner/flutter_window.cpp b/apps/flutter_forge/windows/runner/flutter_window.cpp index 9c69cdb..032e356 100644 --- a/apps/flutter_forge/windows/runner/flutter_window.cpp +++ b/apps/flutter_forge/windows/runner/flutter_window.cpp @@ -69,6 +69,13 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, } switch (message) { + case WM_GETMINMAXINFO: { + auto* info = reinterpret_cast(lparam); + const UINT dpi = GetDpiForWindow(hwnd); + info->ptMinTrackSize.x = MulDiv(800, dpi, 96); + info->ptMinTrackSize.y = MulDiv(600, dpi, 96); + return 0; + } case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; diff --git a/docs/adr/0012-adaptive-navigation-shell.md b/docs/adr/0012-adaptive-navigation-shell.md new file mode 100644 index 0000000..effb61d --- /dev/null +++ b/docs/adr/0012-adaptive-navigation-shell.md @@ -0,0 +1,11 @@ +# 自适应导航外壳与分类专注窗口 + +状态:accepted + +Flutter Forge 在所有平台共享“创作者主页—学习分类—业务模块”的信息架构,但按可用宽度选择导航外壳:小于 `600dp` 使用按需打开的移动导航,`600–1023dp` 使用 NavigationRail,`1024dp` 起使用可折叠侧栏。macOS 和 Windows 主窗口及分类专注窗口的最小尺寸为 `800 × 600`,避免原生桌面窗口退化到未经设计的紧凑状态。 + +主窗口负责发现、标题搜索和应用内学习导航;桌面多窗口不再是打开分类的默认行为,而是使用者显式选择的增强能力。分类专注窗口只承载一个分类、复用同分类已有窗口且不重复主窗口侧栏。移动端保持单窗口,使用 Drawer、首页分类入口和全屏教学页。 + +默认路由为创作者主页,只展示作者身份与 GitHub 直链;学习分类和项目内容由导航外壳承载,不在主页重复推荐,也不再建立独立“关于”页面。初版搜索只匹配模块标题,侧栏折叠偏好只在当前运行期间保留。 + +本决策取代 ADR 0005 中“桌面宽窗口打开分类时默认创建独立窗口”的交互;ADR 0005 的移动端单窗口、紧凑窗口应用内导航和多窗口生命周期边界继续有效。 diff --git a/docs/adr/README.md b/docs/adr/README.md index a22891b..fe5fee6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ | 0009 | Flutter Scene Windows 逻辑准入 | accepted | | 0010 | Flutter Scene Android 只读准入 | accepted | | 0011 | 平台快照与守卫路由 | accepted | +| 0012 | 自适应导航外壳与分类专注窗口 | accepted | ## 状态定义 diff --git a/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift b/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift index 67e81ab..f1dddbc 100644 --- a/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift +++ b/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift @@ -19,6 +19,7 @@ class CustomWindow: NSWindow { defer: false) self.isReleasedWhenClosed = false + self.contentMinSize = NSSize(width: 800, height: 600) } deinit { diff --git a/packages/desktop_multi_window/windows/flutter_window.cc b/packages/desktop_multi_window/windows/flutter_window.cc index 4a199d4..f28d59e 100644 --- a/packages/desktop_multi_window/windows/flutter_window.cc +++ b/packages/desktop_multi_window/windows/flutter_window.cc @@ -69,6 +69,13 @@ LRESULT FlutterWindow::MessageHandler(HWND hwnd, } switch (message) { + case WM_GETMINMAXINFO: { + auto* info = reinterpret_cast(lparam); + const UINT dpi = GetDpiForWindow(hwnd); + info->ptMinTrackSize.x = MulDiv(800, dpi, 96); + info->ptMinTrackSize.y = MulDiv(600, dpi, 96); + return 0; + } case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; diff --git a/pubspec.lock b/pubspec.lock index 957b1ff..26111a5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -771,6 +771,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" vector_graphics: dependency: transitive description: diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index d9ac5d2..4256363 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -1194,18 +1194,17 @@ function writeRouteTable() { routeLines.push(''); routeLines.push("import 'package:go_router/go_router.dart';"); routeLines.push(''); + routeLines.push("import '../adaptive_app_shell.dart';"); + routeLines.push("import '../creator_home_page.dart';"); routeLines.push("import '../module_home_page.dart';"); routeLines.push("import '../unsupported_module_page.dart';"); routeLines.push("import '../../module_registry/app_platform_snapshot.dart';"); routeLines.push("import '../../module_registry/module_catalog_utils.dart';"); routeLines.push("import '../../module_registry/module_entry.dart';"); + routeLines.push("import '../../module_registry/module_category.dart';"); routeLines.push("import '../../module_registry/module_manifest.dart';"); routeLines.push(''); - routeLines.push('List _routesFor(AppPlatformSnapshot platform) => ['); - routeLines.push(' GoRoute('); - routeLines.push(" path: '/',"); - routeLines.push(' builder: (context, state) => ModuleHomePage(modules: moduleManifest),'); - routeLines.push(' ),'); + routeLines.push('List _moduleRoutesFor(AppPlatformSnapshot platform) => ['); routeLines.push(' for (final module in moduleManifest)'); routeLines.push(' GoRoute('); routeLines.push(' path: module.path,'); @@ -1216,9 +1215,43 @@ function writeRouteTable() { routeLines.push(' ),'); routeLines.push('];'); routeLines.push(''); + routeLines.push('List _routesFor(AppPlatformSnapshot platform) => ['); + routeLines.push(' ShellRoute('); + routeLines.push(' builder: (context, state, child) => AdaptiveAppShell('); + routeLines.push(' modules: moduleManifest,'); + routeLines.push(' location: state.uri.path,'); + routeLines.push(' child: child,'); + routeLines.push(' ),'); + routeLines.push(' routes: ['); + routeLines.push(' GoRoute('); + routeLines.push(" path: '/',"); + routeLines.push(' builder: (context, state) => const CreatorHomePage(),'); + routeLines.push(' ),'); + routeLines.push(' GoRoute('); + routeLines.push(" path: '/category/:category',"); + routeLines.push(' builder: (context, state) {'); + routeLines.push(" final name = state.pathParameters['category'];"); + routeLines.push(' final category = ModuleCategory.values.firstWhere('); + routeLines.push(' (candidate) => candidate.name == name,'); + routeLines.push(' orElse: () => ModuleCategory.basic,'); + routeLines.push(' );'); + routeLines.push(' return CategoryHomePage('); + routeLines.push(' category: category,'); + routeLines.push(' modules: moduleManifest,'); + routeLines.push(' platform: platform,'); + routeLines.push(' );'); + routeLines.push(' },'); + routeLines.push(' ),'); + routeLines.push(' ..._moduleRoutesFor(platform),'); + routeLines.push(' ],'); + routeLines.push(' ),'); + routeLines.push('];'); + routeLines.push(''); routeLines.push('class AppRouteTable {'); - routeLines.push(' static List routesFor(AppPlatformSnapshot platform) =>'); + routeLines.push(' static List routesFor(AppPlatformSnapshot platform) =>'); routeLines.push(' _routesFor(platform);'); + routeLines.push(' static List moduleRoutesFor(AppPlatformSnapshot platform) =>'); + routeLines.push(' _moduleRoutesFor(platform);'); routeLines.push(' static List get modules => moduleManifest;'); routeLines.push('}'); routeLines.push(''); @@ -1267,9 +1300,9 @@ function writeLayerIndexes() { rel: 'lib/app/AI_ANALYSIS.md', id: 'flutter_forge_app.app', kind: 'app_index', - entrypoints: ['app.dart', 'app_bootstrap.dart', 'app_platform_provider.dart', 'module_home_page.dart', 'unsupported_module_page.dart', 'category_navigation.dart', 'navigation_policy.dart', 'category_window_app.dart', 'router/app_router.dart', 'router/app_route_table.dart'], - owns: ['host_bootstrap', 'platform_snapshot_injection', 'material_app_router', 'router', 'module_home', 'unsupported_module_route', 'responsive_navigation_policy', 'adaptive_category_navigation', 'desktop_category_window_shell'], - depends: ['go_router', 'module_registry', 'shared/multi_window', 'modules'], + entrypoints: ['app.dart', 'app_bootstrap.dart', 'app_platform_provider.dart', 'adaptive_app_shell.dart', 'creator_home_page.dart', 'creator_profile.dart', 'module_home_page.dart', 'unsupported_module_page.dart', 'category_navigation.dart', 'navigation_policy.dart', 'category_window_app.dart', 'router/app_router.dart', 'router/app_route_table.dart'], + owns: ['host_bootstrap', 'platform_snapshot_injection', 'material_app_router', 'router', 'creator_home', 'module_catalog', 'title_search', 'unsupported_module_route', 'responsive_navigation_shell', 'adaptive_category_navigation', 'desktop_category_window_shell'], + depends: ['go_router', 'flutter_riverpod', 'url_launcher', 'module_registry', 'shared/multi_window', 'modules'], children: ['router/AI_ANALYSIS.md'], }); writeIndex({ @@ -1277,8 +1310,8 @@ function writeLayerIndexes() { id: 'flutter_forge_app.app.router', kind: 'router_index', entrypoints: ['app_router.dart', 'app_route_table.dart'], - owns: ['go_router_root', 'stable_guarded_module_routes', 'module_route_aggregation', 'module_catalog_composition'], - depends: ['app/module_home_page', 'module_registry', 'modules'], + owns: ['go_router_root', 'adaptive_shell_route', 'creator_home_route', 'category_route', 'stable_guarded_module_routes', 'module_route_aggregation', 'module_catalog_composition'], + depends: ['app/adaptive_app_shell', 'app/creator_home_page', 'app/module_home_page', 'module_registry', 'modules'], }); writeIndex({ rel: 'lib/module_registry/AI_ANALYSIS.md', From 10914f023ff0d8d02c28a164eba51eba326cc093 Mon Sep 17 00:00:00 2001 From: lizy Date: Tue, 22 Sep 2026 20:52:51 +0800 Subject: [PATCH 19/21] feat(app): refine learning home experience --- .../lib/app/creator_home_page.dart | 523 ++++++++++++++++-- .../lib/app/creator_profile.dart | 21 +- .../lib/app/module_home_page.dart | 132 ++++- .../responsive_navigation_layout_test.dart | 103 +++- 4 files changed, 696 insertions(+), 83 deletions(-) diff --git a/apps/flutter_forge/lib/app/creator_home_page.dart b/apps/flutter_forge/lib/app/creator_home_page.dart index 3744d4a..42e6f9f 100644 --- a/apps/flutter_forge/lib/app/creator_home_page.dart +++ b/apps/flutter_forge/lib/app/creator_home_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../module_registry/module_category.dart'; import 'creator_profile.dart'; class CreatorHomePage extends StatelessWidget { @@ -8,71 +10,492 @@ class CreatorHomePage extends StatelessWidget { @override Widget build(BuildContext context) { - final profile = flutterForgeCreator; - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), + final colors = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colors.primaryContainer.withValues(alpha: 0.42), + colors.surface, + colors.tertiaryContainer.withValues(alpha: 0.24), + ], + ), + ), + child: SingleChildScrollView( + key: const ValueKey('creator-home-scroll'), + padding: const EdgeInsets.fromLTRB(20, 32, 20, 40), + child: Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: Card( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, + constraints: const BoxConstraints(maxWidth: 1120), + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 860; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 42, - child: Text( - profile.name.substring(0, 1).toUpperCase(), - style: Theme.of(context).textTheme.headlineMedium, - ), - ), - const SizedBox(height: 24), + _HeroSection(wide: wide), + const SizedBox(height: 40), Text( - profile.name, - style: Theme.of(context).textTheme.headlineLarge, + '从一个方向开始', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 6), Text( - profile.headline, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 24), - Text( - profile.summary, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 24), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final area in profile.focusAreas) - Chip(label: Text(area)), - ], - ), - const SizedBox(height: 28), - FilledButton.icon( - key: const ValueKey('creator-github-link'), - onPressed: () => launchUrl( - profile.githubUri, - mode: LaunchMode.externalApplication, + '每个主题都由可运行示例、关键概念和实践路径组成。', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colors.onSurfaceVariant, ), - icon: const Icon(Icons.code), - label: const Text('访问 GitHub'), ), + const SizedBox(height: 18), + _CategoryGrid(wide: wide), + const SizedBox(height: 40), + const _CreatorFooter(), ], - ), + ); + }, + ), + ), + ), + ), + ); + } +} + +class _HeroSection extends StatelessWidget { + const _HeroSection({required this.wide}); + + final bool wide; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final introduction = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: colors.secondaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Text( + 'FLUTTER LEARNING WORKSPACE', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colors.onSecondaryContainer, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, ), ), ), ), + const SizedBox(height: 20), + Text( + '把 Flutter 学习\n变成一次次实践', + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w800, + height: 1.12, + letterSpacing: -1.2, + ), + ), + const SizedBox(height: 16), + Text( + 'Flutter Forge 汇集跨平台、状态管理、异步与 UI 实验。不是孤立的 Demo,而是一座可以运行、拆解和验证的学习工坊。', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colors.onSurfaceVariant, + height: 1.65, + ), + ), + const SizedBox(height: 24), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + key: const ValueKey('start-learning'), + onPressed: () => + context.go('/category/${ModuleCategory.basic.name}'), + icon: const Icon(Icons.arrow_forward_rounded), + label: const Text('开始学习'), + ), + OutlinedButton.icon( + key: const ValueKey('creator-github-link'), + onPressed: () => launchUrl( + flutterForgeCreator.githubUri, + mode: LaunchMode.externalApplication, + ), + icon: const Icon(Icons.code_rounded), + label: const Text('GitHub'), + ), + ], + ), + ], + ); + + const highlights = _HighlightPanel(); + return Container( + padding: EdgeInsets.all(wide ? 36 : 24), + decoration: BoxDecoration( + color: colors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: colors.outlineVariant), + boxShadow: [ + BoxShadow( + color: colors.shadow.withValues(alpha: 0.08), + blurRadius: 30, + offset: const Offset(0, 12), + ), + ], + ), + child: wide + ? Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(flex: 6, child: introduction), + const SizedBox(width: 40), + const Expanded(flex: 4, child: highlights), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [introduction, const SizedBox(height: 32), highlights], + ), + ); + } +} + +class _HighlightPanel extends StatelessWidget { + const _HighlightPanel(); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + const highlights = [ + (Icons.devices_rounded, '跨平台', '移动、桌面与 Web'), + (Icons.touch_app_rounded, '可交互', '从代码走向真实体验'), + (Icons.verified_outlined, '可验证', '测试与平台证据同行'), + ]; + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(22), + ), + child: Column( + children: [ + for (var index = 0; index < highlights.length; index++) ...[ + _HighlightRow(data: highlights[index]), + if (index != highlights.length - 1) + Divider(height: 1, color: colors.outlineVariant), + ], + ], + ), + ); + } +} + +class _HighlightRow extends StatelessWidget { + const _HighlightRow({required this.data}); + + final (IconData, String, String) data; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(data.$1, color: colors.onPrimaryContainer), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.$2, style: Theme.of(context).textTheme.titleMedium), + Text( + data.$3, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _CategoryGrid extends StatelessWidget { + const _CategoryGrid({required this.wide}); + + final bool wide; + + @override + Widget build(BuildContext context) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: ModuleCategory.values.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: wide ? 3 : 1, + mainAxisExtent: 118, + crossAxisSpacing: 14, + mainAxisSpacing: 14, + ), + itemBuilder: (context, index) { + final category = ModuleCategory.values[index]; + return _CategoryCard(category: category, index: index); + }, + ); + } +} + +class _CategoryCard extends StatelessWidget { + const _CategoryCard({required this.category, required this.index}); + + final ModuleCategory category; + final int index; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final accent = [ + colors.primary, + colors.tertiary, + colors.secondary, + ][index % 3]; + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + key: ValueKey('home-category:${category.name}'), + onTap: () => context.go('/category/${category.name}'), + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(15), + ), + child: Icon(category.icon, color: accent), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + category.label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Icon(Icons.arrow_outward_rounded, color: colors.onSurfaceVariant), + ], + ), + ), ), ); } } + +class _CreatorFooter extends StatelessWidget { + const _CreatorFooter(); + + @override + Widget build(BuildContext context) { + final profile = flutterForgeCreator; + final colors = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: colors.outlineVariant), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 760; + final identity = _CreatorIdentity(profile: profile); + final details = _CreatorDetails(profile: profile); + return wide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 220, child: identity), + const SizedBox(width: 32), + Expanded(child: details), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [identity, const SizedBox(height: 24), details], + ); + }, + ), + ); + } +} + +class _CreatorIdentity extends StatelessWidget { + const _CreatorIdentity({required this.profile}); + + final CreatorProfile profile; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + radius: 30, + backgroundColor: colors.primaryContainer, + foregroundColor: colors.onPrimaryContainer, + child: Text( + profile.name.substring(0, 1).toUpperCase(), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.name, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + Text( + '@${profile.handle}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.location_on_outlined, + size: 16, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + const Text('深圳'), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _CreatorDetails extends StatelessWidget { + const _CreatorDetails({required this.profile}); + + final CreatorProfile profile; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.headline, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + profile.summary, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.55, + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final area in profile.focusAreas) + Chip(visualDensity: VisualDensity.compact, label: Text(area)), + ], + ), + const SizedBox(height: 18), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _ProfileLink( + key: const ValueKey('profile-github-link'), + icon: Icons.code_rounded, + label: 'GitHub', + uri: profile.githubUri, + ), + _ProfileLink( + key: const ValueKey('profile-juejin-link'), + icon: Icons.article_outlined, + label: '掘金 · 人形打码机', + uri: profile.juejinUri, + ), + _ProfileLink( + key: const ValueKey('profile-yuque-link'), + icon: Icons.menu_book_outlined, + label: '语雀', + uri: profile.yuqueUri, + ), + ], + ), + ], + ); + } +} + +class _ProfileLink extends StatelessWidget { + const _ProfileLink({ + super.key, + required this.icon, + required this.label, + required this.uri, + }); + + final IconData icon; + final String label; + final Uri uri; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), + icon: Icon(icon), + label: Text(label), + ); + } +} diff --git a/apps/flutter_forge/lib/app/creator_profile.dart b/apps/flutter_forge/lib/app/creator_profile.dart index 834b285..2f65be0 100644 --- a/apps/flutter_forge/lib/app/creator_profile.dart +++ b/apps/flutter_forge/lib/app/creator_profile.dart @@ -4,24 +4,37 @@ import 'package:flutter/material.dart'; class CreatorProfile { const CreatorProfile({ required this.name, + required this.handle, required this.headline, required this.summary, required this.focusAreas, required this.githubUri, + required this.juejinUri, + required this.yuqueUri, }); final String name; + final String handle; final String headline; final String summary; final List focusAreas; final Uri githubUri; + final Uri juejinUri; + final Uri yuqueUri; } final CreatorProfile flutterForgeCreator = CreatorProfile( name: 'Lizy', - headline: 'IoT · Flutter · AI Agent Engineering', - summary: - '长期从事 IoT 与 Flutter 跨平台开发,关注设备连接、平台能力、复杂状态治理,以及可控制、可验证、可持续的 Agent 工程流程。', - focusAreas: const ['IoT 设备与系统能力', 'Flutter 跨平台与 Plugin', 'AI Agent 编排与工程治理'], + handle: 'lizy-coding', + headline: 'AI Native Flutter Infrastructure Engineer', + summary: '专注构建 AI 驱动的 Flutter 工程体系与跨平台基础架构。相比孤立的业务功能,更关注可复用、可扩展、可持续演进的工程系统。', + focusAreas: const [ + 'Flutter 跨平台基础架构', + 'AI Engineering Workflow', + '高性能渲染与稳定性', + '复杂系统架构治理', + ], githubUri: Uri.parse('https://github.com/lizy-coding'), + juejinUri: Uri.parse('https://juejin.cn/user/2085122730895063/posts'), + yuqueUri: Uri.parse('https://www.yuque.com/diligent_coding/flutter'), ); diff --git a/apps/flutter_forge/lib/app/module_home_page.dart b/apps/flutter_forge/lib/app/module_home_page.dart index cb77e15..6d26e87 100644 --- a/apps/flutter_forge/lib/app/module_home_page.dart +++ b/apps/flutter_forge/lib/app/module_home_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../module_registry/app_platform_snapshot.dart'; import '../module_registry/module_category.dart'; @@ -37,33 +38,13 @@ class CategoryHomePage extends StatelessWidget { child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1200), - child: Row( - children: [ - Icon(category.icon, size: 32), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.label, - style: Theme.of(context).textTheme.headlineMedium, - ), - Text('${categoryModules.length} 个学习模块'), - ], - ), - ), - if (!focusedWindow && - platform.hostFamily == AppHostFamily.desktop && - MultiWindowManager.isSupported) - OutlinedButton.icon( - key: ValueKey('open-window:${category.name}'), - onPressed: () => MultiWindowManager.instance - .createCategoryWindow(category), - icon: const Icon(Icons.open_in_new), - label: const Text('在新窗口打开'), - ), - ], + child: _CategoryHeader( + category: category, + moduleCount: categoryModules.length, + showWindowAction: + !focusedWindow && + platform.hostFamily == AppHostFamily.desktop && + MultiWindowManager.isSupported, ), ), ), @@ -98,6 +79,92 @@ class CategoryHomePage extends StatelessWidget { } } +class _CategoryHeader extends StatelessWidget { + const _CategoryHeader({ + required this.category, + required this.moduleCount, + required this.showWindowAction, + }); + + final ModuleCategory category; + final int moduleCount; + final bool showWindowAction; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return DecoratedBox( + key: ValueKey('category-header:${category.name}'), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colors.primaryContainer.withValues(alpha: 0.72), + colors.surfaceContainerHigh, + ], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: colors.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(18), + ), + child: Icon(category.icon, color: colors.onPrimary, size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.label, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '$moduleCount 个学习模块', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + if (showWindowAction) ...[ + const SizedBox(width: 16), + Tooltip( + message: '在独立窗口中专注学习这个分类', + child: FilledButton.tonalIcon( + key: ValueKey('open-window:${category.name}'), + onPressed: () => MultiWindowManager.instance + .createCategoryWindow(category), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 16, + ), + ), + icon: const Icon(Icons.open_in_new_rounded, size: 19), + label: const Text('在新窗口打开'), + ), + ), + ], + ], + ), + ), + ); + } +} + class ModuleCard extends StatelessWidget { const ModuleCard({super.key, required this.module, required this.platform}); @@ -130,6 +197,17 @@ class ModuleCard extends StatelessWidget { visualDensity: VisualDensity.compact, label: Text(module.difficulty.label), ), + if (platform.isWeb) + IconButton( + key: ValueKey('open-tab:${module.path}'), + tooltip: '在新标签页打开', + visualDensity: VisualDensity.compact, + onPressed: () => launchUrl( + Uri.base.resolve(module.path), + webOnlyWindowName: '_blank', + ), + icon: const Icon(Icons.open_in_new_rounded, size: 19), + ), ], ), const SizedBox(height: 6), diff --git a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart index 044b9ee..23696ae 100644 --- a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart +++ b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart @@ -16,13 +16,35 @@ void main() { expect(find.byType(NavigationRail), findsNothing); expect(find.byKey(const ValueKey('expanded-sidebar')), findsNothing); expect(find.text('Lizy'), findsOneWidget); - expect(find.text('IoT · Flutter · AI Agent Engineering'), findsOneWidget); + expect( + find.text('AI Native Flutter Infrastructure Engineer'), + findsOneWidget, + ); expect(find.byKey(const ValueKey('creator-github-link')), findsOneWidget); + expect(find.byKey(const ValueKey('profile-juejin-link')), findsOneWidget); await tester.tap(find.byTooltip('Open navigation menu')); await tester.pumpAndSettle(); expect(find.byType(NavigationDrawer), findsOneWidget); - expect(find.text('架构与状态'), findsOneWidget); + expect( + find.descendant( + of: find.byType(NavigationDrawer), + matching: find.text('架构与状态'), + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('creator home stays overflow-free at 320dp', (tester) async { + await _pumpShell(tester, size: const Size(320, 700)); + + expect(find.byKey(const ValueKey('creator-home-scroll')), findsOneWidget); + expect(find.byKey(const ValueKey('start-learning')), findsOneWidget); + expect( + find.byKey(const ValueKey('home-category:platform')), + findsOneWidget, + ); expect(tester.takeException(), isNull); }); @@ -48,6 +70,17 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('slash opens title search from the web shell', (tester) async { + await _pumpShell(tester, size: const Size(1200, 800)); + + await tester.sendKeyEvent(LogicalKeyboardKey.slash); + await tester.pumpAndSettle(); + + expect(find.text('搜索模块标题'), findsOneWidget); + expect(find.byTooltip('返回'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('category content changes from one to two columns', ( tester, ) async { @@ -69,6 +102,72 @@ void main() { await tester.pump(); expect(tester.takeException(), isNull); }); + + testWidgets('desktop category header presents a focused-window action', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1100, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + home: CategoryHomePage( + category: ModuleCategory.basic, + modules: AppRouteTable.modules, + platform: const AppPlatformSnapshot( + platform: AppTargetPlatform.macOS, + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('category-header:basic')), findsOneWidget); + expect(find.byKey(const ValueKey('open-window:basic')), findsOneWidget); + expect(find.text('在新窗口打开'), findsOneWidget); + expect(find.byType(FilledButton), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('focused category window omits the open-window action', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1100, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + home: CategoryHomePage( + category: ModuleCategory.basic, + modules: AppRouteTable.modules, + platform: const AppPlatformSnapshot( + platform: AppTargetPlatform.macOS, + ), + focusedWindow: true, + ), + ), + ); + + expect(find.byKey(const ValueKey('open-window:basic')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('web module cards offer an explicit new-tab action', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1100, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + home: CategoryHomePage( + category: ModuleCategory.basic, + modules: AppRouteTable.modules, + platform: const AppPlatformSnapshot(platform: AppTargetPlatform.web), + ), + ), + ); + + expect(find.byTooltip('在新标签页打开'), findsWidgets); + expect(find.byKey(const ValueKey('open-window:basic')), findsNothing); + expect(tester.takeException(), isNull); + }); } Future _pumpShell(WidgetTester tester, {required Size size}) async { From 9019c1c2fe6183aa4da4007e2dc57be86b42afad Mon Sep 17 00:00:00 2001 From: lizy Date: Tue, 22 Sep 2026 20:54:48 +0800 Subject: [PATCH 20/21] feat(app): add search keyboard shortcut --- .../lib/app/adaptive_app_shell.dart | 193 ++++++++++-------- .../responsive_navigation_layout_test.dart | 1 + 2 files changed, 112 insertions(+), 82 deletions(-) diff --git a/apps/flutter_forge/lib/app/adaptive_app_shell.dart b/apps/flutter_forge/lib/app/adaptive_app_shell.dart index 141dc0a..2f0d5ee 100644 --- a/apps/flutter_forge/lib/app/adaptive_app_shell.dart +++ b/apps/flutter_forge/lib/app/adaptive_app_shell.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import '../module_registry/module_category.dart'; @@ -64,88 +65,96 @@ class _AdaptiveAppShellState extends State { sidebarExpanded: _sidebarExpanded, ); final compact = layout == AppNavigationLayout.compact; - return Scaffold( - key: const ValueKey('adaptive-app-shell'), - appBar: - compact && - (widget.location == '/' || - widget.location.startsWith('/category/')) - ? AppBar( - title: const Text('Flutter Forge'), - actions: [ - IconButton( - tooltip: '搜索模块', - onPressed: _showSearch, - icon: const Icon(Icons.search), - ), - ], - ) - : null, - drawer: compact - ? NavigationDrawer( - selectedIndex: _selectedIndex(), - onDestinationSelected: (index) { - Navigator.of(context).pop(); - _goTo(index); - }, - children: _destinations - .map( - (destination) => NavigationDrawerDestination( - key: ValueKey( - 'category-drawer:${_destinations.indexOf(destination) == 0 ? 'home' : ModuleCategory.values[_destinations.indexOf(destination) - 1].name}', - ), - icon: Icon(destination.icon), - label: Text(destination.label), + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.slash): _showSearch, + }, + child: Focus( + autofocus: true, + child: Scaffold( + key: const ValueKey('adaptive-app-shell'), + appBar: + compact && + (widget.location == '/' || + widget.location.startsWith('/category/')) + ? AppBar( + title: const Text('Flutter Forge'), + actions: [ + IconButton( + tooltip: '搜索模块', + onPressed: _showSearch, + icon: const Icon(Icons.search), ), - ) - .toList(), - ) - : null, - body: compact - ? widget.child - : Row( - children: [ - if (layout == AppNavigationLayout.sidebar) - _Sidebar( - selectedIndex: _selectedIndex(), - onDestinationSelected: _goTo, - onSearch: _showSearch, - onCollapse: () => - setState(() => _sidebarExpanded = false), - ) - else - NavigationRail( - key: const ValueKey('navigation-rail'), - selectedIndex: _selectedIndex(), - onDestinationSelected: _goTo, - leading: Column( - children: [ - IconButton( - tooltip: '展开侧栏', - onPressed: () => - setState(() => _sidebarExpanded = true), - icon: const Icon(Icons.menu_open), + ], + ) + : null, + drawer: compact + ? NavigationDrawer( + selectedIndex: _selectedIndex(), + onDestinationSelected: (index) { + Navigator.of(context).pop(); + _goTo(index); + }, + children: _destinations + .map( + (destination) => NavigationDrawerDestination( + key: ValueKey( + 'category-drawer:${_destinations.indexOf(destination) == 0 ? 'home' : ModuleCategory.values[_destinations.indexOf(destination) - 1].name}', + ), + icon: Icon(destination.icon), + label: Text(destination.label), ), - IconButton( - tooltip: '搜索模块', - onPressed: _showSearch, - icon: const Icon(Icons.search), + ) + .toList(), + ) + : null, + body: compact + ? widget.child + : Row( + children: [ + if (layout == AppNavigationLayout.sidebar) + _Sidebar( + selectedIndex: _selectedIndex(), + onDestinationSelected: _goTo, + onSearch: _showSearch, + onCollapse: () => + setState(() => _sidebarExpanded = false), + ) + else + NavigationRail( + key: const ValueKey('navigation-rail'), + selectedIndex: _selectedIndex(), + onDestinationSelected: _goTo, + leading: Column( + children: [ + IconButton( + tooltip: '展开侧栏', + onPressed: () => + setState(() => _sidebarExpanded = true), + icon: const Icon(Icons.menu_open), + ), + IconButton( + tooltip: '搜索模块', + onPressed: _showSearch, + icon: const Icon(Icons.search), + ), + ], ), - ], - ), - destinations: _destinations - .map( - (destination) => NavigationRailDestination( - icon: Icon(destination.icon), - label: Text(destination.label), - ), - ) - .toList(), - ), - const VerticalDivider(width: 1), - Expanded(child: widget.child), - ], - ), + destinations: _destinations + .map( + (destination) => NavigationRailDestination( + icon: Icon(destination.icon), + label: Text(destination.label), + ), + ) + .toList(), + ), + const VerticalDivider(width: 1), + Expanded(child: widget.child), + ], + ), + ), + ), ); }, ); @@ -189,10 +198,30 @@ class _Sidebar extends StatelessWidget { key: const ValueKey('sidebar-search'), readOnly: true, onTap: onSearch, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.search), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), hintText: '搜索模块标题', - border: OutlineInputBorder(), + suffixIcon: Padding( + padding: const EdgeInsets.all(10), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: const Center( + child: Text( + '/', + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + ), + ), + border: const OutlineInputBorder(), ), ), ), diff --git a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart index 23696ae..7a13727 100644 --- a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart +++ b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_forge_app/app/adaptive_app_shell.dart'; import 'package:flutter_forge_app/app/creator_home_page.dart'; import 'package:flutter_forge_app/app/module_home_page.dart'; From 4c8af50be8cbad9c6f873472f1c64a511ee7cb7d Mon Sep 17 00:00:00 2001 From: lizy Date: Thu, 24 Sep 2026 18:11:09 +0800 Subject: [PATCH 21/21] build: upgrade gcode_core to v0.2.0 --- apps/flutter_forge/pubspec.yaml | 2 +- pubspec.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/flutter_forge/pubspec.yaml b/apps/flutter_forge/pubspec.yaml index c363c44..eb6929f 100644 --- a/apps/flutter_forge/pubspec.yaml +++ b/apps/flutter_forge/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: gcode_core: git: url: https://github.com/lizy-coding/gcode_core.git - ref: v0.2.0-dev.1 + ref: v0.2.0 file_picker_bridge: path: ../../packages/file_picker_bridge flutter_ioc_core: diff --git a/pubspec.lock b/pubspec.lock index 26111a5..2783983 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -356,11 +356,11 @@ packages: dependency: transitive description: path: "." - ref: "v0.2.0-dev.1" - resolved-ref: "019678664bb5891c72f904373efad3aea8238fc6" + ref: "v0.2.0" + resolved-ref: "318dd628a1e3900b0c8b90d7883983f850c7cb34" url: "https://github.com/lizy-coding/gcode_core.git" source: git - version: "0.2.0-dev.1" + version: "0.2.0" glob: dependency: transitive description: