Files
listenmeister/lib/main.dart
2025-10-26 14:10:13 +01:00

450 lines
12 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
import 'package:shoppinglist/services/api.dart';
import 'package:shoppinglist/models/models.dart';
import 'package:shoppinglist/providers/theme.dart';
late final PocketBase pb;
late final ApiService apiService;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isAndroid || Platform.isIOS) {
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
}
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
await windowManager.ensureInitialized();
const WindowOptions windowOptions = WindowOptions(
size: Size(400, 720),
minimumSize: Size(380, 600),
center: true,
title: 'Einkaufsliste',
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
});
}
final SharedPreferences prefs = await SharedPreferences.getInstance();
final AsyncAuthStore store = AsyncAuthStore(
save: (String data) async => prefs.setString('pb_auth', data),
initial: prefs.getString('pb_auth'),
);
pb = PocketBase('https://pb.flumm.io', authStore: store);
apiService = ApiService(pb);
runApp(
ChangeNotifierProvider(
create: (_) => ThemeProvider(prefs),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: 'Einkaufsliste',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: themeProvider.themeMode,
home: const AuthGate(),
);
},
);
}
}
class AuthGate extends StatefulWidget {
const AuthGate({super.key});
@override
State<AuthGate> createState() => _AuthGateState();
}
class _AuthGateState extends State<AuthGate> {
StreamSubscription? _authSub;
bool _loggedIn = false;
@override
void initState() {
super.initState();
_loggedIn = pb.authStore.isValid;
_authSub = pb.authStore.onChange.listen((_) {
setState(() {
_loggedIn = pb.authStore.isValid;
});
});
}
@override
void dispose() {
_authSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_loggedIn) {
return const LoginPage();
} else {
return const ListsPage();
}
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final TextEditingController _emailCtl = TextEditingController();
final TextEditingController _pwCtl = TextEditingController();
bool _isRegister = false;
bool _loading = false;
String? _error;
Future<void> _submit() async {
setState(() {
_loading = true;
_error = null;
});
try {
final String email = _emailCtl.text.trim();
final String password = _pwCtl.text.trim();
if (_isRegister) {
await apiService.register(email, password);
} else {
await apiService.login(email, password);
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_isRegister ? 'Registrierung' : 'Login')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _emailCtl,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 8),
TextField(
controller: _pwCtl,
decoration: const InputDecoration(labelText: 'Passwort'),
obscureText: true,
),
const SizedBox(height: 20),
if (_error != null) ...[
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
],
ElevatedButton(
onPressed: _loading ? null : _submit,
child: _loading
? const CircularProgressIndicator()
: Text(_isRegister ? 'Registrieren' : 'Einloggen'),
),
TextButton(
onPressed: _loading
? null
: () => setState(() => _isRegister = !_isRegister),
child: Text(
_isRegister ? 'Ich habe schon ein Konto' : 'Neu registrieren',
),
),
],
),
),
);
}
}
class ListsPage extends StatefulWidget {
const ListsPage({super.key});
@override
State<ListsPage> createState() => _ListsPageState();
}
class _ListsPageState extends State<ListsPage> {
late Future<List<Liste>> _listsFuture;
@override
void initState() {
super.initState();
_loadLists();
}
void _loadLists() {
setState(() => _listsFuture = apiService.getLists());
}
Future<void> _createList() async {
final TextEditingController titleCtl = TextEditingController();
final bool? ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Neue Liste'),
content: TextField(
controller: titleCtl,
decoration: const InputDecoration(labelText: 'Titel'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('Abbrechen'),
),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Erstellen'),
),
],
),
);
if (ok != true || titleCtl.text.trim().isEmpty) return;
try {
await apiService.createList(titleCtl.text.trim());
_loadLists();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
}
}
}
void _openList(Liste list) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => ListDetailPage(list: list)),
).then((_) => _loadLists());
}
@override
Widget build(BuildContext context) {
final ThemeProvider themeProvider = Provider.of<ThemeProvider>(
context,
listen: false,
);
return Scaffold(
appBar: AppBar(
title: const Text('Deine Listen'),
actions: [
PopupMenuButton<ThemeMode>(
onSelected: (mode) => themeProvider.setThemeMode(mode),
itemBuilder: (context) => [
const PopupMenuItem(value: ThemeMode.light, child: Text('Hell')),
const PopupMenuItem(value: ThemeMode.dark, child: Text('Dunkel')),
const PopupMenuItem(
value: ThemeMode.system,
child: Text('System'),
),
],
icon: const Icon(Icons.brightness_6_outlined),
),
IconButton(
tooltip: 'Ausloggen',
icon: const Icon(Icons.logout),
onPressed: () {
apiService.logout();
},
),
],
),
body: FutureBuilder<List<Liste>>(
future: _listsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Fehler: ${snapshot.error}'));
}
final lists = snapshot.data ?? [];
if (lists.isEmpty) {
return const Center(child: Text('Keine Listen gefunden.'));
}
return ListView.builder(
itemCount: lists.length,
itemBuilder: (c, i) {
final l = lists[i];
return ListTile(title: Text(l.title), onTap: () => _openList(l));
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _createList,
child: const Icon(Icons.add),
),
);
}
}
class ListDetailPage extends StatefulWidget {
final Liste list;
const ListDetailPage({super.key, required this.list});
@override
State<ListDetailPage> createState() => _ListDetailPageState();
}
class _ListDetailPageState extends State<ListDetailPage> {
late Future<List<Item>> _itemsFuture;
@override
void initState() {
super.initState();
_loadItems();
}
void _loadItems() {
setState(() => _itemsFuture = apiService.getItems(widget.list.id));
}
Future<void> _addItem() async {
final TextEditingController ctl = TextEditingController();
final bool? ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Eintrag hinzufügen'),
content: TextField(
controller: ctl,
decoration: const InputDecoration(labelText: 'Name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('Abbrechen'),
),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Hinzufügen'),
),
],
),
);
if (ok != true || ctl.text.trim().isEmpty) return;
try {
final List<Item> items = await _itemsFuture;
final int maxPos = items.fold<int>(
-1,
(max, item) => item.position > max ? item.position : max,
);
await apiService.createItem(widget.list.id, ctl.text.trim(), maxPos + 1);
_loadItems();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Fehler: $e')));
}
}
}
Future<void> _toggleItem(Item item) async {
try {
setState(() => item.checked = !item.checked);
await apiService.updateItem(item.id, checked: item.checked);
} catch (e) {
setState(() => item.checked = !item.checked);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler beim Aktualisieren: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.list.title)),
body: FutureBuilder<List<Item>>(
future: _itemsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Fehler: ${snapshot.error}'));
}
final items = snapshot.data ?? [];
return ListView.builder(
itemCount: items.length,
itemBuilder: (c, i) {
final it = items[i];
return CheckboxListTile(
title: Text(it.name),
value: it.checked,
onChanged: (_) => _toggleItem(it),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _addItem,
child: const Icon(Icons.add),
),
);
}
}