feat(tv-admin): fix action/focus flows, update app title, randomize unsplash, bump 1.0.9+10

This commit is contained in:
dwindown
2026-04-05 14:59:55 +07:00
parent c70a6baf7b
commit 98b8437e87
13 changed files with 144 additions and 131 deletions

View File

@@ -2,7 +2,7 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <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}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

View File

@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Jamshalat Masjid Screen</string> <string>JamShalat - Masjid Screen</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>jamshalat_masjid_screen</string> <string>JamShalat - Masjid Screen</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@@ -10,12 +10,22 @@ class UnsplashService {
UnsplashService._(); UnsplashService._();
/// Fetches a list of highly compressed landscape URLs based on the given keyword. /// Fetches a list of highly compressed landscape URLs based on the given keyword.
Future<List<String>> fetchLandscapeBackgrounds(String keyword) async { Future<List<String>> fetchLandscapeBackgrounds(
String keyword, {
int page = 1,
}) async {
// Trim keyword and default to 'mosque' if empty // Trim keyword and default to 'mosque' if empty
final query = keyword.trim().isEmpty ? 'mosque' : keyword.trim(); final query = keyword.trim().isEmpty ? 'mosque' : keyword.trim();
// Specifically requesting 'regular' size to fit 1080p elegantly while minimizing RAM overhead. // Specifically requesting 'regular' size to fit 1080p elegantly while minimizing RAM overhead.
final url = Uri.parse('$_baseUrl/search/photos?query=$query&orientation=landscape&per_page=20'); final url = Uri.parse('$_baseUrl/search/photos').replace(
queryParameters: {
'query': query,
'orientation': 'landscape',
'per_page': '20',
'page': page.clamp(1, 1000).toString(),
},
);
try { try {
final response = await http.get( final response = await http.get(

View File

@@ -344,6 +344,40 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
); );
} }
Future<void> _pickSlideshowImages() async {
try {
final res = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
);
if (res == null) return;
var hasNewImage = false;
setState(() {
for (final path in res.paths) {
if (path != null &&
File(path).existsSync() &&
!_slideshowImages.contains(path)) {
_slideshowImages.add(path);
hasNewImage = true;
}
}
});
if (hasNewImage) {
_queueTampilanAutoSave(
message: 'Galeri slideshow otomatis tersimpan',
);
}
} catch (e) {
if (!mounted) return;
_showStatusBadge(
'Gagal membuka pemilih file. Pastikan file manager tersedia di perangkat.',
isError: true,
);
}
}
Future<void> _savePengumuman({ Future<void> _savePengumuman({
String message = 'Pengaturan pengumuman otomatis tersimpan', String message = 'Pengaturan pengumuman otomatis tersimpan',
}) async { }) async {
@@ -1270,7 +1304,6 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
count += 1; count += 1;
count += 1; count += 1;
count += _slideshowImages.length; count += _slideshowImages.length;
count += 1;
return count; return count;
} }
@@ -1375,9 +1408,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_focusNavTab(index + 1); _focusNavTab(index + 1);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (key == LogicalKeyboardKey.arrowRight || if (key == LogicalKeyboardKey.arrowRight || _isActivateKey(key)) {
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.enter) {
_focusEntryForTab(index); _focusEntryForTab(index);
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1410,7 +1441,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { if (_isActivateKey(key)) {
onActivate(); onActivate();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1453,7 +1484,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { if (_isActivateKey(key)) {
onActivate(); onActivate();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1485,7 +1516,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { if (_isActivateKey(key)) {
onActivate(); onActivate();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1517,7 +1548,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { if (_isActivateKey(key)) {
onActivate(); onActivate();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1548,7 +1579,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { if (_isActivateKey(key)) {
onActivate(); onActivate();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -1579,13 +1610,21 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) { if (_isActivateKey(key)) {
onActivate(); onActivate();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
bool _isActivateKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.numpadEnter ||
key == LogicalKeyboardKey.space ||
key == LogicalKeyboardKey.gameButtonA;
}
Widget _buildJumatTab(double s) { Widget _buildJumatTab(double s) {
return FocusTraversalGroup( return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(), policy: WidgetOrderTraversalPolicy(),
@@ -1743,7 +1782,6 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_slideshowImages.length, _slideshowImages.length,
(_) => row++, (_) => row++,
); );
final openPengumumanRow = row++;
return FocusTraversalGroup( return FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(), policy: WidgetOrderTraversalPolicy(),
@@ -2050,41 +2088,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_buildTampilanActionButton( _buildTampilanActionButton(
rowIndex: addSlideshowImageRow, rowIndex: addSlideshowImageRow,
s: s, s: s,
onActivate: () async { onActivate: _pickSlideshowImages,
final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
if (res != null) {
setState(() {
for (var path in res.paths) {
if (path != null &&
File(path).existsSync() &&
!_slideshowImages.contains(path)) {
_slideshowImages.add(path);
}
}
});
_queueTampilanAutoSave(
message: 'Galeri slideshow otomatis tersimpan',
);
}
},
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () async { onPressed: _pickSlideshowImages,
final res = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true);
if (res != null) {
setState(() {
for (var path in res.paths) {
if (path != null &&
File(path).existsSync() &&
!_slideshowImages.contains(path)) {
_slideshowImages.add(path);
}
}
});
_queueTampilanAutoSave(
message: 'Galeri slideshow otomatis tersimpan',
);
}
},
icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.onPrimary, size: 18 * s), icon: HugeIcon(icon: HugeIcons.strokeRoundedPlusSign, color: SacredColors.onPrimary, size: 18 * s),
label: Text('TAMBAH FOTO', style: TextStyle(fontSize: 14 * s)), label: Text('TAMBAH FOTO', style: TextStyle(fontSize: 14 * s)),
style: _tvElevatedActionStyle( style: _tvElevatedActionStyle(
@@ -2180,58 +2186,6 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
], ],
), ),
), ),
SizedBox(height: 24 * s),
_adminCard(
s,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel('Pengumuman Dipisah ke Tab Sendiri', s),
SizedBox(height: 8 * s),
Text(
'Text slide tengah dan running text bawah sekarang dipindahkan ke tab Pengumuman agar halaman Tampilan & Media lebih ringkas.',
style: GoogleFonts.manrope(
fontSize: 14 * s,
color: SacredColors.onSurfaceVariant,
),
),
SizedBox(height: 16 * s),
_buildTampilanActionButton(
rowIndex: openPengumumanRow,
s: s,
onActivate: () {
_setSelectedTab(3);
_focusEntryForTab(3);
},
child: ElevatedButton.icon(
onPressed: () {
_setSelectedTab(3);
_focusEntryForTab(3);
},
icon: HugeIcon(
icon: HugeIcons.strokeRoundedNotification03,
color: SacredColors.onPrimary,
size: 18 * s,
),
label: Text(
'BUKA TAB PENGUMUMAN',
style: TextStyle(fontSize: 14 * s),
),
style: _tvElevatedActionStyle(
s: s,
normalBackground: SacredColors.secondary,
normalForeground: SacredColors.onPrimary,
padding: EdgeInsets.symmetric(
horizontal: 20 * s,
vertical: 14 * s,
),
fontSize: 14 * s,
),
),
),
],
),
),
SizedBox(height: 40 * s), SizedBox(height: 40 * s),
], ],
), ),

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -14,11 +15,15 @@ class UnsplashBackground extends ConsumerStatefulWidget {
} }
class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> { class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
final Random _rng = Random();
List<String> _urls = []; List<String> _urls = [];
int _currentIndex = 0; int _currentIndex = 0;
Timer? _rotationTimer; Timer? _rotationTimer;
Timer? _keywordDebounceTimer;
int _fetchNonce = 0;
String? _lastKeyword; String? _lastKeyword;
int? _lastRotationHours; int? _lastRotationHours;
bool? _lastUseUnsplash;
@override @override
void initState() { void initState() {
@@ -30,18 +35,55 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
_lastKeyword = settings.unsplashKeyword; _lastKeyword = settings.unsplashKeyword;
_lastRotationHours = settings.unsplashRotationHours; _lastRotationHours = settings.unsplashRotationHours;
_lastUseUnsplash = settings.useUnsplashBackground;
await _fetchImages(settings.unsplashKeyword); if (settings.useUnsplashBackground) {
await _fetchImages(settings.unsplashKeyword, immediate: true);
}
_startTimer(settings.unsplashRotationHours); _startTimer(settings.unsplashRotationHours);
} }
Future<void> _fetchImages(String keyword) async { Future<void> _fetchImages(String keyword, {bool immediate = false}) async {
final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(keyword); _keywordDebounceTimer?.cancel();
if (urls.isNotEmpty && mounted) { Future<void> runFetch() async {
setState(() { if (!ref.read(settingsProvider).useUnsplashBackground) return;
_urls = urls; final requestId = ++_fetchNonce;
_currentIndex = 0; final randomPage = 1 + _rng.nextInt(10);
}); final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(
keyword,
page: randomPage,
);
if (!mounted || requestId != _fetchNonce) return;
if (urls.isNotEmpty) {
urls.shuffle(_rng);
setState(() {
_urls = urls;
_currentIndex = _rng.nextInt(_urls.length);
});
}
}
if (immediate) {
await runFetch();
return;
}
_keywordDebounceTimer = Timer(
const Duration(milliseconds: 1200),
() {
unawaited(runFetch());
},
);
}
void _nextRandomImage() {
if (_urls.isNotEmpty && mounted) {
if (_urls.length <= 1) return;
var nextIndex = _currentIndex;
while (nextIndex == _currentIndex) {
nextIndex = _rng.nextInt(_urls.length);
}
setState(() => _currentIndex = nextIndex);
} }
} }
@@ -50,17 +92,14 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
if (hours <= 0) return; if (hours <= 0) return;
_rotationTimer = Timer.periodic(Duration(hours: hours), (_) { _rotationTimer = Timer.periodic(Duration(hours: hours), (_) {
if (_urls.isNotEmpty && mounted) { _nextRandomImage();
setState(() {
_currentIndex = (_currentIndex + 1) % _urls.length;
});
}
}); });
} }
@override @override
void dispose() { void dispose() {
_rotationTimer?.cancel(); _rotationTimer?.cancel();
_keywordDebounceTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -69,10 +108,18 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
// Watch for config changes // Watch for config changes
if (settings.useUnsplashBackground != _lastUseUnsplash) {
_lastUseUnsplash = settings.useUnsplashBackground;
if (settings.useUnsplashBackground && _urls.isEmpty) {
_fetchImages(settings.unsplashKeyword, immediate: true);
}
}
if (settings.unsplashKeyword != _lastKeyword) { if (settings.unsplashKeyword != _lastKeyword) {
_lastKeyword = settings.unsplashKeyword; _lastKeyword = settings.unsplashKeyword;
// Re-fetch images organically if (settings.useUnsplashBackground) {
_fetchImages(settings.unsplashKeyword); _fetchImages(settings.unsplashKeyword);
}
} }
if (settings.unsplashRotationHours != _lastRotationHours) { if (settings.unsplashRotationHours != _lastRotationHours) {

View File

@@ -125,7 +125,7 @@ class JamShalatApp extends ConsumerWidget {
// textScaleProvider will be used selectively in child components. // textScaleProvider will be used selectively in child components.
return MaterialApp( return MaterialApp(
title: 'Jam Shalat Digital', title: 'JamShalat - Masjid Screen',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,

View File

@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "jamshalat_masjid_screen"); gtk_header_bar_set_title(header_bar, "JamShalat - Masjid Screen");
gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "jamshalat_masjid_screen"); gtk_window_set_title(window, "JamShalat - Masjid Screen");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);

View File

@@ -12,8 +12,10 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleDisplayName</key>
<string>JamShalat - Masjid Screen</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string> <string>JamShalat - Masjid Screen</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@@ -1,7 +1,7 @@
name: jamshalat_masjid_screen name: jamshalat_masjid_screen
description: Smart Digital Prayer Clock for Android TV Box description: Smart Digital Prayer Clock for Android TV Box
publish_to: 'none' publish_to: 'none'
version: 1.0.8+9 version: 1.0.9+10
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'

View File

@@ -23,13 +23,13 @@
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="jamshalat_masjid_screen"> <meta name="apple-mobile-web-app-title" content="JamShalat - Masjid Screen">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>jamshalat_masjid_screen</title> <title>JamShalat - Masjid Screen</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>

View File

@@ -1,6 +1,6 @@
{ {
"name": "jamshalat_masjid_screen", "name": "JamShalat - Masjid Screen",
"short_name": "jamshalat_masjid_screen", "short_name": "JamShalat",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0175C2",

View File

@@ -90,12 +90,12 @@ BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "com.jamshalat" "\0" VALUE "CompanyName", "com.jamshalat" "\0"
VALUE "FileDescription", "jamshalat_masjid_screen" "\0" VALUE "FileDescription", "JamShalat - Masjid Screen" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "jamshalat_masjid_screen" "\0" VALUE "InternalName", "jamshalat_masjid_screen" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.jamshalat. All rights reserved." "\0" VALUE "LegalCopyright", "Copyright (C) 2026 com.jamshalat. All rights reserved." "\0"
VALUE "OriginalFilename", "jamshalat_masjid_screen.exe" "\0" VALUE "OriginalFilename", "jamshalat_masjid_screen.exe" "\0"
VALUE "ProductName", "jamshalat_masjid_screen" "\0" VALUE "ProductName", "JamShalat - Masjid Screen" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"
END END
END END

View File

@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720); Win32Window::Size size(1280, 720);
if (!window.Create(L"jamshalat_masjid_screen", origin, size)) { if (!window.Create(L"JamShalat - Masjid Screen", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);