From c70a6baf7b062650df56741686c0ae2b76aaa5d8 Mon Sep 17 00:00:00 2001 From: dwindown Date: Sun, 5 Apr 2026 14:30:10 +0700 Subject: [PATCH] fix(tv-focus): stabilize tampilan navigation and reveal + bump 1.0.8+9 --- lib/features/admin/admin_screen.dart | 477 ++++++++++++++++----------- pubspec.yaml | 2 +- 2 files changed, 284 insertions(+), 195 deletions(-) diff --git a/lib/features/admin/admin_screen.dart b/lib/features/admin/admin_screen.dart index ef50ad5..90563e7 100644 --- a/lib/features/admin/admin_screen.dart +++ b/lib/features/admin/admin_screen.dart @@ -527,6 +527,14 @@ class _AdminScreenState extends ConsumerState { } Future _showCitySearchDialog(double s) async { + bool isActivateKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.space || + key == LogicalKeyboardKey.gameButtonA; + } + final queryCtrl = TextEditingController(); final queryFocusNode = FocusNode(debugLabel: 'city_query'); final searchFocusNode = FocusNode(debugLabel: 'city_search'); @@ -639,6 +647,9 @@ class _AdminScreenState extends ConsumerState { label: 'Kata Kunci Kota / Kabupaten', controller: queryCtrl, focusNode: queryFocusNode, + onMoveDown: () { + searchFocusNode.requestFocus(); + }, onEditComplete: () { WidgetsBinding.instance.addPostFrameCallback((_) { if (ctx.mounted) { @@ -667,8 +678,7 @@ class _AdminScreenState extends ConsumerState { } return KeyEventResult.handled; } - if (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.select) { + if (isActivateKey(key)) { runSearch(); return KeyEventResult.handled; } @@ -804,10 +814,7 @@ class _AdminScreenState extends ConsumerState { return KeyEventResult.ignored; } final key = event.logicalKey; - if (key == - LogicalKeyboardKey.enter || - key == - LogicalKeyboardKey.select) { + if (isActivateKey(key)) { selectCity(city); return KeyEventResult.handled; } @@ -1185,41 +1192,37 @@ class _AdminScreenState extends ConsumerState { void _focusIdentityRow(int index) { if (_selectedTab != 0) return; if (index < 0 || index >= _identityFocusNodes.length) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _identityFocusNodes[index].requestFocus(); - } - }); + _requestFocusAndReveal( + _identityFocusNodes[index], + _identityScrollController, + ); } void _focusJumatRow(int index) { if (_selectedTab != 4) return; if (index < 0 || index >= _jumatFocusNodes.length) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _jumatFocusNodes[index].requestFocus(); - } - }); + _requestFocusAndReveal( + _jumatFocusNodes[index], + _jumatScrollController, + ); } void _focusSimulasiRow(int index) { if (_selectedTab != 5) return; if (index < 0 || index >= _simulasiFocusNodes.length) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _simulasiFocusNodes[index].requestFocus(); - } - }); + _requestFocusAndReveal( + _simulasiFocusNodes[index], + _simulasiScrollController, + ); } void _focusTentangRow(int index) { if (_selectedTab != 6) return; if (index < 0 || index >= _tentangRowCount()) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _tentangFocusNodes[index].requestFocus(); - } - }); + _requestFocusAndReveal( + _tentangFocusNodes[index], + _tentangScrollController, + ); } int _tentangRowCount() { @@ -1229,11 +1232,10 @@ class _AdminScreenState extends ConsumerState { void _focusJadwalRow(int index) { if (_selectedTab != 1) return; if (index < 0 || index >= _jadwalFocusNodes.length) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _jadwalFocusNodes[index].requestFocus(); - } - }); + _requestFocusAndReveal( + _jadwalFocusNodes[index], + _jadwalScrollController, + ); } FocusNode _tampilanFocusNode(int index) { @@ -1276,11 +1278,10 @@ class _AdminScreenState extends ConsumerState { if (_selectedTab != 2) return; final max = _tampilanRowCount(); if (index < 0 || index >= max) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _tampilanFocusNode(index).requestFocus(); - } - }); + _requestFocusAndReveal( + _tampilanFocusNode(index), + _tampilanScrollController, + ); } int _pengumumanRowCount() { @@ -1298,10 +1299,30 @@ class _AdminScreenState extends ConsumerState { if (_selectedTab != 3) return; final max = _pengumumanRowCount(); if (index < 0 || index >= max) return; + _requestFocusAndReveal( + _pengumumanFocusNode(index), + _pengumumanScrollController, + ); + } + + void _requestFocusAndReveal( + FocusNode node, + ScrollController controller, + ) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _pengumumanFocusNode(index).requestFocus(); - } + if (!mounted) return; + node.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((__) { + if (!mounted || !controller.hasClients) return; + final focusContext = node.context; + if (focusContext == null || !focusContext.mounted) return; + Scrollable.ensureVisible( + focusContext, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: 0.18, + ); + }); }); } @@ -1977,8 +1998,14 @@ class _AdminScreenState extends ConsumerState { label: 'Gunakan Foto Unsplash API', value: _useUnsplash, onChanged: (val) { + final previous = _useUnsplash; setState(() => _useUnsplash = val); _queueTampilanAutoSave(); + if (!previous && val) { + _focusTampilanRow(useUnsplashRow + 1); + } else if (previous && !val) { + _focusTampilanRow(useUnsplashRow); + } }, trueLabel: 'Aktif', falseLabel: 'Nonaktif', @@ -2363,36 +2390,39 @@ class _AdminScreenState extends ConsumerState { ), ), SizedBox(height: 12 * s), - _TvEditableTextTile( - scale: s, - label: 'Isi Text Slide', - focusNode: _pengumumanFocusNode( - textSlideTextRows[idx], + _scrollAware( + controller: _pengumumanScrollController, + child: _TvEditableTextTile( + scale: s, + label: 'Isi Text Slide', + focusNode: _pengumumanFocusNode( + textSlideTextRows[idx], + ), + controller: textCtrl, + maxLines: 3, + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusPengumumanRow( + idx == 0 + ? announcementDurationRow + : textSlideDeleteRows[idx - 1], + ), + onMoveDown: () => _focusPengumumanRow( + textSlideDeleteRows[idx], + ), + onChanged: (val) { + _textSlides[idx] = val; + _queuePengumumanAutoSave( + message: + 'Text slide tengah otomatis tersimpan', + ); + }, + onEditComplete: () { + _queuePengumumanAutoSave( + message: + 'Text slide tengah otomatis tersimpan', + ); + }, ), - controller: textCtrl, - maxLines: 3, - onMoveLeft: () => _focusNavTab(_selectedTab), - onMoveUp: () => _focusPengumumanRow( - idx == 0 - ? announcementDurationRow - : textSlideDeleteRows[idx - 1], - ), - onMoveDown: () => _focusPengumumanRow( - textSlideDeleteRows[idx], - ), - onChanged: (val) { - _textSlides[idx] = val; - _queuePengumumanAutoSave( - message: - 'Text slide tengah otomatis tersimpan', - ); - }, - onEditComplete: () { - _queuePengumumanAutoSave( - message: - 'Text slide tengah otomatis tersimpan', - ); - }, ), SizedBox(height: 10 * s), _buildPengumumanActionButton( @@ -2579,57 +2609,26 @@ class _AdminScreenState extends ConsumerState { ), ), SizedBox(height: 12 * s), - _TvEditableTextTile( - scale: s, - label: 'Teks Berjalan', - focusNode: _pengumumanFocusNode( - runningTextTextRows[idx], - ), - controller: textCtrl, - onMoveLeft: () => _focusNavTab(_selectedTab), - onMoveUp: () => _focusPengumumanRow( - idx == 0 - ? marqueeModeRow - : runningTextDeleteRows[idx - 1], - ), - onMoveDown: () => _focusPengumumanRow( - runningTextDurationRows[idx], - ), - onChanged: (val) { - _runningTexts[idx] = val; - _queuePengumumanAutoSave( - message: - 'Teks berjalan otomatis tersimpan', - ); - }, - onEditComplete: () { - _queuePengumumanAutoSave( - message: - 'Teks berjalan otomatis tersimpan', - ); - }, - ), - SizedBox(height: 12 * s), - SizedBox( - width: 180 * s, + _scrollAware( + controller: _pengumumanScrollController, child: _TvEditableTextTile( scale: s, - label: 'Durasi (detik)', + label: 'Teks Berjalan', focusNode: _pengumumanFocusNode( - runningTextDurationRows[idx], - ), - controller: durCtrl, - keyboardType: TextInputType.number, - onMoveLeft: () => _focusNavTab(_selectedTab), - onMoveUp: () => _focusPengumumanRow( runningTextTextRows[idx], ), + controller: textCtrl, + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusPengumumanRow( + idx == 0 + ? marqueeModeRow + : runningTextDeleteRows[idx - 1], + ), onMoveDown: () => _focusPengumumanRow( - runningTextDeleteRows[idx], + runningTextDurationRows[idx], ), onChanged: (val) { - _runningTextDurations[idx] = - int.tryParse(val) ?? 12; + _runningTexts[idx] = val; _queuePengumumanAutoSave( message: 'Teks berjalan otomatis tersimpan', @@ -2643,6 +2642,43 @@ class _AdminScreenState extends ConsumerState { }, ), ), + SizedBox(height: 12 * s), + SizedBox( + width: 180 * s, + child: _scrollAware( + controller: _pengumumanScrollController, + child: _TvEditableTextTile( + scale: s, + label: 'Durasi (detik)', + focusNode: _pengumumanFocusNode( + runningTextDurationRows[idx], + ), + controller: durCtrl, + keyboardType: TextInputType.number, + onMoveLeft: () => _focusNavTab(_selectedTab), + onMoveUp: () => _focusPengumumanRow( + runningTextTextRows[idx], + ), + onMoveDown: () => _focusPengumumanRow( + runningTextDeleteRows[idx], + ), + onChanged: (val) { + _runningTextDurations[idx] = + int.tryParse(val) ?? 12; + _queuePengumumanAutoSave( + message: + 'Teks berjalan otomatis tersimpan', + ); + }, + onEditComplete: () { + _queuePengumumanAutoSave( + message: + 'Teks berjalan otomatis tersimpan', + ); + }, + ), + ), + ), SizedBox(height: 10 * s), _buildPengumumanActionButton( rowIndex: runningTextDeleteRows[idx], @@ -2881,16 +2917,25 @@ class _AdminScreenState extends ConsumerState { } final key = event.logicalKey; if (key == LogicalKeyboardKey.arrowLeft) { - onMoveLeft?.call(); - return KeyEventResult.handled; + if (onMoveLeft != null) { + onMoveLeft(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (key == LogicalKeyboardKey.arrowUp) { - onMoveUp?.call(); - return KeyEventResult.handled; + if (onMoveUp != null) { + onMoveUp(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (key == LogicalKeyboardKey.arrowDown) { - onMoveDown?.call(); - return KeyEventResult.handled; + if (onMoveDown != null) { + onMoveDown(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (key == LogicalKeyboardKey.arrowRight) { return KeyEventResult.handled; @@ -4046,52 +4091,50 @@ class _AdminScreenState extends ConsumerState { final denominator = max - min; final progress = denominator <= 0 ? 0.0 : ((value - min) / denominator); - return _scrollAware( - controller: _scrollControllerForTab(_selectedTab), - child: _buildTvAdjustTile( - s: s, - focusNode: focusNode, - label: label, - valueLabel: valueLabel, - progress: progress.clamp(0.0, 1.0), - helperText: 'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk menyesuaikan nilai.', - onMoveLeft: onMoveLeft, - onMoveUp: onMoveUp, - onMoveDown: onMoveDown, - onIncrement: () { - _bumpCtrlInt( - controller, - delta: 1, - min: min, - max: max, - fallback: fallback, - ); - onValueChanged?.call(); - }, - onDecrement: () { - _bumpCtrlInt( - controller, - delta: -1, - min: min, - max: max, - fallback: fallback, - ); - onValueChanged?.call(); - }, - onProgressChanged: denominator <= 0 - ? null - : (nextProgress) { - final mapped = (min + ((max - min) * nextProgress)) - .round() - .clamp(min, max) - .toInt(); - if (mapped == _parseCtrlInt(controller, fallback)) return; - setState(() { - controller.text = mapped.toString(); - }); - onValueChanged?.call(); - }, - ), + return _buildTvAdjustTile( + s: s, + focusNode: focusNode, + label: label, + valueLabel: valueLabel, + progress: progress.clamp(0.0, 1.0), + helperText: + 'Tekan OK untuk mulai ubah. Saat aktif, gunakan ← → untuk menyesuaikan nilai.', + onMoveLeft: onMoveLeft, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + onIncrement: () { + _bumpCtrlInt( + controller, + delta: 1, + min: min, + max: max, + fallback: fallback, + ); + onValueChanged?.call(); + }, + onDecrement: () { + _bumpCtrlInt( + controller, + delta: -1, + min: min, + max: max, + fallback: fallback, + ); + onValueChanged?.call(); + }, + onProgressChanged: denominator <= 0 + ? null + : (nextProgress) { + final mapped = (min + ((max - min) * nextProgress)) + .round() + .clamp(min, max) + .toInt(); + if (mapped == _parseCtrlInt(controller, fallback)) return; + setState(() { + controller.text = mapped.toString(); + }); + onValueChanged?.call(); + }, ); } @@ -4109,19 +4152,22 @@ class _AdminScreenState extends ConsumerState { required VoidCallback onDecrement, ValueChanged? onProgressChanged, }) { - return _TvAdjustTile( - scale: s, - focusNode: focusNode, - label: label, - valueLabel: valueLabel, - progress: progress, - helperText: helperText, - onMoveLeft: onMoveLeft, - onMoveUp: onMoveUp, - onMoveDown: onMoveDown, - onIncrement: onIncrement, - onDecrement: onDecrement, - onProgressChanged: onProgressChanged, + return _scrollAware( + controller: _scrollControllerForTab(_selectedTab), + child: _TvAdjustTile( + scale: s, + focusNode: focusNode, + label: label, + valueLabel: valueLabel, + progress: progress, + helperText: helperText, + onMoveLeft: onMoveLeft, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + onIncrement: onIncrement, + onDecrement: onDecrement, + onProgressChanged: onProgressChanged, + ), ); } @@ -4972,29 +5018,60 @@ class _TvAdjustTileState extends State<_TvAdjustTile> { } final key = event.logicalKey; - if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { + final isActivateKey = key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.space || + key == LogicalKeyboardKey.gameButtonA; + + if (isActivateKey) { setState(() => _isEditing = !_isEditing); return KeyEventResult.handled; } if (!_isEditing && key == LogicalKeyboardKey.arrowUp) { - widget.onMoveUp?.call(); - return KeyEventResult.handled; + if (widget.onMoveUp != null) { + widget.onMoveUp!.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (!_isEditing && key == LogicalKeyboardKey.arrowDown) { - widget.onMoveDown?.call(); - return KeyEventResult.handled; + if (widget.onMoveDown != null) { + widget.onMoveDown!.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (!_isEditing && key == LogicalKeyboardKey.arrowLeft) { - widget.onMoveLeft?.call(); - return KeyEventResult.handled; + if (widget.onMoveLeft != null) { + widget.onMoveLeft!.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (!_isEditing && key == LogicalKeyboardKey.arrowRight) { - return KeyEventResult.handled; + return KeyEventResult.ignored; } if (!_isEditing) return KeyEventResult.ignored; + if (key == LogicalKeyboardKey.arrowUp) { + setState(() => _isEditing = false); + if (widget.onMoveUp != null) { + widget.onMoveUp!.call(); + } + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowDown) { + setState(() => _isEditing = false); + if (widget.onMoveDown != null) { + widget.onMoveDown!.call(); + } + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowLeft) { widget.onDecrement(); return KeyEventResult.handled; @@ -5300,26 +5377,39 @@ class _TvEditableTextTileState extends State<_TvEditableTextTile> { } final key = event.logicalKey; + final isActivateKey = key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.numpadEnter || + key == LogicalKeyboardKey.space || + key == LogicalKeyboardKey.gameButtonA; if (!_isEditing && - (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.select)) { + isActivateKey) { _startEditing(); return KeyEventResult.handled; } if (!_isEditing && key == LogicalKeyboardKey.arrowLeft) { - widget.onMoveLeft?.call(); - return KeyEventResult.handled; + if (widget.onMoveLeft != null) { + widget.onMoveLeft!.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (!_isEditing && key == LogicalKeyboardKey.arrowUp) { - widget.onMoveUp?.call(); - return KeyEventResult.handled; + if (widget.onMoveUp != null) { + widget.onMoveUp!.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (!_isEditing && key == LogicalKeyboardKey.arrowDown) { - widget.onMoveDown?.call(); - return KeyEventResult.handled; + if (widget.onMoveDown != null) { + widget.onMoveDown!.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } if (_isEditing && key == LogicalKeyboardKey.escape) { @@ -5329,8 +5419,7 @@ class _TvEditableTextTileState extends State<_TvEditableTextTile> { if (_isEditing && widget.maxLines == 1 && - (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.select)) { + isActivateKey) { _finishEditing(); return KeyEventResult.handled; } diff --git a/pubspec.yaml b/pubspec.yaml index 026c768..4c3157a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: jamshalat_masjid_screen description: Smart Digital Prayer Clock for Android TV Box publish_to: 'none' -version: 1.0.6+7 +version: 1.0.8+9 environment: sdk: '>=3.0.0 <4.0.0'