first commit
This commit is contained in:
449
lib/main.dart
Normal file
449
lib/main.dart
Normal file
@@ -0,0 +1,449 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/models/models.dart
Normal file
35
lib/models/models.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
class Liste {
|
||||
final String id;
|
||||
String title;
|
||||
|
||||
Liste({required this.id, required this.title});
|
||||
|
||||
factory Liste.fromJson(Map<String, dynamic> json) {
|
||||
return Liste(id: json['id'] as String, title: json['title'] as String);
|
||||
}
|
||||
}
|
||||
|
||||
class Item {
|
||||
final String id;
|
||||
String name;
|
||||
bool checked;
|
||||
int position;
|
||||
|
||||
Item({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.checked = false,
|
||||
this.position = 0,
|
||||
});
|
||||
|
||||
factory Item.fromJson(Map<String, dynamic> json) {
|
||||
return Item(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
checked: json['checked'] as bool? ?? false,
|
||||
position: (json['position'] is int)
|
||||
? json['position']
|
||||
: int.tryParse(json['position'].toString()) ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/providers/theme.dart
Normal file
50
lib/providers/theme.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ThemeProvider extends ChangeNotifier {
|
||||
final SharedPreferences _prefs;
|
||||
static const String _themeKey = 'theme_mode';
|
||||
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
|
||||
ThemeProvider(this._prefs) {
|
||||
_loadTheme();
|
||||
}
|
||||
|
||||
void _loadTheme() {
|
||||
final themeString = _prefs.getString(_themeKey);
|
||||
switch (themeString) {
|
||||
case 'light':
|
||||
_themeMode = ThemeMode.light;
|
||||
break;
|
||||
case 'dark':
|
||||
_themeMode = ThemeMode.dark;
|
||||
break;
|
||||
default:
|
||||
_themeMode = ThemeMode.system;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
if (mode == _themeMode) return;
|
||||
_themeMode = mode;
|
||||
notifyListeners();
|
||||
|
||||
String themeString;
|
||||
switch (mode) {
|
||||
case ThemeMode.light:
|
||||
themeString = 'light';
|
||||
break;
|
||||
case ThemeMode.dark:
|
||||
themeString = 'dark';
|
||||
break;
|
||||
case ThemeMode.system:
|
||||
default:
|
||||
themeString = 'system';
|
||||
break;
|
||||
}
|
||||
await _prefs.setString(_themeKey, themeString);
|
||||
}
|
||||
}
|
||||
74
lib/services/api.dart
Normal file
74
lib/services/api.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import 'package:shoppinglist/models/models.dart';
|
||||
|
||||
class ApiService {
|
||||
final PocketBase pb;
|
||||
|
||||
ApiService(this.pb);
|
||||
|
||||
String? get userId => pb.authStore.model?.id;
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
}
|
||||
|
||||
Future<void> register(String email, String password) async {
|
||||
await pb
|
||||
.collection('users')
|
||||
.create(
|
||||
body: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'passwordConfirm': password,
|
||||
},
|
||||
);
|
||||
await login(email, password);
|
||||
}
|
||||
|
||||
void logout() {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
|
||||
Future<List<Liste>> getLists() async {
|
||||
if (userId == null) return [];
|
||||
final List<RecordModel> res = await pb
|
||||
.collection('lists')
|
||||
.getFullList(filter: 'owner = "$userId" || members ?~ "$userId"');
|
||||
return res.map((r) => Liste.fromJson(r.toJson())).toList();
|
||||
}
|
||||
|
||||
Future<Liste> createList(String title) async {
|
||||
if (userId == null) throw Exception('Nicht eingeloggt');
|
||||
final RecordModel rec = await pb
|
||||
.collection('lists')
|
||||
.create(body: {'title': title, 'owner': userId});
|
||||
return Liste.fromJson(rec.toJson());
|
||||
}
|
||||
|
||||
Future<List<Item>> getItems(String listId) async {
|
||||
final List<RecordModel> res = await pb
|
||||
.collection('items')
|
||||
.getFullList(filter: 'list = "$listId"', sort: 'position');
|
||||
return res.map((r) => Item.fromJson(r.toJson())).toList();
|
||||
}
|
||||
|
||||
Future<Item> createItem(String listId, String name, int position) async {
|
||||
final RecordModel rec = await pb
|
||||
.collection('items')
|
||||
.create(
|
||||
body: {
|
||||
'list': listId,
|
||||
'name': name,
|
||||
'position': position,
|
||||
'checked': false,
|
||||
},
|
||||
);
|
||||
return Item.fromJson(rec.toJson());
|
||||
}
|
||||
|
||||
Future<void> updateItem(String itemId, {bool? checked}) async {
|
||||
if (checked == null) return;
|
||||
await pb.collection('items').update(itemId, body: {'checked': checked});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user