fix(tv-picker): native Android TV image picker for branded/slideshow + bump 1.0.10+11

This commit is contained in:
dwindown
2026-04-05 16:01:46 +07:00
parent 98b8437e87
commit ba407b1848
5 changed files with 404 additions and 35 deletions

View File

@@ -0,0 +1,82 @@
import 'dart:io';
import 'package:flutter/services.dart';
class TvPickerHandler {
final String packageName;
final String label;
const TvPickerHandler({
required this.packageName,
required this.label,
});
}
class TvMediaPickerUnavailable implements Exception {
final String message;
final List<TvPickerHandler> handlers;
const TvMediaPickerUnavailable({
required this.message,
required this.handlers,
});
}
class TvMediaPickerService {
TvMediaPickerService._();
static final TvMediaPickerService instance = TvMediaPickerService._();
static const MethodChannel _channel =
MethodChannel('jamshalat/tv_media_picker');
Future<List<String>> pickImages({
required bool allowMultiple,
}) async {
if (!Platform.isAndroid) return const [];
try {
final raw = await _channel.invokeMethod<List<dynamic>>(
'pickImages',
{'allowMultiple': allowMultiple},
);
if (raw == null) return const [];
return raw
.map((item) => item?.toString() ?? '')
.where((path) => path.isNotEmpty)
.toList(growable: false);
} on PlatformException catch (error) {
if (error.code == 'NO_PICKER') {
throw TvMediaPickerUnavailable(
message: error.message ??
'Tidak ada aplikasi pemilih file yang kompatibel di perangkat.',
handlers: _parseHandlers(error.details),
);
}
rethrow;
}
}
Future<List<TvPickerHandler>> listPickers() async {
if (!Platform.isAndroid) return const [];
final raw = await _channel.invokeMethod<List<dynamic>>('listPickers');
return _parseHandlers(raw);
}
List<TvPickerHandler> _parseHandlers(dynamic raw) {
if (raw is! List) return const [];
final handlers = <TvPickerHandler>[];
for (final item in raw) {
if (item is! Map) continue;
final packageName = item['packageName']?.toString() ?? '';
final label = item['label']?.toString() ?? '';
if (packageName.isEmpty) continue;
handlers.add(
TvPickerHandler(
packageName: packageName,
label: label.isEmpty ? packageName : label,
),
);
}
return handlers;
}
}

View File

