Overhaul ui and remove BackButtonListener #9
@ -48,6 +48,10 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
versionNameSuffix '-DEBUG'
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.release
|
||||
}
|
||||
|
59
integration_test/app_test.dart
Normal file
59
integration_test/app_test.dart
Normal file
@ -0,0 +1,59 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
/* Copyright (C) 2024 Marco Groß <mgross@sw-gross.de> */
|
||||
|
||||
import 'package:calorimeter/main.dart';
|
||||
import 'package:calorimeter/storage/storage.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() {});
|
||||
|
||||
group('end-to-end test', () {
|
||||
testWidgets('add food manually', (tester) async {
|
||||
var foodStorage = await FoodStorage.create();
|
||||
|
||||
await tester.pumpWidget(MainApp(storage: foodStorage));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final addButtonFinder = find.byIcon(Icons.add);
|
||||
expect(addButtonFinder, findsOneWidget);
|
||||
|
||||
await tester.tap(addButtonFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final nameAutocompleteFinder =
|
||||
find.widgetWithText(Autocomplete<String>, "Name");
|
||||
final amountFinder = find.widgetWithText(TextField, "Amount");
|
||||
final kcalFinder = find.widgetWithText(TextField, "kcal");
|
||||
final addButton = find.widgetWithIcon(ElevatedButton, Icons.check);
|
||||
|
||||
expect(nameAutocompleteFinder, findsOneWidget);
|
||||
expect(amountFinder, findsOneWidget);
|
||||
expect(kcalFinder, findsOneWidget);
|
||||
expect(addButton, findsOneWidget);
|
||||
|
||||
await tester.enterText(nameAutocompleteFinder, "Bread");
|
||||
await tester.enterText(amountFinder, "150");
|
||||
await tester.enterText(kcalFinder, "250");
|
||||
|
||||
await tester.tap(addButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// EnterFoodWidget collapses
|
||||
expect(nameAutocompleteFinder, findsNothing);
|
||||
|
||||
var enteredFood = find.text("Bread");
|
||||
var enteredAmount = find.text("150");
|
||||
var enteredKcal = find.text("250");
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(enteredFood, findsOneWidget);
|
||||
expect(enteredAmount, findsOneWidget);
|
||||
expect(enteredKcal, findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
/* Copyright (C) 2024 Marco Groß <mgross@sw-gross.de> */
|
||||
import 'package:calorimeter/storage/storage.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:calorimeter/food_entry/food_entry_bloc.dart';
|
||||
import 'package:calorimeter/utils/row_with_spacers_widget.dart';
|
||||
@ -8,8 +8,10 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class EnterFoodWidget extends StatefulWidget {
|
||||
final Function(BuildContext context, FoodEntryState entry) onAdd;
|
||||
final Map<String, int> foodEntryLookupDatabase;
|
||||
|
||||
const EnterFoodWidget({super.key, required this.onAdd});
|
||||
const EnterFoodWidget(
|
||||
{super.key, required this.onAdd, required this.foodEntryLookupDatabase});
|
||||
|
||||
@override
|
||||
State<EnterFoodWidget> createState() => _EnterFoodWidgetState();
|
||||
@ -19,28 +21,52 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
|
||||
late TextEditingController nameController;
|
||||
late TextEditingController massController;
|
||||
late TextEditingController kcalPerMassController;
|
||||
late Map<String, int> suggestions;
|
||||
late bool open;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
nameController = TextEditingController();
|
||||
massController = TextEditingController();
|
||||
kcalPerMassController = TextEditingController();
|
||||
suggestions = FoodStorage.getInstance().getFoodEntryLookupDatabase;
|
||||
|
||||
open = false;
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
return Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
if (!open)
|
||||
RowWidget(
|
||||
showDividers: false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: () => setState(() => open = true),
|
||||
child: Icon(Icons.add)),
|
||||
),
|
||||
Offstage(
|
||||
offstage: !open,
|
||||
child: AnimatedOpacity(
|
||||
duration: Duration(milliseconds: 250),
|
||||
opacity: open ? 1.0 : 0.0,
|
||||
child: RowWidget(
|
||||
showDividers: true,
|
||||
Autocomplete<String>(
|
||||
optionsViewOpenDirection: OptionsViewOpenDirection.down,
|
||||
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
|
||||
fieldViewBuilder:
|
||||
(context, controller, focusNode, onSubmitted) {
|
||||
nameController = controller;
|
||||
return TextFormField(
|
||||
scrollPadding: EdgeInsets.only(bottom: 100),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
@ -53,7 +79,7 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
|
||||
return const Iterable<String>.empty();
|
||||
}
|
||||
|
||||
return suggestions.keys.where(
|
||||
return widget.foodEntryLookupDatabase.keys.where(
|
||||
(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
@ -62,7 +88,8 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
|
||||
);
|
||||
},
|
||||
onSelected: (selectedFood) {
|
||||
int kcalPerMassForSelectedFood = suggestions[selectedFood]!;
|
||||
int kcalPerMassForSelectedFood =
|
||||
widget.foodEntryLookupDatabase[selectedFood]!;
|
||||
setState(() {
|
||||
nameController.text = selectedFood;
|
||||
kcalPerMassController.text =
|
||||
@ -70,11 +97,12 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
|
||||
});
|
||||
}),
|
||||
TextField(
|
||||
scrollPadding: EdgeInsets.only(bottom: 100),
|
||||
textAlign: TextAlign.end,
|
||||
decoration: InputDecoration(
|
||||
label: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(AppLocalizations.of(context)!.amountPer),
|
||||
label: Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Text(AppLocalizations.of(context)!.amount),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
@ -82,26 +110,34 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
|
||||
onSubmitted: (value) => onSubmitAction(),
|
||||
),
|
||||
TextField(
|
||||
scrollPadding: EdgeInsets.only(bottom: 100),
|
||||
textAlign: TextAlign.end,
|
||||
decoration: InputDecoration(
|
||||
label: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(AppLocalizations.of(context)!.kcalper),
|
||||
)),
|
||||
label: Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Text(AppLocalizations.of(context)!.kcal))),
|
||||
keyboardType: TextInputType.number,
|
||||
controller: kcalPerMassController,
|
||||
onSubmitted: (value) => onSubmitAction(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: ElevatedButton(
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: () => onSubmitAction(),
|
||||
child: const Icon(Icons.add)),
|
||||
child: const Icon(Icons.check)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
open = false;
|
||||
}))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -143,6 +179,7 @@ class _EnterFoodWidgetState extends State<EnterFoodWidget> {
|
||||
nameController.text = "";
|
||||
massController.text = "";
|
||||
kcalPerMassController.text = "";
|
||||
open = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -40,12 +40,8 @@ class FoodEntryBloc extends Bloc<FoodEvent, GlobalEntryState> {
|
||||
await storage.writeEntriesForDate(event.forDate, entriesForDate);
|
||||
storage.addFoodEntryToLookupDatabase(event.entry);
|
||||
|
||||
// this is just checking if writing to the database worked
|
||||
// can be optimized out by just emitting newState
|
||||
var newList = await storage.getEntriesForDate(event.forDate);
|
||||
|
||||
var newFoodEntries = state.foodEntries;
|
||||
newFoodEntries.addAll({event.forDate: newList});
|
||||
newFoodEntries.addAll({event.forDate: entriesForDate});
|
||||
|
||||
emit(GlobalEntryState(foodEntries: newFoodEntries));
|
||||
}
|
||||
@ -65,12 +61,8 @@ class FoodEntryBloc extends Bloc<FoodEvent, GlobalEntryState> {
|
||||
await storage.writeEntriesForDate(event.forDate, entriesForDate);
|
||||
storage.addFoodEntryToLookupDatabase(event.newEntry);
|
||||
|
||||
// this is just checking if writing to the database worked
|
||||
// can be optimized out by just emitting newState
|
||||
var newList = await storage.getEntriesForDate(event.forDate);
|
||||
|
||||
var newFoodEntries = state.foodEntries;
|
||||
newFoodEntries.addAll({event.forDate: newList});
|
||||
newFoodEntries.addAll({event.forDate: entriesForDate});
|
||||
|
||||
emit(GlobalEntryState(foodEntries: newFoodEntries));
|
||||
}
|
||||
@ -84,12 +76,8 @@ class FoodEntryBloc extends Bloc<FoodEvent, GlobalEntryState> {
|
||||
|
||||
await storage.writeEntriesForDate(event.forDate, entriesForDate);
|
||||
|
||||
// this is just checking if writing to the database worked
|
||||
// can be optimized out by just emitting newState
|
||||
var newList = await storage.getEntriesForDate(event.forDate);
|
||||
|
||||
var newFoodEntries = state.foodEntries;
|
||||
newFoodEntries.addAll({event.forDate: newList});
|
||||
newFoodEntries.addAll({event.forDate: entriesForDate});
|
||||
|
||||
emit(GlobalEntryState(foodEntries: newFoodEntries));
|
||||
}
|
||||
@ -109,11 +97,10 @@ class FoodEntryBloc extends Bloc<FoodEvent, GlobalEntryState> {
|
||||
return;
|
||||
}
|
||||
|
||||
var client = FoodFactLookupClient();
|
||||
var entriesForDate = state.foodEntries[event.forDate];
|
||||
if (entriesForDate == null) return;
|
||||
|
||||
var client = FoodFactLookupClient();
|
||||
|
||||
if (scanResult.type == ResultType.Cancelled) {
|
||||
return;
|
||||
}
|
||||
@ -134,9 +121,8 @@ class FoodEntryBloc extends Bloc<FoodEvent, GlobalEntryState> {
|
||||
);
|
||||
|
||||
entriesForDate.add(newEntryWaiting);
|
||||
var newFoodEntries = state.foodEntries;
|
||||
newFoodEntries.addAll({event.forDate: entriesForDate});
|
||||
emit(GlobalEntryState(foodEntries: newFoodEntries));
|
||||
state.foodEntries.addAll({event.forDate: entriesForDate});
|
||||
emit(GlobalEntryState(foodEntries: state.foodEntries));
|
||||
|
||||
await responseFuture.then((response) async {
|
||||
var index = entriesForDate
|
||||
@ -184,9 +170,8 @@ class FoodEntryBloc extends Bloc<FoodEvent, GlobalEntryState> {
|
||||
await storage.writeEntriesForDate(event.forDate, entriesForDate);
|
||||
storage.addFoodEntryToLookupDatabase(newEntryFinishedWaiting);
|
||||
|
||||
var entriesFromStorage = await storage.getEntriesForDate(event.forDate);
|
||||
var newFoodEntries = state.foodEntries;
|
||||
newFoodEntries.addAll({event.forDate: entriesFromStorage});
|
||||
newFoodEntries.addAll({event.forDate: entriesForDate});
|
||||
|
||||
emit(GlobalEntryState(foodEntries: newFoodEntries));
|
||||
});
|
||||
|
@ -1,8 +1,8 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
/* Copyright (C) 2024 Marco Groß <mgross@sw-gross.de> */
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:calorimeter/food_entry/food_entry_bloc.dart';
|
||||
import 'package:calorimeter/utils/row_with_spacers_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class FoodEntryWidget extends StatefulWidget {
|
||||
@ -24,6 +24,8 @@ class FoodEntryWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _FoodEntryWidgetState extends State<FoodEntryWidget> {
|
||||
final animationDuration = const Duration(milliseconds: 150);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -32,56 +34,61 @@ class _FoodEntryWidgetState extends State<FoodEntryWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onTap(context, widget.entry),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
onTap: () {
|
||||
widget.onTap(context, widget.entry);
|
||||
},
|
||||
child: Stack(children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: RowWidget(
|
||||
RowWidget(
|
||||
showDividers: !widget.entry.isSelected,
|
||||
widget.entry.waitingForNetwork
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Text(widget.entry.name),
|
||||
widget.entry.waitingForNetwork
|
||||
AnimatedOpacity(
|
||||
duration: animationDuration,
|
||||
opacity: widget.entry.isSelected ? 0.0 : 1.0,
|
||||
child: widget.entry.waitingForNetwork
|
||||
? Container()
|
||||
: Text(widget.entry.mass.ceil().toString(),
|
||||
textAlign: TextAlign.end),
|
||||
Opacity(
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: animationDuration,
|
||||
opacity: widget.entry.isSelected ? 0.0 : 1.0,
|
||||
child: widget.entry.waitingForNetwork
|
||||
? Container()
|
||||
: Text(widget.entry.kcalPer100.ceil().toString(),
|
||||
textAlign: TextAlign.end),
|
||||
),
|
||||
Opacity(
|
||||
AnimatedOpacity(
|
||||
duration: animationDuration,
|
||||
opacity: widget.entry.isSelected ? 0.0 : 1.0,
|
||||
child: widget.entry.waitingForNetwork
|
||||
? Container()
|
||||
: Text(
|
||||
(widget.entry.mass *
|
||||
widget.entry.kcalPer100 /
|
||||
100)
|
||||
(widget.entry.mass * widget.entry.kcalPer100 / 100)
|
||||
.ceil()
|
||||
.toString(),
|
||||
textAlign: TextAlign.end),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
Positioned.fill(
|
||||
child: Stack(children: [
|
||||
AnimatedOpacity(
|
||||
duration: animationDuration,
|
||||
opacity: widget.entry.isSelected ? 0.66 : 0.0,
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.secondary)),
|
||||
]),
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
Opacity(
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: animationDuration,
|
||||
opacity: widget.entry.isSelected ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: IconButton(
|
||||
padding: const EdgeInsets.all(0.0),
|
||||
icon: const Icon(Icons.edit),
|
||||
@ -94,29 +101,27 @@ class _FoodEntryWidgetState extends State<FoodEntryWidget> {
|
||||
return FoodEntryChangeDialog(
|
||||
entry: widget.entry,
|
||||
onChange: (context, entry) {
|
||||
widget.onChange(context, entry);
|
||||
widget.onChange(
|
||||
context, entry);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
: null),
|
||||
),
|
||||
: null)),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: IconButton(
|
||||
padding: const EdgeInsets.all(0.0),
|
||||
iconSize: 24,
|
||||
icon: const Icon(Icons.delete),
|
||||
icon: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
onPressed: widget.entry.isSelected
|
||||
? () => widget.onDelete(context, widget.entry.id)
|
||||
: null),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
? () =>
|
||||
widget.onDelete(context, widget.entry.id)
|
||||
: null))
|
||||
])))
|
||||
]))
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,19 +77,23 @@ class FoodFactModel {
|
||||
}
|
||||
}
|
||||
|
||||
String quantityString = json['product']['product_quantity'] ?? "0";
|
||||
double quantity;
|
||||
|
||||
int quantityForModel = 0;
|
||||
try {
|
||||
quantity = double.parse(quantityString);
|
||||
String quantityString = json['product']['product_quantity'] ?? "0";
|
||||
quantityForModel = double.parse(quantityString).ceil();
|
||||
} catch (e) {
|
||||
quantity = 0;
|
||||
try {
|
||||
quantityForModel =
|
||||
(json['product']['product_quantity'] as num).toDouble().ceil();
|
||||
} catch (e) {
|
||||
quantityForModel = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return FoodFactModel(
|
||||
name: json['product']['product_name'] ?? "",
|
||||
kcalPer100g: kcalPer100gForModel,
|
||||
mass: quantity.ceil(),
|
||||
mass: quantityForModel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
{
|
||||
"today": "Heute",
|
||||
"ok": "OK",
|
||||
"name": "Name",
|
||||
"amount": "Menge",
|
||||
"amountPer": "Menge in 100 g/ml",
|
||||
"kcalper": "kcal pro 100 g/ml",
|
||||
"amountPer": "Menge in g oder ml",
|
||||
"kcal": "kcal",
|
||||
"kcalper": "kcal pro 100 g oder ml",
|
||||
"kcalSum": "kcal gesamt",
|
||||
"kcalToday": "kcal heute",
|
||||
"menu": "Menü",
|
||||
"settings": "Einstellungen",
|
||||
|
@ -1,9 +1,12 @@
|
||||
{
|
||||
"today": "Today",
|
||||
"ok": "OK",
|
||||
"name": "Name",
|
||||
"amount": "Amount",
|
||||
"amountPer": "Amount in 100 g/ml",
|
||||
"kcalper": "kcal per 100 g/ml",
|
||||
"amountPer": "Amount in g or ml",
|
||||
"kcal": "kcal",
|
||||
"kcalper": "kcal per 100 g or ml",
|
||||
"kcalSum": "kcal total",
|
||||
"kcalToday": "kcal today",
|
||||
"menu": "Menu",
|
||||
"settings": "Settings",
|
||||
|
103
lib/main.dart
103
lib/main.dart
@ -1,5 +1,6 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
/* Copyright (C) 2024 Marco Groß <mgross@sw-gross.de> */
|
||||
|
||||
import 'package:calorimeter/food_entry/food_entry_bloc.dart';
|
||||
import 'package:calorimeter/perdate/perdate_pageview_controller.dart';
|
||||
import 'package:calorimeter/storage/storage.dart';
|
||||
@ -8,65 +9,81 @@ import 'package:calorimeter/utils/settings_bloc.dart';
|
||||
import 'package:calorimeter/utils/theme_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
List<FoodEntryState> entriesForToday = [];
|
||||
DateTime timeNow = DateTime.now();
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
var storage = await FoodStorage.create();
|
||||
await storage.buildFoodLookupDatabase();
|
||||
|
||||
timeNow = DateTimeHelper.now();
|
||||
entriesForToday = await storage.getEntriesForDate(timeNow);
|
||||
|
||||
var kcalLimit = await storage.readLimit();
|
||||
var brightness = await storage.readBrightness();
|
||||
var foodStorage = await FoodStorage.create();
|
||||
await foodStorage.buildFoodLookupDatabase();
|
||||
|