a337c3fda3
- Check OpenResult.type before resetting state — previously a failed open_file call (e.g. installer blocked on emulator) silently called state = const UpdateState(), nulling latestVersion and downloadUrl, causing "Version null" and a non-functional Download button - Show errorMessage in dialog with error colour so failures are visible - Guard Download button on downloadUrl != null (no button if URL lost) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
5.0 KiB
Dart
163 lines
5.0 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:open_file/open_file.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
|
|
|
class UpdateState {
|
|
final UpdateStatus status;
|
|
final String? currentVersion;
|
|
final String? latestVersion;
|
|
final String? downloadUrl;
|
|
final double downloadProgress;
|
|
final String? errorMessage;
|
|
|
|
const UpdateState({
|
|
this.status = UpdateStatus.idle,
|
|
this.currentVersion,
|
|
this.latestVersion,
|
|
this.downloadUrl,
|
|
this.downloadProgress = 0.0,
|
|
this.errorMessage,
|
|
});
|
|
|
|
UpdateState copyWith({
|
|
UpdateStatus? status,
|
|
String? currentVersion,
|
|
String? latestVersion,
|
|
String? downloadUrl,
|
|
double? downloadProgress,
|
|
String? errorMessage,
|
|
}) =>
|
|
UpdateState(
|
|
status: status ?? this.status,
|
|
currentVersion: currentVersion ?? this.currentVersion,
|
|
latestVersion: latestVersion ?? this.latestVersion,
|
|
downloadUrl: downloadUrl ?? this.downloadUrl,
|
|
downloadProgress: downloadProgress ?? this.downloadProgress,
|
|
errorMessage: errorMessage ?? this.errorMessage,
|
|
);
|
|
}
|
|
|
|
class UpdateNotifier extends Notifier<UpdateState> {
|
|
@override
|
|
UpdateState build() => const UpdateState();
|
|
|
|
/// [repoUrl] is the Forgejo repo page URL, e.g.
|
|
/// "https://git.example.com/user/fabled_app"
|
|
Future<void> check(String repoUrl) async {
|
|
state = state.copyWith(status: UpdateStatus.checking);
|
|
try {
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
|
|
final currentVersion =
|
|
'${packageInfo.version}.${packageInfo.buildNumber}';
|
|
|
|
// Parse repo URL → Forgejo API endpoint
|
|
final uri = Uri.parse(repoUrl);
|
|
final parts =
|
|
uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
|
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
|
|
final apiUrl =
|
|
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest';
|
|
|
|
final response = await Dio().get(apiUrl);
|
|
final tagName = (response.data['tag_name'] as String? ?? '').trim();
|
|
final latestVersion =
|
|
tagName.startsWith('v') ? tagName.substring(1) : tagName;
|
|
|
|
if (_isNewer(latestVersion, currentVersion)) {
|
|
final assets =
|
|
(response.data['assets'] as List<dynamic>? ?? [])
|
|
.cast<Map<String, dynamic>>();
|
|
final apk = assets.firstWhere(
|
|
(a) => (a['name'] as String? ?? '').endsWith('.apk'),
|
|
orElse: () => {},
|
|
);
|
|
if (apk.isNotEmpty) {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.available,
|
|
currentVersion: currentVersion,
|
|
latestVersion: latestVersion,
|
|
downloadUrl: apk['browser_download_url'] as String?,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
state = state.copyWith(
|
|
status: UpdateStatus.upToDate,
|
|
currentVersion: currentVersion,
|
|
latestVersion: latestVersion,
|
|
);
|
|
} catch (e) {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.error,
|
|
errorMessage: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> downloadAndInstall() async {
|
|
if (state.downloadUrl == null) return;
|
|
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
|
|
|
try {
|
|
final dir = await getExternalStorageDirectory() ??
|
|
await getTemporaryDirectory();
|
|
final path = '${dir.path}/fabled_update.apk';
|
|
|
|
await Dio().download(
|
|
state.downloadUrl!,
|
|
path,
|
|
onReceiveProgress: (received, total) {
|
|
if (total > 0) {
|
|
state = state.copyWith(downloadProgress: received / total);
|
|
}
|
|
},
|
|
);
|
|
|
|
final result = await OpenFile.open(
|
|
path,
|
|
type: 'application/vnd.android.package-archive',
|
|
);
|
|
|
|
if (result.type == ResultType.done) {
|
|
// Installer launched — reset to idle so the dialog closes naturally.
|
|
state = const UpdateState();
|
|
} else {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.error,
|
|
errorMessage: 'Could not open installer: ${result.message}',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.error,
|
|
errorMessage: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
void dismiss() => state = const UpdateState();
|
|
|
|
bool _isNewer(String latest, String current) {
|
|
try {
|
|
final l = latest.split('.').map(int.parse).toList();
|
|
final c = current.split('.').map(int.parse).toList();
|
|
for (var i = 0; i < l.length && i < c.length; i++) {
|
|
if (l[i] > c[i]) return true;
|
|
if (l[i] < c[i]) return false;
|
|
}
|
|
return l.length > c.length;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
final updateProvider =
|
|
NotifierProvider<UpdateNotifier, UpdateState>(UpdateNotifier.new);
|