@@ -1,16 +1,19 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hugeicons/hugeicons.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
import '../../data/services/sync_service.dart';
import '../../data/services/myquran_service.dart';
import '../../data/services/sound_service.dart';
import '../../data/services/tv_media_picker_service.dart';
import '../../data/services/update_service.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';
@@ -344,20 +347,31 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
);
}
Future<void> _pickBrandedImage() async {
try {
final pickedPaths = await _pickImagePaths(allowMultiple: false);
if (pickedPaths.isEmpty) return;
setState(() => _brandedBgImage = pickedPaths.first);
_queueTampilanAutoSave(
message: 'Foto latar otomatis tersimpan',
);
} catch (e) {
if (!mounted) return;
_showStatusBadge(
'Gagal membuka pemilih file. Pastikan file manager tersedia di perangkat.',
isError: true,
);
}
}
Future<void> _pickSlideshowImages() async {
try {
final res = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
);
if (res == null) return;
final pickedPaths = await _pickImagePaths(allowMultiple: true);
if (pickedPaths.isEmpty) return;
var hasNewImage = false;
setState(() {
for (final path in res.paths) {
if (path != null &&
File(path).existsSync() &&
!_slideshowImages.contains(path)) {
for (final path in pickedPaths) {
if (File(path).existsSync() && !_slideshowImages.contains(path)) {
_slideshowImages.add(path);
hasNewImage = true;
}
@@ -378,6 +392,88 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
}
}
Future<List<String>> _pickImagePaths({
required bool allowMultiple,
}) async {
if (Platform.isAndroid) {
try {
return await TvMediaPickerService.instance.pickImages(
allowMultiple: allowMultiple,
);
} on TvMediaPickerUnavailable catch (error) {
if (!mounted) return const [];
final detected = error.handlers.map((handler) => handler.label).join(', ');
final supported = [
'File Commander',
'X-plore',
'Cx File Explorer',
'Files by Google',
].join(', ');
final message = detected.isNotEmpty
? 'Pemilih TV tidak tersedia. Aplikasi terdeteksi: $detected. Gunakan salah satu yang mendukung pemilih dokumen: $supported.'
: 'Tidak ada pemilih file Android TV yang kompatibel. Instal salah satu: $supported.';
_showStatusBadge(message, isError: true);
return const [];
} on MissingPluginException {
// Fallback below if native Android channel is not available.
}
}
final picked = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: allowMultiple,
withData: true,
withReadStream: true,
);
if (picked == null) return const [];
final resolvedPaths = <String>[];
for (final file in picked.files) {
if (file.path != null && File(file.path!).existsSync()) {
resolvedPaths.add(file.path!);
continue;
}
final persisted = await _persistPickedImageFile(file);
if (persisted != null) {
resolvedPaths.add(persisted);
}
}
return resolvedPaths;
}
Future<String?> _persistPickedImageFile(PlatformFile file) async {
final hasBytes = file.bytes != null;
final stream = file.readStream;
if (!hasBytes && stream == null) return null;
final supportDir = await getApplicationSupportDirectory();
final mediaDir = Directory('${supportDir.path}/picked_images');
await mediaDir.create(recursive: true);
final ext = _extractImageExtension(file.name);
final target = File(
'${mediaDir.path}/img_${DateTime.now().millisecondsSinceEpoch}_${1000 + Random().nextInt(9000)}.$ext',
);
if (file.bytes != null) {
await target.writeAsBytes(file.bytes!, flush: true);
return target.path;
}
final sink = target.openWrite();
await stream!.pipe(sink);
await sink.close();
return target.path;
}
String _extractImageExtension(String name) {
final dot = name.lastIndexOf('.');
if (dot > 0 && dot < name.length - 1) {
return name.substring(dot + 1).toLowerCase();
}
return 'jpg';
}
Future<void> _savePengumuman({
String message = 'Pengaturan pengumuman otomatis tersimpan',
}) async {
@@ -1986,28 +2082,9 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
_buildTampilanActionButton(
rowIndex: pickBrandedBgRow,
s: s,
onActivate: () async {
final res = await FilePicker.platform.pickFiles(type: FileType.image);
final selectedPath = res?.files.single.path;
if (selectedPath != null && File(selectedPath).existsSync()) {
setState(() => _brandedBgImage = selectedPath);
_queueTampilanAutoSave(
message: 'Foto latar otomatis tersimpan',
);
}
},
onActivate: _pickBrandedImage,
child: ElevatedButton.icon(
onPressed: () async {
final res = await FilePicker.platform.pickFiles(type: FileType.image);
final selectedPath = res?.files.single.path;
if (selectedPath != null &&
File(selectedPath).existsSync()) {
setState(() => _brandedBgImage = selectedPath);
_queueTampilanAutoSave(
message: 'Foto latar otomatis tersimpan',
);
}
},
onPressed: _pickBrandedImage,
icon: HugeIcon(icon: HugeIcons.strokeRoundedImage01, color: SacredColors.onPrimary, size: 20 * s),
label: Text('PILIH FOTO MASJID', style: TextStyle(fontSize: 16 * s)),
style: _tvElevatedActionStyle(

View File

@@ -48,10 +48,8 @@ class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
Future<void> runFetch() async {
if (!ref.read(settingsProvider).useUnsplashBackground) return;
final requestId = ++_fetchNonce;
final randomPage = 1 + _rng.nextInt(10);
final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(
keyword,
page: randomPage,
);
if (!mounted || requestId != _fetchNonce) return;
if (urls.isNotEmpty) {