feat: update checker — controllo aggiornamenti all'avvio
Merge branch 'update-checker' All'avvio l'app confronta silenziosamente la versione installata con l'ultima release GitHub. Se disponibile una versione più recente, mostra un dialog con pulsante "Scarica" che apre releases/latest nel browser. Testato su device fisico Android. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,9 @@ dist/
|
||||
/build/
|
||||
*.g.dart
|
||||
|
||||
# Generato da flutter create — non appartiene al progetto
|
||||
test/widget_test.dart
|
||||
|
||||
# Piattaforme non usate
|
||||
/ios/
|
||||
/linux/
|
||||
|
||||
@@ -84,24 +84,27 @@ lib/
|
||||
|
||||
## Testing
|
||||
|
||||
110 test (unit + widget). Non richiedono device fisico.
|
||||
130 test (unit + widget). Non richiedono device fisico.
|
||||
|
||||
```
|
||||
test/
|
||||
├── helpers/pump_app.dart # estensione pumpApp per widget test
|
||||
└── features/
|
||||
├── converter/
|
||||
│ ├── models/conversion_entry_test.dart
|
||||
│ └── providers/converter_provider_test.dart
|
||||
├── meal_planner/
|
||||
│ ├── models/meal_plan_test.dart
|
||||
│ ├── providers/meal_planner_provider_test.dart
|
||||
│ ├── widgets/meal_card_test.dart
|
||||
│ └── qr_test.dart
|
||||
└── shopping_list/
|
||||
├── models/shopping_item_test.dart
|
||||
├── providers/shopping_list_provider_test.dart
|
||||
└── widgets/shopping_item_tile_test.dart
|
||||
├── features/
|
||||
│ ├── converter/
|
||||
│ │ ├── models/conversion_entry_test.dart
|
||||
│ │ └── providers/converter_provider_test.dart
|
||||
│ ├── meal_planner/
|
||||
│ │ ├── models/meal_plan_test.dart
|
||||
│ │ ├── providers/meal_planner_provider_test.dart
|
||||
│ │ ├── widgets/meal_card_test.dart
|
||||
│ │ └── qr_test.dart
|
||||
│ └── shopping_list/
|
||||
│ ├── models/shopping_item_test.dart
|
||||
│ ├── providers/shopping_list_provider_test.dart
|
||||
│ └── widgets/shopping_item_tile_test.dart
|
||||
└── shared/
|
||||
├── services/update_service_test.dart
|
||||
└── widgets/update_dialog_test.dart
|
||||
```
|
||||
|
||||
I test usano `SharedPreferences.setMockInitialValues({})` per isolare lo storage.
|
||||
|
||||
@@ -24,6 +24,10 @@ App Android per la gestione della dieta quotidiana — pianificazione pasti, con
|
||||
- Separazione visiva tra elementi da completare e completati
|
||||
- Rimozione singola e svuota lista con conferma
|
||||
|
||||
### Controllo aggiornamenti
|
||||
- All'avvio controlla silenziosamente l'ultima release su GitHub
|
||||
- Se disponibile una versione più recente, mostra un dialog con link diretto al download dell'APK
|
||||
|
||||
## Stack
|
||||
|
||||
| Livello | Tecnologia |
|
||||
@@ -74,19 +78,24 @@ docker run --rm -v "$(pwd):/workspace" -w /workspace biteplan-build \
|
||||
test/
|
||||
├── helpers/
|
||||
│ └── pump_app.dart # estensione pumpApp per widget test
|
||||
└── features/
|
||||
├── converter/
|
||||
│ ├── models/conversion_entry_test.dart
|
||||
│ └── providers/converter_provider_test.dart
|
||||
├── meal_planner/
|
||||
│ ├── models/meal_plan_test.dart
|
||||
│ ├── providers/meal_planner_provider_test.dart
|
||||
│ ├── widgets/meal_card_test.dart
|
||||
│ └── qr_test.dart
|
||||
└── shopping_list/
|
||||
├── models/shopping_item_test.dart
|
||||
├── providers/shopping_list_provider_test.dart
|
||||
└── widgets/shopping_item_tile_test.dart
|
||||
├── features/
|
||||
│ ├── converter/
|
||||
│ │ ├── models/conversion_entry_test.dart
|
||||
│ │ └── providers/converter_provider_test.dart
|
||||
│ ├── meal_planner/
|
||||
│ │ ├── models/meal_plan_test.dart
|
||||
│ │ ├── providers/meal_planner_provider_test.dart
|
||||
│ │ ├── widgets/meal_card_test.dart
|
||||
│ │ └── qr_test.dart
|
||||
│ └── shopping_list/
|
||||
│ ├── models/shopping_item_test.dart
|
||||
│ ├── providers/shopping_list_provider_test.dart
|
||||
│ └── widgets/shopping_item_tile_test.dart
|
||||
└── shared/
|
||||
├── services/
|
||||
│ └── update_service_test.dart # parsing versione e confronto semver
|
||||
└── widgets/
|
||||
└── update_dialog_test.dart # widget test dialog aggiornamento
|
||||
```
|
||||
|
||||
## Build APK
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:label="BitePlan"
|
||||
android:name="${applicationName}"
|
||||
@@ -42,5 +43,9 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
package com.davide.biteplan
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val channel = "com.davide.biteplan/launcher"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "launch") {
|
||||
val url = call.argument<String>("url")
|
||||
if (url != null) {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error("INVALID_URL", "URL is null", null)
|
||||
}
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'features/meal_planner/presentation/pages/meal_planner_page.dart';
|
||||
import 'features/converter/presentation/pages/converter_page.dart';
|
||||
import 'features/shopping_list/presentation/pages/shopping_list_page.dart';
|
||||
import 'features/guide/presentation/widgets/info_bottom_sheet.dart';
|
||||
import 'shared/services/update_service.dart';
|
||||
import 'shared/widgets/update_dialog.dart';
|
||||
|
||||
class BitePlanApp extends StatelessWidget {
|
||||
const BitePlanApp({super.key});
|
||||
@@ -42,6 +44,11 @@ class _MainScaffoldState extends State<_MainScaffold> {
|
||||
const ConverterPage(),
|
||||
const ShoppingListPage(),
|
||||
];
|
||||
UpdateService.checkUpdate().then((newVersion) {
|
||||
if (newVersion != null && mounted) {
|
||||
showUpdateDialog(context, newVersion);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting;
|
||||
|
||||
import '../../core/constants/app_constants.dart';
|
||||
|
||||
class UpdateService {
|
||||
static const _apiUrl =
|
||||
'https://api.github.com/repos/davide3011/biteplan/releases/latest';
|
||||
|
||||
/// Returns the latest version string if it's newer than the current version,
|
||||
/// or null if already up to date or if the check fails.
|
||||
static Future<String?> checkUpdate() async {
|
||||
if (kIsWeb) return null;
|
||||
try {
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 5);
|
||||
final request = await client
|
||||
.getUrl(Uri.parse(_apiUrl))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
request.headers
|
||||
..set('Accept', 'application/vnd.github+json')
|
||||
..set('User-Agent', 'biteplan/$kAppVersion');
|
||||
final response =
|
||||
await request.close().timeout(const Duration(seconds: 5));
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
client.close();
|
||||
if (response.statusCode != 200) return null;
|
||||
final tag = parseTagName(body);
|
||||
if (tag == null) return null;
|
||||
return isNewer(tag, kAppVersion) ? tag : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static String? parseTagName(String body) {
|
||||
try {
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
return (data['tag_name'] as String).replaceFirst(RegExp(r'^v'), '');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static bool isNewer(String remote, String local) {
|
||||
final r = parseVersion(remote);
|
||||
final l = parseVersion(local);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (r[i] > l[i]) return true;
|
||||
if (r[i] < l[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static List<int> parseVersion(String v) {
|
||||
final parts = v.split('.');
|
||||
return List.generate(
|
||||
3, (i) => i < parts.length ? (int.tryParse(parts[i]) ?? 0) : 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class UrlLauncherService {
|
||||
static const _channel = MethodChannel('com.davide.biteplan/launcher');
|
||||
|
||||
static Future<void> launch(String url) async {
|
||||
await _channel.invokeMethod<void>('launch', {'url': url});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/url_launcher_service.dart';
|
||||
|
||||
const _releasesUrl = 'https://github.com/davide3011/biteplan/releases/latest';
|
||||
|
||||
void showUpdateDialog(BuildContext context, String newVersion) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
icon: const Icon(Icons.system_update_outlined, size: 32),
|
||||
title: const Text('Aggiornamento disponibile'),
|
||||
content: Text(
|
||||
'È disponibile la versione $newVersion di BitePlan.\n'
|
||||
'Vuoi scaricare il nuovo APK?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Più tardi'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
UrlLauncherService.launch(_releasesUrl);
|
||||
},
|
||||
child: const Text('Scarica'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/shared/services/update_service.dart';
|
||||
|
||||
void main() {
|
||||
group('UpdateService.parseTagName', () {
|
||||
test('strips v prefix from tag_name', () {
|
||||
expect(UpdateService.parseTagName('{"tag_name":"v2.1.0"}'), '2.1.0');
|
||||
});
|
||||
|
||||
test('works without v prefix', () {
|
||||
expect(UpdateService.parseTagName('{"tag_name":"2.1.0"}'), '2.1.0');
|
||||
});
|
||||
|
||||
test('returns null for malformed JSON', () {
|
||||
expect(UpdateService.parseTagName('not json'), isNull);
|
||||
});
|
||||
|
||||
test('returns null when tag_name is missing', () {
|
||||
expect(UpdateService.parseTagName('{"message":"Not Found"}'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for empty body', () {
|
||||
expect(UpdateService.parseTagName(''), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('UpdateService.parseVersion', () {
|
||||
test('parses standard semver', () {
|
||||
expect(UpdateService.parseVersion('2.1.3'), [2, 1, 3]);
|
||||
});
|
||||
|
||||
test('pads missing parts with zeros', () {
|
||||
expect(UpdateService.parseVersion('2.1'), [2, 1, 0]);
|
||||
expect(UpdateService.parseVersion('2'), [2, 0, 0]);
|
||||
});
|
||||
|
||||
test('handles non-numeric parts as zero', () {
|
||||
expect(UpdateService.parseVersion('2.x.3'), [2, 0, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
group('UpdateService.isNewer', () {
|
||||
test('patch bump is newer', () {
|
||||
expect(UpdateService.isNewer('2.0.1', '2.0.0'), isTrue);
|
||||
});
|
||||
|
||||
test('minor bump is newer', () {
|
||||
expect(UpdateService.isNewer('2.1.0', '2.0.9'), isTrue);
|
||||
});
|
||||
|
||||
test('major bump is newer', () {
|
||||
expect(UpdateService.isNewer('3.0.0', '2.9.9'), isTrue);
|
||||
});
|
||||
|
||||
test('same version is not newer', () {
|
||||
expect(UpdateService.isNewer('2.0.0', '2.0.0'), isFalse);
|
||||
});
|
||||
|
||||
test('older remote is not newer', () {
|
||||
expect(UpdateService.isNewer('1.9.9', '2.0.0'), isFalse);
|
||||
});
|
||||
|
||||
test('remote with v-prefix stripped is handled correctly', () {
|
||||
// tag_name comes in already stripped of 'v' by checkUpdate
|
||||
expect(UpdateService.isNewer('2.0.1', '2.0.0'), isTrue);
|
||||
});
|
||||
|
||||
test('multi-digit version components', () {
|
||||
expect(UpdateService.isNewer('2.0.10', '2.0.9'), isTrue);
|
||||
expect(UpdateService.isNewer('2.10.0', '2.9.0'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:biteplan/shared/widgets/update_dialog.dart';
|
||||
|
||||
import '../../helpers/pump_app.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.davide.biteplan/launcher'),
|
||||
(call) async => null,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.davide.biteplan/launcher'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> openDialog(WidgetTester tester, String version) async {
|
||||
await tester.pumpApp(
|
||||
Builder(
|
||||
builder: (ctx) => TextButton(
|
||||
onPressed: () => showUpdateDialog(ctx, version),
|
||||
child: const Text('apri'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('apri'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('mostra il titolo corretto', (tester) async {
|
||||
await openDialog(tester, '2.1.0');
|
||||
expect(find.text('Aggiornamento disponibile'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('mostra la versione nel contenuto', (tester) async {
|
||||
await openDialog(tester, '2.1.0');
|
||||
expect(find.textContaining('2.1.0'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ha i pulsanti Più tardi e Scarica', (tester) async {
|
||||
await openDialog(tester, '2.1.0');
|
||||
expect(find.text('Più tardi'), findsOneWidget);
|
||||
expect(find.text('Scarica'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Più tardi chiude il dialog', (tester) async {
|
||||
await openDialog(tester, '2.1.0');
|
||||
await tester.tap(find.text('Più tardi'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Aggiornamento disponibile'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Scarica chiude il dialog e chiama il launcher', (tester) async {
|
||||
final calls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.davide.biteplan/launcher'),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
await openDialog(tester, '2.1.0');
|
||||
await tester.tap(find.text('Scarica'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Aggiornamento disponibile'), findsNothing);
|
||||
expect(calls, hasLength(1));
|
||||
expect(calls.first.method, 'launch');
|
||||
expect(calls.first.arguments['url'],
|
||||
contains('github.com/davide3011/biteplan'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user