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

6
.gitignore vendored
View File

@@ -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

View File

@@ -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<version>-build<code>.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.

View File

@@ -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")
}
}
}

View File

@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="jamshalat_masjid_screen"
android:name="${applicationName}"

View File

@@ -0,0 +1,4 @@
storePassword=CHANGE_ME
keyPassword=CHANGE_ME
keyAlias=upload
storeFile=keystore/upload-keystore.jks

View 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,
});
}

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,

View File

@@ -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');

View File

@@ -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:

View File

@@ -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

77
scripts/build_release_apk.sh Executable file
View File

@@ -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" <<EOF
{
"latest_version": "$VERSION_NAME",
"version_code": $BUILD_NUMBER,
"apk_url": "UPLOAD_APK_URL_HERE",
"published_at": "$PUBLISHED_AT",
"notes": "FILL_RELEASE_NOTES_HERE",
"sha256": "$APK_SHA256",
"min_supported_version_code": 1
}
EOF
echo
echo "Build complete."
echo "Versioned APK : $VERSIONED_APK"
echo "Latest APK : $LATEST_APK"
echo "SHA-256 file : $CHECKSUM_FILE"
echo "JSON template : $JSON_TEMPLATE_FILE"
echo
echo "Release signing config was loaded from android/key.properties."

View File

@@ -3,6 +3,7 @@ import 'package:jamshalat_masjid_screen/core/enums.dart';
import 'package:jamshalat_masjid_screen/data/local/models.dart';
import 'package:jamshalat_masjid_screen/data/services/hijri_service.dart';
import 'package:jamshalat_masjid_screen/data/services/sync_service.dart';
import 'package:jamshalat_masjid_screen/data/services/update_service.dart';
void main() {
group('PrayerName display labels', () {
@@ -117,4 +118,24 @@ void main() {
expect(staleKeys, isNot(contains('2026-04-30')));
});
});
group('AppUpdateInfo parsing', () {
test('supports +0700 timezone format and preserves multiline notes', () {
final info = AppUpdateInfo.fromJson({
'latest_version': '1.0.0',
'version_code': 1,
'apk_url': 'https://files.jamshalat.com/app.apk',
'published_at': '2026-04-01T12:05:23+0700',
'notes': 'Initial APK\n\n- Menu Admin Baru: Tentang\n- Akses Admin Panel',
'sha256': 'abc123',
'min_supported_version_code': 1,
});
expect(info.isValid, isTrue);
expect(info.publishedAt, isNotNull);
expect(info.publishedAt!.toUtc(), DateTime.utc(2026, 4, 1, 5, 5, 23));
expect(info.notes, contains('\n\n- Menu Admin Baru: Tentang'));
expect(info.notes, contains('\n- Akses Admin Panel'));
});
});
}