feat: in-app update downloader with progress, Gitea auto-push hook
- update_checker: Gitea at git.steggi-matrix.work, Windows + Android support, asset URL detection - update_download_dialog: animated Pyramid logo, progress bar, platform install (exe/apk) - update_banner: Herunterladen button triggers dialog, dismiss option - Android: REQUEST_INSTALL_PACKAGES permission + FileProvider for APK install - post-commit hook: auto-push to Gitea on every commit - chat_input: larger send button (60px desktop), more padding, bigger icon - rooms_panel: space menu visible on mobile (leave space fix) - docs: feature-checklist.md added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
const _kGiteaBase = 'https://git.steggi-matrix.work';
|
||||
const _kGiteaRepo = 'steggi/pyramid';
|
||||
const _kCheckIntervalHours = 4;
|
||||
const _kPrefLastCheck = 'update_last_check_ms';
|
||||
const _kPrefSkippedTag = 'update_skipped_tag';
|
||||
|
||||
// ─── Data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
class UpdateInfo {
|
||||
final String tagName;
|
||||
final String releaseName;
|
||||
final String body;
|
||||
final String htmlUrl;
|
||||
final String? downloadUrl;
|
||||
final (int, int, int) remoteVersion;
|
||||
|
||||
const UpdateInfo({
|
||||
required this.tagName,
|
||||
required this.releaseName,
|
||||
required this.body,
|
||||
required this.htmlUrl,
|
||||
required this.remoteVersion,
|
||||
this.downloadUrl,
|
||||
});
|
||||
|
||||
bool get canDownload => downloadUrl != null;
|
||||
}
|
||||
|
||||
// ─── Version helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
(int, int, int)? _parseVersion(String raw) {
|
||||
final clean = raw.trim().replaceFirst(RegExp(r'^v'), '').split('+').first;
|
||||
final parts = clean.split('.');
|
||||
if (parts.length < 3) return null;
|
||||
try {
|
||||
return (int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2]));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isNewer((int, int, int) remote, (int, int, int) local) {
|
||||
if (remote.$1 != local.$1) return remote.$1 > local.$1;
|
||||
if (remote.$2 != local.$2) return remote.$2 > local.$2;
|
||||
return remote.$3 > local.$3;
|
||||
}
|
||||
|
||||
String? _findDownloadUrl(List<dynamic> assets) {
|
||||
if (assets.isEmpty) return null;
|
||||
final list = assets.cast<Map<String, dynamic>>();
|
||||
|
||||
String? pick(bool Function(String name) test) {
|
||||
for (final a in list) {
|
||||
final name = (a['name'] as String? ?? '').toLowerCase();
|
||||
if (test(name)) return a['browser_download_url'] as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
return pick((n) => n.endsWith('.exe')) ?? pick((n) => n.endsWith('.msix'));
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return pick((n) => n.endsWith('.apk'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Fetch ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<UpdateInfo?> _fetchLatestRelease() async {
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
'$_kGiteaBase/api/v1/repos/$_kGiteaRepo/releases?limit=1',
|
||||
);
|
||||
final response = await http
|
||||
.get(uri, headers: {'Accept': 'application/json'})
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final list = json.decode(response.body) as List<dynamic>;
|
||||
if (list.isEmpty) return null;
|
||||
final data = list.first as Map<String, dynamic>;
|
||||
|
||||
final tagName = data['tag_name'] as String? ?? '';
|
||||
if (tagName.isEmpty) return null;
|
||||
|
||||
final remoteVer = _parseVersion(tagName);
|
||||
if (remoteVer == null) return null;
|
||||
|
||||
final assets = (data['assets'] as List<dynamic>?) ?? [];
|
||||
final downloadUrl = _findDownloadUrl(assets);
|
||||
|
||||
return UpdateInfo(
|
||||
tagName: tagName,
|
||||
releaseName: data['name'] as String? ?? tagName,
|
||||
body: data['body'] as String? ?? '',
|
||||
htmlUrl: '$_kGiteaBase/$_kGiteaRepo/releases/tag/$tagName',
|
||||
remoteVersion: remoteVer,
|
||||
downloadUrl: downloadUrl,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
|
||||
// iOS: can't sideload, skip
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) return null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastCheckMs = prefs.getInt(_kPrefLastCheck) ?? 0;
|
||||
final elapsed = DateTime.now().millisecondsSinceEpoch - lastCheckMs;
|
||||
if (elapsed < _kCheckIntervalHours * 3600 * 1000) return null;
|
||||
|
||||
await prefs.setInt(_kPrefLastCheck, DateTime.now().millisecondsSinceEpoch);
|
||||
|
||||
final remote = await _fetchLatestRelease();
|
||||
if (remote == null) return null;
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVer = _parseVersion(info.version);
|
||||
if (localVer == null) return null;
|
||||
|
||||
if (!_isNewer(remote.remoteVersion, localVer)) return null;
|
||||
|
||||
final skipped = prefs.getString(_kPrefSkippedTag);
|
||||
if (skipped == remote.tagName) return null;
|
||||
|
||||
return remote;
|
||||
});
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> skipUpdateVersion(String tagName) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kPrefSkippedTag, tagName);
|
||||
}
|
||||
|
||||
Future<void> resetUpdateCheckTimer() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kPrefLastCheck);
|
||||
}
|
||||
Reference in New Issue
Block a user