Add TV update flow in Tentang and fix startup zone

This commit is contained in:
dwindown
2026-04-01 14:20:04 +07:00
parent 081ed9f695
commit 925189417d
12 changed files with 953 additions and 44 deletions

View File

@@ -11,6 +11,7 @@ import '../../providers.dart';
import '../../data/services/sync_service.dart';
import '../../data/services/myquran_service.dart';
import '../../data/services/sound_service.dart';
import '../../data/services/update_service.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';
@@ -76,15 +77,18 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
final _tampilanScrollController = ScrollController();
final _jumatScrollController = ScrollController();
final _simulasiScrollController = ScrollController();
final _tentangScrollController = ScrollController();
late final FocusNode _identityEntryFocusNode;
late final FocusNode _tampilanEntryFocusNode;
late final FocusNode _jumatEntryFocusNode;
late final FocusNode _simulasiEntryFocusNode;
late final FocusNode _tentangEntryFocusNode;
late final List<FocusNode> _navFocusNodes;
late final List<FocusNode> _jadwalFocusNodes;
late final List<FocusNode> _identityFocusNodes;
late final List<FocusNode> _jumatFocusNodes;
late final List<FocusNode> _simulasiFocusNodes;
late final List<FocusNode> _tentangFocusNodes;
final Map<int, FocusNode> _tampilanFocusNodes = {};
Timer? _identityAutoSaveTimer;
Timer? _tampilanAutoSaveTimer;
@@ -94,17 +98,23 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
String? _statusBadgeMessage;
bool _statusBadgeIsError = false;
int _hijriOffsetDays = 0;
AppVersionInfo? _currentVersion;
UpdateCheckResult? _updateCheckResult;
bool _isCheckingUpdate = false;
bool _isInstallingUpdate = false;
double _updateDownloadProgress = 0;
@override
void initState() {
super.initState();
_selectedTab = widget.initialTab.clamp(0, 4);
_selectedTab = widget.initialTab.clamp(0, 5);
_identityEntryFocusNode = FocusNode(debugLabel: 'identity_entry');
_tampilanEntryFocusNode = FocusNode(debugLabel: 'tampilan_entry');
_jumatEntryFocusNode = FocusNode(debugLabel: 'jumat_entry');
_simulasiEntryFocusNode = FocusNode(debugLabel: 'simulasi_entry');
_tentangEntryFocusNode = FocusNode(debugLabel: 'tentang_entry');
_navFocusNodes = List.generate(
5,
6,
(index) => FocusNode(debugLabel: 'admin_nav_$index'),
);
_identityFocusNodes = [
@@ -125,6 +135,10 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
(index) => FocusNode(debugLabel: 'simulasi_row_${index + 1}'),
),
];
_tentangFocusNodes = [
_tentangEntryFocusNode,
FocusNode(debugLabel: 'tentang_row_1'),
];
_jadwalFocusNodes = List.generate(
11,
(index) => FocusNode(debugLabel: 'jadwal_row_$index'),
@@ -192,6 +206,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_focusNavTab(_selectedTab);
}
});
unawaited(_loadCurrentVersion());
}
@override
@@ -218,6 +233,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_tampilanScrollController.dispose();
_jumatScrollController.dispose();
_simulasiScrollController.dispose();
_tentangScrollController.dispose();
_tampilanEntryFocusNode.dispose();
_identityAutoSaveTimer?.cancel();
_tampilanAutoSaveTimer?.cancel();
@@ -236,6 +252,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
for (final node in _simulasiFocusNodes) {
node.dispose();
}
for (final node in _tentangFocusNodes) {
node.dispose();
}
for (final node in _jadwalFocusNodes) {
node.dispose();
}
@@ -371,6 +390,66 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
});
}
Future<void> _loadCurrentVersion() async {
final version = await UpdateService.instance.getCurrentVersion();
if (!mounted) return;
setState(() {
_currentVersion = version;
});
}
Future<void> _checkForUpdates() async {
if (_isCheckingUpdate) return;
setState(() => _isCheckingUpdate = true);
final result = await UpdateService.instance.checkForUpdate();
if (!mounted) return;
setState(() {
_isCheckingUpdate = false;
_currentVersion = result.current;
_updateCheckResult = result;
});
_showStatusBadge(
result.hasError
? result.errorMessage!
: result.updateAvailable
? 'Update baru tersedia'
: 'Versi ini sudah terbaru',
isError: result.hasError,
);
}
Future<void> _installLatestUpdate() async {
final result = _updateCheckResult;
final remote = result?.remote;
if (_isInstallingUpdate || result == null || remote == null) return;
if (!result.updateAvailable) {
_showStatusBadge('Versi ini sudah terbaru');
return;
}
setState(() {
_isInstallingUpdate = true;
_updateDownloadProgress = 0;
});
final installResult = await UpdateService.instance.downloadAndTriggerInstall(
remote,
onProgress: (progress) {
if (!mounted) return;
setState(() {
_updateDownloadProgress = progress.clamp(0, 1);
});
},
);
if (!mounted) return;
setState(() {
_isInstallingUpdate = false;
});
_showStatusBadge(installResult.message, isError: !installResult.success);
}
Future<void> _syncData() async {
setState(() => _isSyncing = true);
final success = await SyncService.instance.syncMonthlyData();
@@ -976,6 +1055,19 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
onKeyEvent: (node, event) => _handleNavKey(4, event),
onTap: () => setState(() => _selectedTab = 4),
),
SizedBox(height: 16 * s),
_NavButton(
title: 'TENTANG',
icon: HugeIcons.strokeRoundedInformationCircle,
isActive: _selectedTab == 5,
scale: s,
focusNode: _navFocusNodes[5],
onFocusChange: (focused) {
if (focused) _setSelectedTab(5);
},
onKeyEvent: (node, event) => _handleNavKey(5, event),
onTap: () => setState(() => _selectedTab = 5),
),
],
),
),
@@ -992,7 +1084,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
? _buildTampilanTab(s)
: _selectedTab == 3
? _buildJumatTab(s)
: _buildSimulasiTab(s),
: _selectedTab == 4
? _buildSimulasiTab(s)
: _buildTentangTab(s),
),
),
],
@@ -1045,6 +1139,16 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
});
}
void _focusTentangRow(int index) {
if (_selectedTab != 5) return;
if (index < 0 || index >= _tentangFocusNodes.length) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_tentangFocusNodes[index].requestFocus();
}
});
}
void _focusJadwalRow(int index) {
if (_selectedTab != 1) return;
if (index < 0 || index >= _jadwalFocusNodes.length) return;
@@ -1112,6 +1216,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
case 4:
_focusSimulasiRow(0);
return;
case 5:
_focusTentangRow(0);
return;
default:
target = null;
}
@@ -1285,6 +1392,37 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
return KeyEventResult.ignored;
}
KeyEventResult _handleTentangActionKey(
int index,
KeyEvent event, {
required VoidCallback onActivate,
}) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowUp) {
_focusTentangRow(index - 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowDown) {
_focusTentangRow(index + 1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
_focusNavTab(_selectedTab);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
onActivate();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Widget _buildJumatTab(double s) {
return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(),
@@ -2316,6 +2454,69 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
);
}
Widget _buildTentangActionButton({
required int rowIndex,
required double s,
required VoidCallback onActivate,
Widget? child,
Widget Function(bool isFocused)? builder,
}) {
assert(child != null || builder != null);
final focusNode = _tentangFocusNodes[rowIndex];
return _scrollAware(
controller: _tentangScrollController,
child: Focus(
focusNode: focusNode,
onKeyEvent: (node, event) => _handleTentangActionKey(
rowIndex,
event,
onActivate: onActivate,
),
child: ListenableBuilder(
listenable: focusNode,
builder: (context, _) {
final isFocused = focusNode.hasFocus;
return AnimatedScale(
scale: isFocused ? 1.01 : 1.0,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
padding: EdgeInsets.all(isFocused ? 5 * s : 0),
decoration: BoxDecoration(
color: isFocused
? SacredColors.surfaceContainerLow.withValues(alpha: 0.96)
: Colors.transparent,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(
color: isFocused
? SacredColors.primary.withValues(alpha: 0.95)
: Colors.transparent,
width: isFocused ? 3 : 0,
),
boxShadow: isFocused
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.28),
blurRadius: 24 * s,
spreadRadius: 2 * s,
),
]
: null,
),
child: ExcludeFocus(
child: builder?.call(isFocused) ?? child!,
),
),
);
},
),
),
);
}
Widget _buildTvChoiceField({
required double s,
required int rowIndex,
@@ -3093,8 +3294,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
case 3:
return _jumatScrollController;
case 4:
default:
return _simulasiScrollController;
case 5:
return _tentangScrollController;
default:
return _identityScrollController;
}
}
@@ -3512,6 +3716,246 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
);
}
Widget _buildTentangTab(double s) {
final currentVersion = _currentVersion;
final updateResult = _updateCheckResult;
final remote = updateResult?.remote;
return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(),
child: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) => _handleSimpleTabKey(event),
child: SingleChildScrollView(
controller: _tentangScrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tentang Aplikasi',
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
SizedBox(height: 16 * s),
Text(
'Informasi aplikasi, kontak bantuan, dan pemeriksaan versi terbaru.',
style: GoogleFonts.manrope(
fontSize: 18 * s,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 40 * s),
_adminCard(
s,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Kontak Bantuan', s),
SizedBox(height: 20 * s),
_buildStatusRow(
'Nama Pengembang',
'Dwindi Ramadhana',
HugeIcons.strokeRoundedUser,
s,
),
SizedBox(height: 20 * s),
_buildStatusRow(
'Alamat',
'Yogyakarta, Indonesia',
HugeIcons.strokeRoundedLocation01,
s,
),
SizedBox(height: 20 * s),
_buildStatusRow(
'Nomor Kontak',
'+62 812 2988 6864',
HugeIcons.strokeRoundedCall02,
s,
),
],
),
),
SizedBox(height: 32 * s),
_adminCard(
s,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Versi & Pembaruan', s),
SizedBox(height: 20 * s),
_buildStatusRow(
'Versi Saat Ini',
currentVersion?.displayLabel ?? 'Memuat versi...',
HugeIcons.strokeRoundedPackage,
s,
),
SizedBox(height: 20 * s),
_buildStatusRow(
'Sumber Update',
'files.jamshalat.com/latest.json',
HugeIcons.strokeRoundedLinkCircle02,
s,
),
if (updateResult != null) ...[
SizedBox(height: 20 * s),
_buildStatusRow(
'Status',
_buildUpdateStatusLabel(updateResult),
updateResult.hasError
? HugeIcons.strokeRoundedAlert02
: updateResult.updateAvailable
? HugeIcons.strokeRoundedArrowDown01
: HugeIcons.strokeRoundedCheckmarkCircle02,
s,
),
],
if (remote != null) ...[
SizedBox(height: 20 * s),
_buildStatusRow(
'Versi Remote',
'${remote.latestVersion}+${remote.versionCode}',
HugeIcons.strokeRoundedPackage,
s,
),
if (remote.publishedAt != null) ...[
SizedBox(height: 20 * s),
_buildStatusRow(
'Tanggal Rilis',
DateFormat(
'dd MMM yyyy, HH:mm',
'id_ID',
).format(remote.publishedAt!.toLocal()),
HugeIcons.strokeRoundedCalendar03,
s,
),
],
if (remote.notes.isNotEmpty) ...[
SizedBox(height: 24 * s),
Container(
width: double.infinity,
padding: EdgeInsets.all(24 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(SacredRadii.lg),
border: Border.all(
color: SacredColors.outlineVariant.withValues(
alpha: 0.35,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Catatan Rilis',
style: GoogleFonts.manrope(
fontSize: 15 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 12 * s),
Text(
remote.notes,
style: GoogleFonts.manrope(
fontSize: 18 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurface,
height: 1.5,
),
),
],
),
),
],
],
SizedBox(height: 24 * s),
_buildTentangActionButton(
rowIndex: 0,
s: s,
onActivate: _checkForUpdates,
builder: (isFocused) => _buildTvPrimaryActionSurface(
s: s,
isFocused: isFocused,
icon: _isCheckingUpdate
? SizedBox(
width: 24 * s,
height: 24 * s,
child: CircularProgressIndicator(
color: isFocused
? SacredColors.onPrimary
: SacredColors.onSecondary,
strokeWidth: 3,
),
)
: HugeIcon(
icon: HugeIcons.strokeRoundedRefresh,
color: isFocused
? SacredColors.onPrimary
: SacredColors.onSecondary,
),
label: _isCheckingUpdate
? 'MEMERIKSA UPDATE...'
: 'CEK UPDATE',
),
),
SizedBox(height: 16 * s),
_buildTentangActionButton(
rowIndex: 1,
s: s,
onActivate: _installLatestUpdate,
builder: (isFocused) => _buildTvPrimaryActionSurface(
s: s,
isFocused: isFocused,
icon: _isInstallingUpdate
? SizedBox(
width: 24 * s,
height: 24 * s,
child: CircularProgressIndicator(
color: isFocused
? SacredColors.onPrimary
: SacredColors.onSecondary,
strokeWidth: 3,
),
)
: HugeIcon(
icon: HugeIcons.strokeRoundedArrowDown01,
color: isFocused
? SacredColors.onPrimary
: SacredColors.onSecondary,
),
label: _isInstallingUpdate
? 'MENGUNDUH UPDATE ${(100 * _updateDownloadProgress).toStringAsFixed(0)}%'
: (updateResult?.updateAvailable ?? false)
? 'UPDATE SEKARANG'
: 'BELUM ADA UPDATE',
),
),
],
),
),
],
),
),
),
);
}
String _buildUpdateStatusLabel(UpdateCheckResult result) {
if (result.hasError) {
return result.errorMessage ?? 'Pemeriksaan update gagal';
}
if (result.updateAvailable) {
final remote = result.remote;
if (remote == null) return 'Update tersedia';
return 'Update tersedia ke ${remote.latestVersion}+${remote.versionCode}';
}
return 'Versi ini sudah terbaru';
}
Widget _simulasiCard({
required double s,
required String title,