Add TV update flow in Tentang and fix startup zone
This commit is contained in:
274
lib/data/services/update_service.dart
Normal file
274
lib/data/services/update_service.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class AppUpdateInfo {
|
||||
final String latestVersion;
|
||||
final int versionCode;
|
||||
final String apkUrl;
|
||||
final DateTime? publishedAt;
|
||||
final String notes;
|
||||
final String sha256;
|
||||
final int minSupportedVersionCode;
|
||||
|
||||
const AppUpdateInfo({
|
||||
required this.latestVersion,
|
||||
required this.versionCode,
|
||||
required this.apkUrl,
|
||||
required this.publishedAt,
|
||||
required this.notes,
|
||||
required this.sha256,
|
||||
required this.minSupportedVersionCode,
|
||||
});
|
||||
|
||||
static DateTime? _parsePublishedAt(String? rawValue) {
|
||||
final value = (rawValue ?? '').trim();
|
||||
if (value.isEmpty) return null;
|
||||
|
||||
// Accept timestamps with timezone offset both as +07:00 and +0700.
|
||||
final normalized = value.replaceFirstMapped(
|
||||
RegExp(r'([+-]\d{2})(\d{2})$'),
|
||||
(match) => '${match.group(1)}:${match.group(2)}',
|
||||
);
|
||||
return DateTime.tryParse(normalized);
|
||||
}
|
||||
|
||||
factory AppUpdateInfo.fromJson(Map<String, dynamic> json) {
|
||||
return AppUpdateInfo(
|
||||
latestVersion: (json['latest_version'] as String? ?? '').trim(),
|
||||
versionCode: (json['version_code'] as num?)?.toInt() ?? 0,
|
||||
apkUrl: (json['apk_url'] as String? ?? '').trim(),
|
||||
publishedAt: _parsePublishedAt(json['published_at'] as String?),
|
||||
notes: (json['notes'] as String? ?? '').trim(),
|
||||
sha256: (json['sha256'] as String? ?? '').trim(),
|
||||
minSupportedVersionCode:
|
||||
(json['min_supported_version_code'] as num?)?.toInt() ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isValid =>
|
||||
latestVersion.isNotEmpty && versionCode > 0 && apkUrl.isNotEmpty;
|
||||
}
|
||||
|
||||
class AppVersionInfo {
|
||||
final String versionName;
|
||||
final int versionCode;
|
||||
|
||||
const AppVersionInfo({
|
||||
required this.versionName,
|
||||
required this.versionCode,
|
||||
});
|
||||
|
||||
String get displayLabel => '$versionName+$versionCode';
|
||||
}
|
||||
|
||||
class UpdateCheckResult {
|
||||
final AppVersionInfo current;
|
||||
final AppUpdateInfo? remote;
|
||||
final bool updateAvailable;
|
||||
final String? errorMessage;
|
||||
|
||||
const UpdateCheckResult({
|
||||
required this.current,
|
||||
required this.remote,
|
||||
required this.updateAvailable,
|
||||
required this.errorMessage,
|
||||
});
|
||||
|
||||
bool get hasError => errorMessage != null;
|
||||
|
||||
const UpdateCheckResult.error({
|
||||
required AppVersionInfo current,
|
||||
required String message,
|
||||
}) : this(
|
||||
current: current,
|
||||
remote: null,
|
||||
updateAvailable: false,
|
||||
errorMessage: message,
|
||||
);
|
||||
|
||||
const UpdateCheckResult.success({
|
||||
required AppVersionInfo current,
|
||||
required AppUpdateInfo remote,
|
||||
required bool updateAvailable,
|
||||
}) : this(
|
||||
current: current,
|
||||
remote: remote,
|
||||
updateAvailable: updateAvailable,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
UpdateService._();
|
||||
|
||||
static const String metadataUrl = 'https://files.jamshalat.com/latest.json';
|
||||
static final UpdateService instance = UpdateService._();
|
||||
|
||||
Future<AppVersionInfo> getCurrentVersion() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
return AppVersionInfo(
|
||||
versionName: packageInfo.version,
|
||||
versionCode: int.tryParse(packageInfo.buildNumber) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<UpdateCheckResult> checkForUpdate() async {
|
||||
final current = await getCurrentVersion();
|
||||
|
||||
try {
|
||||
final response = await http.get(
|
||||
Uri.parse(metadataUrl),
|
||||
headers: const {
|
||||
'Accept': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return UpdateCheckResult.error(
|
||||
current: current,
|
||||
message: 'Gagal mengambil data update (${response.statusCode}).',
|
||||
);
|
||||
}
|
||||
|
||||
final payload = json.decode(response.body) as Map<String, dynamic>;
|
||||
final remote = AppUpdateInfo.fromJson(payload);
|
||||
|
||||
if (!remote.isValid) {
|
||||
return UpdateCheckResult.error(
|
||||
current: current,
|
||||
message: 'Format update tidak valid.',
|
||||
);
|
||||
}
|
||||
|
||||
final updateAvailable = remote.versionCode > current.versionCode;
|
||||
return UpdateCheckResult.success(
|
||||
current: current,
|
||||
remote: remote,
|
||||
updateAvailable: updateAvailable,
|
||||
);
|
||||
} catch (_) {
|
||||
return UpdateCheckResult.error(
|
||||
current: current,
|
||||
message: 'Tidak dapat memeriksa update saat ini.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<UpdateInstallResult> downloadAndTriggerInstall(
|
||||
AppUpdateInfo info, {
|
||||
void Function(double progress)? onProgress,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return const UpdateInstallResult(
|
||||
success: false,
|
||||
message: 'Install update otomatis hanya didukung di Android.',
|
||||
);
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(info.apkUrl);
|
||||
if (uri == null) {
|
||||
return const UpdateInstallResult(
|
||||
success: false,
|
||||
message: 'URL APK tidak valid.',
|
||||
);
|
||||
}
|
||||
|
||||
final client = http.Client();
|
||||
IOSink? sink;
|
||||
try {
|
||||
final request = http.Request('GET', uri);
|
||||
final response = await client.send(request);
|
||||
if (response.statusCode != 200) {
|
||||
return UpdateInstallResult(
|
||||
success: false,
|
||||
message: 'Download APK gagal (${response.statusCode}).',
|
||||
);
|
||||
}
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
'${tempDir.path}/jamshalat-update-v${info.latestVersion}-b${info.versionCode}.apk',
|
||||
);
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
sink = file.openWrite();
|
||||
|
||||
final totalBytes = response.contentLength ?? 0;
|
||||
var receivedBytes = 0;
|
||||
await for (final chunk in response.stream) {
|
||||
sink.add(chunk);
|
||||
receivedBytes += chunk.length;
|
||||
if (totalBytes > 0) {
|
||||
onProgress?.call(receivedBytes / totalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
sink = null;
|
||||
onProgress?.call(1);
|
||||
|
||||
final expectedSha = info.sha256.trim().toLowerCase();
|
||||
final downloadedSha = sha256
|
||||
.convert(await file.readAsBytes())
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
if (expectedSha.isNotEmpty && expectedSha != downloadedSha) {
|
||||
await file.delete();
|
||||
return const UpdateInstallResult(
|
||||
success: false,
|
||||
message: 'Checksum APK tidak cocok. Download dibatalkan.',
|
||||
);
|
||||
}
|
||||
|
||||
final openResult = await OpenFilex.open(
|
||||
file.path,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
if (openResult.type != ResultType.done) {
|
||||
final msg = openResult.message.trim().isNotEmpty
|
||||
? openResult.message.trim()
|
||||
: 'Tidak dapat membuka installer Android.';
|
||||
return UpdateInstallResult(
|
||||
success: false,
|
||||
message: msg,
|
||||
);
|
||||
}
|
||||
|
||||
return UpdateInstallResult(
|
||||
success: true,
|
||||
message: 'APK selesai diunduh. Silakan lanjutkan instalasi pada prompt Android.',
|
||||
downloadedFilePath: file.path,
|
||||
);
|
||||
} catch (_) {
|
||||
return const UpdateInstallResult(
|
||||
success: false,
|
||||
message: 'Terjadi kesalahan saat mengunduh atau memasang update.',
|
||||
);
|
||||
} finally {
|
||||
await sink?.close();
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateInstallResult {
|
||||
final bool success;
|
||||
final String message;
|
||||
final String? downloadedFilePath;
|
||||
|
||||
const UpdateInstallResult({
|
||||
required this.success,
|
||||
required this.message,
|
||||
this.downloadedFilePath,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -13,38 +13,39 @@ import 'core/sacred_tokens.dart';
|
||||
import 'data/local/models.dart';
|
||||
import 'features/home/home_view.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
debugPrint('[Fatal][FlutterError] ${details.exceptionAsString()}');
|
||||
};
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
debugPrint('[Fatal][PlatformDispatcher] $error');
|
||||
debugPrintStack(stackTrace: stack);
|
||||
return true;
|
||||
};
|
||||
ErrorWidget.builder = (details) {
|
||||
return const Material(
|
||||
color: SacredColors.background,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'Terjadi gangguan tampilan.\nAplikasi tetap berjalan dalam mode aman.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: SacredColors.onSurface,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
void main() {
|
||||
runZonedGuarded(() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
debugPrint('[Fatal][FlutterError] ${details.exceptionAsString()}');
|
||||
};
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
debugPrint('[Fatal][PlatformDispatcher] $error');
|
||||
debugPrintStack(stackTrace: stack);
|
||||
return true;
|
||||
};
|
||||
ErrorWidget.builder = (details) {
|
||||
return const Material(
|
||||
color: SacredColors.background,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'Terjadi gangguan tampilan.\nAplikasi tetap berjalan dalam mode aman.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: SacredColors.onSurface,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
);
|
||||
};
|
||||
|
||||
await runZonedGuarded(() async {
|
||||
await _bootstrapAndRun();
|
||||
}, (error, stack) {
|
||||
debugPrint('[Fatal][Zone] $error');
|
||||
|
||||
Reference in New Issue
Block a user