diff --git a/.gitignore b/.gitignore index 3820a95..b7d2d89 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ +/dist/ /coverage/ # Symbolication related @@ -43,3 +44,8 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/android/.kotlin/ +/android/key.properties +/android/keystore/ +*.jks +*.keystore diff --git a/README.md b/README.md index e4d7055..5c12291 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,46 @@ flutter analyze flutter test ``` +### Build Release APK + +```bash +./scripts/build_release_apk.sh +``` + +Artifacts will be written to `dist/android/`: + +- versioned APK: `jamshalat-masjid-screen-v-build.apk` +- latest APK copy: `jamshalat-masjid-screen-latest.apk` +- checksum file: `*.sha256` +- update JSON template: `latest.json.example` + +### Android Production Signing + +Release APKs now require real signing config. Before building: + +1. Copy [android/key.properties.example](/Users/dwindown/CascadeProjects/jamshalat-masjid-screen/android/key.properties.example) to `android/key.properties` +2. Fill in your real keystore values +3. Put the keystore file under `android/keystore/` or update `storeFile` accordingly + +Example: + +```properties +storePassword=YOUR_STORE_PASSWORD +keyPassword=YOUR_KEY_PASSWORD +keyAlias=upload +storeFile=keystore/upload-keystore.jks +``` + +Notes: + +- `android/key.properties` +- `android/keystore/` +- `*.jks` + +are ignored by Git and must stay private. + +The release build now fails fast if signing is not configured, so you cannot accidentally produce another debug-signed production APK. + ## Current Stabilization Status The app is in a workable development state, but not yet fully stabilized. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index de59889..cb186e7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +7,24 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +val hasReleaseSigning = keystorePropertiesFile.exists() +if (hasReleaseSigning) { + keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) } +} + +val isReleaseTaskRequested = gradle.startParameter.taskNames.any { + it.contains("Release", ignoreCase = true) +} + +if (isReleaseTaskRequested && !hasReleaseSigning) { + throw GradleException( + "Missing android/key.properties for production release signing. " + + "Copy android/key.properties.example to android/key.properties and fill in your keystore values." + ) +} + android { namespace = "com.jamshalat.jamshalat_masjid_screen" compileSdk = flutter.compileSdkVersion @@ -30,11 +50,20 @@ android { versionName = flutter.versionName } + signingConfigs { + if (hasReleaseSigning) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = rootProject.file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("release") } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6c66fa9..2376e72 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + '${match.group(1)}:${match.group(2)}', + ); + return DateTime.tryParse(normalized); + } + + factory AppUpdateInfo.fromJson(Map 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 getCurrentVersion() async { + final packageInfo = await PackageInfo.fromPlatform(); + return AppVersionInfo( + versionName: packageInfo.version, + versionCode: int.tryParse(packageInfo.buildNumber) ?? 0, + ); + } + + Future 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; + 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 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, + }); +} diff --git a/lib/features/admin/admin_screen.dart b/lib/features/admin/admin_screen.dart index 059172a..91d30ac 100644 --- a/lib/features/admin/admin_screen.dart +++ b/lib/features/admin/admin_screen.dart @@ -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 { 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 _navFocusNodes; late final List _jadwalFocusNodes; late final List _identityFocusNodes; late final List _jumatFocusNodes; late final List _simulasiFocusNodes; + late final List _tentangFocusNodes; final Map _tampilanFocusNodes = {}; Timer? _identityAutoSaveTimer; Timer? _tampilanAutoSaveTimer; @@ -94,17 +98,23 @@ class _AdminScreenState extends ConsumerState { 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 { (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 { _focusNavTab(_selectedTab); } }); + unawaited(_loadCurrentVersion()); } @override @@ -218,6 +233,7 @@ class _AdminScreenState extends ConsumerState { _tampilanScrollController.dispose(); _jumatScrollController.dispose(); _simulasiScrollController.dispose(); + _tentangScrollController.dispose(); _tampilanEntryFocusNode.dispose(); _identityAutoSaveTimer?.cancel(); _tampilanAutoSaveTimer?.cancel(); @@ -236,6 +252,9 @@ class _AdminScreenState extends ConsumerState { 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 { }); } + Future _loadCurrentVersion() async { + final version = await UpdateService.instance.getCurrentVersion(); + if (!mounted) return; + setState(() { + _currentVersion = version; + }); + } + + Future _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 _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 _syncData() async { setState(() => _isSyncing = true); final success = await SyncService.instance.syncMonthlyData(); @@ -976,6 +1055,19 @@ class _AdminScreenState extends ConsumerState { 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 { ? _buildTampilanTab(s) : _selectedTab == 3 ? _buildJumatTab(s) - : _buildSimulasiTab(s), + : _selectedTab == 4 + ? _buildSimulasiTab(s) + : _buildTentangTab(s), ), ), ], @@ -1045,6 +1139,16 @@ class _AdminScreenState extends ConsumerState { }); } + 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 { case 4: _focusSimulasiRow(0); return; + case 5: + _focusTentangRow(0); + return; default: target = null; } @@ -1285,6 +1392,37 @@ class _AdminScreenState extends ConsumerState { 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 { ); } + 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 { case 3: return _jumatScrollController; case 4: - default: return _simulasiScrollController; + case 5: + return _tentangScrollController; + default: + return _identityScrollController; } } @@ -3512,6 +3716,246 @@ class _AdminScreenState extends ConsumerState { ); } + 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, diff --git a/lib/main.dart b/lib/main.dart index bb16bc9..cb633f0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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'); diff --git a/pubspec.lock b/pubspec.lock index f9178c3..31f4a0f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -122,7 +122,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -381,14 +381,22 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" - package_info_plus: - dependency: transitive + open_filex: + dependency: "direct main" description: - name: package_info_plus - sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "4.7.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" package_info_plus_platform_interface: dependency: transitive description: @@ -414,7 +422,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -630,10 +638,10 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.3.3" wakelock_plus_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3c82b5f..77eb94b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,10 @@ dependencies: # HTTP http: ^1.2.0 + package_info_plus: ^8.1.3 + crypto: ^3.0.6 + path_provider: ^2.1.5 + open_filex: ^4.7.0 # Date/Time formatting intl: ^0.20.0 diff --git a/scripts/build_release_apk.sh b/scripts/build_release_apk.sh new file mode 100755 index 0000000..fbc608d --- /dev/null +++ b/scripts/build_release_apk.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PUBSPEC_FILE="$ROOT_DIR/pubspec.yaml" +OUTPUT_DIR="$ROOT_DIR/dist/android" +SOURCE_APK="$ROOT_DIR/build/app/outputs/flutter-apk/app-release.apk" +APP_SLUG="jamshalat-masjid-screen" +KEY_PROPERTIES_FILE="$ROOT_DIR/android/key.properties" + +if [[ ! -f "$PUBSPEC_FILE" ]]; then + echo "pubspec.yaml not found at: $PUBSPEC_FILE" >&2 + exit 1 +fi + +if [[ ! -f "$KEY_PROPERTIES_FILE" ]]; then + echo "Missing production signing config: $KEY_PROPERTIES_FILE" >&2 + echo "Copy android/key.properties.example to android/key.properties and fill in your real keystore values." >&2 + exit 1 +fi + +VERSION_LINE="$(grep -E '^version:' "$PUBSPEC_FILE" | head -n 1 | sed -E 's/^version:[[:space:]]*//')" +if [[ -z "$VERSION_LINE" ]]; then + echo "Unable to read version from pubspec.yaml" >&2 + exit 1 +fi + +VERSION_NAME="${VERSION_LINE%%+*}" +if [[ "$VERSION_LINE" == *"+"* ]]; then + BUILD_NUMBER="${VERSION_LINE##*+}" +else + BUILD_NUMBER="0" +fi + +VERSIONED_APK="$OUTPUT_DIR/${APP_SLUG}-v${VERSION_NAME}-build${BUILD_NUMBER}.apk" +LATEST_APK="$OUTPUT_DIR/${APP_SLUG}-latest.apk" +CHECKSUM_FILE="$VERSIONED_APK.sha256" +JSON_TEMPLATE_FILE="$OUTPUT_DIR/latest.json.example" +PUBLISHED_AT="$(date '+%Y-%m-%dT%H:%M:%S%z')" + +mkdir -p "$OUTPUT_DIR" + +echo "Building Android release APK for version $VERSION_NAME+$BUILD_NUMBER" +(cd "$ROOT_DIR" && flutter build apk --release "$@") + +if [[ ! -f "$SOURCE_APK" ]]; then + echo "Expected APK not found: $SOURCE_APK" >&2 + exit 1 +fi + +cp "$SOURCE_APK" "$VERSIONED_APK" +cp "$SOURCE_APK" "$LATEST_APK" + +APK_SHA256="$(shasum -a 256 "$VERSIONED_APK" | awk '{print $1}')" +printf '%s %s\n' "$APK_SHA256" "$(basename "$VERSIONED_APK")" > "$CHECKSUM_FILE" + +cat > "$JSON_TEMPLATE_FILE" <