Add TV update flow in Tentang and fix startup zone
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -31,6 +31,7 @@ migrate_working_dir/
|
|||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
|
/dist/
|
||||||
/coverage/
|
/coverage/
|
||||||
|
|
||||||
# Symbolication related
|
# Symbolication related
|
||||||
@@ -43,3 +44,8 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
/android/.kotlin/
|
||||||
|
/android/key.properties
|
||||||
|
/android/keystore/
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -90,6 +90,46 @@ flutter analyze
|
|||||||
flutter test
|
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
|
## Current Stabilization Status
|
||||||
|
|
||||||
The app is in a workable development state, but not yet fully stabilized.
|
The app is in a workable development state, but not yet fully stabilized.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +7,24 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
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 {
|
android {
|
||||||
namespace = "com.jamshalat.jamshalat_masjid_screen"
|
namespace = "com.jamshalat.jamshalat_masjid_screen"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -30,11 +50,20 @@ android {
|
|||||||
versionName = flutter.versionName
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
<application
|
<application
|
||||||
android:label="jamshalat_masjid_screen"
|
android:label="jamshalat_masjid_screen"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
4
android/key.properties.example
Normal file
4
android/key.properties.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
storePassword=CHANGE_ME
|
||||||
|
keyPassword=CHANGE_ME
|
||||||
|
keyAlias=upload
|
||||||
|
storeFile=keystore/upload-keystore.jks
|
||||||
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/sync_service.dart';
|
||||||
import '../../data/services/myquran_service.dart';
|
import '../../data/services/myquran_service.dart';
|
||||||
import '../../data/services/sound_service.dart';
|
import '../../data/services/sound_service.dart';
|
||||||
|
import '../../data/services/update_service.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -76,15 +77,18 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
final _tampilanScrollController = ScrollController();
|
final _tampilanScrollController = ScrollController();
|
||||||
final _jumatScrollController = ScrollController();
|
final _jumatScrollController = ScrollController();
|
||||||
final _simulasiScrollController = ScrollController();
|
final _simulasiScrollController = ScrollController();
|
||||||
|
final _tentangScrollController = ScrollController();
|
||||||
late final FocusNode _identityEntryFocusNode;
|
late final FocusNode _identityEntryFocusNode;
|
||||||
late final FocusNode _tampilanEntryFocusNode;
|
late final FocusNode _tampilanEntryFocusNode;
|
||||||
late final FocusNode _jumatEntryFocusNode;
|
late final FocusNode _jumatEntryFocusNode;
|
||||||
late final FocusNode _simulasiEntryFocusNode;
|
late final FocusNode _simulasiEntryFocusNode;
|
||||||
|
late final FocusNode _tentangEntryFocusNode;
|
||||||
late final List<FocusNode> _navFocusNodes;
|
late final List<FocusNode> _navFocusNodes;
|
||||||
late final List<FocusNode> _jadwalFocusNodes;
|
late final List<FocusNode> _jadwalFocusNodes;
|
||||||
late final List<FocusNode> _identityFocusNodes;
|
late final List<FocusNode> _identityFocusNodes;
|
||||||
late final List<FocusNode> _jumatFocusNodes;
|
late final List<FocusNode> _jumatFocusNodes;
|
||||||
late final List<FocusNode> _simulasiFocusNodes;
|
late final List<FocusNode> _simulasiFocusNodes;
|
||||||
|
late final List<FocusNode> _tentangFocusNodes;
|
||||||
final Map<int, FocusNode> _tampilanFocusNodes = {};
|
final Map<int, FocusNode> _tampilanFocusNodes = {};
|
||||||
Timer? _identityAutoSaveTimer;
|
Timer? _identityAutoSaveTimer;
|
||||||
Timer? _tampilanAutoSaveTimer;
|
Timer? _tampilanAutoSaveTimer;
|
||||||
@@ -94,17 +98,23 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
String? _statusBadgeMessage;
|
String? _statusBadgeMessage;
|
||||||
bool _statusBadgeIsError = false;
|
bool _statusBadgeIsError = false;
|
||||||
int _hijriOffsetDays = 0;
|
int _hijriOffsetDays = 0;
|
||||||
|
AppVersionInfo? _currentVersion;
|
||||||
|
UpdateCheckResult? _updateCheckResult;
|
||||||
|
bool _isCheckingUpdate = false;
|
||||||
|
bool _isInstallingUpdate = false;
|
||||||
|
double _updateDownloadProgress = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedTab = widget.initialTab.clamp(0, 4);
|
_selectedTab = widget.initialTab.clamp(0, 5);
|
||||||
_identityEntryFocusNode = FocusNode(debugLabel: 'identity_entry');
|
_identityEntryFocusNode = FocusNode(debugLabel: 'identity_entry');
|
||||||
_tampilanEntryFocusNode = FocusNode(debugLabel: 'tampilan_entry');
|
_tampilanEntryFocusNode = FocusNode(debugLabel: 'tampilan_entry');
|
||||||
_jumatEntryFocusNode = FocusNode(debugLabel: 'jumat_entry');
|
_jumatEntryFocusNode = FocusNode(debugLabel: 'jumat_entry');
|
||||||
_simulasiEntryFocusNode = FocusNode(debugLabel: 'simulasi_entry');
|
_simulasiEntryFocusNode = FocusNode(debugLabel: 'simulasi_entry');
|
||||||
|
_tentangEntryFocusNode = FocusNode(debugLabel: 'tentang_entry');
|
||||||
_navFocusNodes = List.generate(
|
_navFocusNodes = List.generate(
|
||||||
5,
|
6,
|
||||||
(index) => FocusNode(debugLabel: 'admin_nav_$index'),
|
(index) => FocusNode(debugLabel: 'admin_nav_$index'),
|
||||||
);
|
);
|
||||||
_identityFocusNodes = [
|
_identityFocusNodes = [
|
||||||
@@ -125,6 +135,10 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
(index) => FocusNode(debugLabel: 'simulasi_row_${index + 1}'),
|
(index) => FocusNode(debugLabel: 'simulasi_row_${index + 1}'),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
_tentangFocusNodes = [
|
||||||
|
_tentangEntryFocusNode,
|
||||||
|
FocusNode(debugLabel: 'tentang_row_1'),
|
||||||
|
];
|
||||||
_jadwalFocusNodes = List.generate(
|
_jadwalFocusNodes = List.generate(
|
||||||
11,
|
11,
|
||||||
(index) => FocusNode(debugLabel: 'jadwal_row_$index'),
|
(index) => FocusNode(debugLabel: 'jadwal_row_$index'),
|
||||||
@@ -192,6 +206,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
_focusNavTab(_selectedTab);
|
_focusNavTab(_selectedTab);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
unawaited(_loadCurrentVersion());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -218,6 +233,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
_tampilanScrollController.dispose();
|
_tampilanScrollController.dispose();
|
||||||
_jumatScrollController.dispose();
|
_jumatScrollController.dispose();
|
||||||
_simulasiScrollController.dispose();
|
_simulasiScrollController.dispose();
|
||||||
|
_tentangScrollController.dispose();
|
||||||
_tampilanEntryFocusNode.dispose();
|
_tampilanEntryFocusNode.dispose();
|
||||||
_identityAutoSaveTimer?.cancel();
|
_identityAutoSaveTimer?.cancel();
|
||||||
_tampilanAutoSaveTimer?.cancel();
|
_tampilanAutoSaveTimer?.cancel();
|
||||||
@@ -236,6 +252,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
for (final node in _simulasiFocusNodes) {
|
for (final node in _simulasiFocusNodes) {
|
||||||
node.dispose();
|
node.dispose();
|
||||||
}
|
}
|
||||||
|
for (final node in _tentangFocusNodes) {
|
||||||
|
node.dispose();
|
||||||
|
}
|
||||||
for (final node in _jadwalFocusNodes) {
|
for (final node in _jadwalFocusNodes) {
|
||||||
node.dispose();
|
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 {
|
Future<void> _syncData() async {
|
||||||
setState(() => _isSyncing = true);
|
setState(() => _isSyncing = true);
|
||||||
final success = await SyncService.instance.syncMonthlyData();
|
final success = await SyncService.instance.syncMonthlyData();
|
||||||
@@ -976,6 +1055,19 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
onKeyEvent: (node, event) => _handleNavKey(4, event),
|
onKeyEvent: (node, event) => _handleNavKey(4, event),
|
||||||
onTap: () => setState(() => _selectedTab = 4),
|
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)
|
? _buildTampilanTab(s)
|
||||||
: _selectedTab == 3
|
: _selectedTab == 3
|
||||||
? _buildJumatTab(s)
|
? _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) {
|
void _focusJadwalRow(int index) {
|
||||||
if (_selectedTab != 1) return;
|
if (_selectedTab != 1) return;
|
||||||
if (index < 0 || index >= _jadwalFocusNodes.length) return;
|
if (index < 0 || index >= _jadwalFocusNodes.length) return;
|
||||||
@@ -1112,6 +1216,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
case 4:
|
case 4:
|
||||||
_focusSimulasiRow(0);
|
_focusSimulasiRow(0);
|
||||||
return;
|
return;
|
||||||
|
case 5:
|
||||||
|
_focusTentangRow(0);
|
||||||
|
return;
|
||||||
default:
|
default:
|
||||||
target = null;
|
target = null;
|
||||||
}
|
}
|
||||||
@@ -1285,6 +1392,37 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
return KeyEventResult.ignored;
|
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) {
|
Widget _buildJumatTab(double s) {
|
||||||
return FocusTraversalGroup(
|
return FocusTraversalGroup(
|
||||||
policy: WidgetOrderTraversalPolicy(),
|
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({
|
Widget _buildTvChoiceField({
|
||||||
required double s,
|
required double s,
|
||||||
required int rowIndex,
|
required int rowIndex,
|
||||||
@@ -3093,8 +3294,11 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
case 3:
|
case 3:
|
||||||
return _jumatScrollController;
|
return _jumatScrollController;
|
||||||
case 4:
|
case 4:
|
||||||
default:
|
|
||||||
return _simulasiScrollController;
|
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({
|
Widget _simulasiCard({
|
||||||
required double s,
|
required double s,
|
||||||
required String title,
|
required String title,
|
||||||
|
|||||||
@@ -13,38 +13,39 @@ import 'core/sacred_tokens.dart';
|
|||||||
import 'data/local/models.dart';
|
import 'data/local/models.dart';
|
||||||
import 'features/home/home_view.dart';
|
import 'features/home/home_view.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
runZonedGuarded(() async {
|
||||||
FlutterError.onError = (details) {
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
FlutterError.presentError(details);
|
|
||||||
debugPrint('[Fatal][FlutterError] ${details.exceptionAsString()}');
|
FlutterError.onError = (details) {
|
||||||
};
|
FlutterError.presentError(details);
|
||||||
PlatformDispatcher.instance.onError = (error, stack) {
|
debugPrint('[Fatal][FlutterError] ${details.exceptionAsString()}');
|
||||||
debugPrint('[Fatal][PlatformDispatcher] $error');
|
};
|
||||||
debugPrintStack(stackTrace: stack);
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
return true;
|
debugPrint('[Fatal][PlatformDispatcher] $error');
|
||||||
};
|
debugPrintStack(stackTrace: stack);
|
||||||
ErrorWidget.builder = (details) {
|
return true;
|
||||||
return const Material(
|
};
|
||||||
color: SacredColors.background,
|
ErrorWidget.builder = (details) {
|
||||||
child: Center(
|
return const Material(
|
||||||
child: Padding(
|
color: SacredColors.background,
|
||||||
padding: EdgeInsets.all(32),
|
child: Center(
|
||||||
child: Text(
|
child: Padding(
|
||||||
'Terjadi gangguan tampilan.\nAplikasi tetap berjalan dalam mode aman.',
|
padding: EdgeInsets.all(32),
|
||||||
textAlign: TextAlign.center,
|
child: Text(
|
||||||
style: TextStyle(
|
'Terjadi gangguan tampilan.\nAplikasi tetap berjalan dalam mode aman.',
|
||||||
color: SacredColors.onSurface,
|
textAlign: TextAlign.center,
|
||||||
fontSize: 24,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
color: SacredColors.onSurface,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
};
|
||||||
};
|
|
||||||
|
|
||||||
await runZonedGuarded(() async {
|
|
||||||
await _bootstrapAndRun();
|
await _bootstrapAndRun();
|
||||||
}, (error, stack) {
|
}, (error, stack) {
|
||||||
debugPrint('[Fatal][Zone] $error');
|
debugPrint('[Fatal][Zone] $error');
|
||||||
|
|||||||
26
pubspec.lock
26
pubspec.lock
@@ -122,7 +122,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+2"
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
@@ -381,14 +381,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.3.0"
|
||||||
package_info_plus:
|
open_filex:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: open_filex
|
||||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -414,7 +422,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
@@ -630,10 +638,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: wakelock_plus
|
name: wakelock_plus
|
||||||
sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39"
|
sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.1"
|
version: "1.3.3"
|
||||||
wakelock_plus_platform_interface:
|
wakelock_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ dependencies:
|
|||||||
|
|
||||||
# HTTP
|
# HTTP
|
||||||
http: ^1.2.0
|
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
|
# Date/Time formatting
|
||||||
intl: ^0.20.0
|
intl: ^0.20.0
|
||||||
|
|||||||
77
scripts/build_release_apk.sh
Executable file
77
scripts/build_release_apk.sh
Executable 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."
|
||||||
@@ -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/local/models.dart';
|
||||||
import 'package:jamshalat_masjid_screen/data/services/hijri_service.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/sync_service.dart';
|
||||||
|
import 'package:jamshalat_masjid_screen/data/services/update_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('PrayerName display labels', () {
|
group('PrayerName display labels', () {
|
||||||
@@ -117,4 +118,24 @@ void main() {
|
|||||||
expect(staleKeys, isNot(contains('2026-04-30')));
|
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user