Merge pull request #1 from DieLustigenTierwesen/shared_collections

Shared collections
This commit is contained in:
Lukas Müllner 2026-02-24 20:45:57 +01:00 committed by GitHub
commit ec0dc1a835
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1342 additions and 43 deletions

1
.gitignore vendored
View file

@ -45,3 +45,4 @@ app.*.map.json
/android/app/release /android/app/release
TPB.md TPB.md
*sql

View file

@ -6,7 +6,7 @@ plugins {
} }
android { android {
namespace = "com.derkauzigekoala.hwhub" namespace = "com.dltw.derkauzigekoala.hwhub"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@ -21,7 +21,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.derkauzigekoala.hwhub" applicationId = "com.dltw.derkauzigekoala.hwhub"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

View file

@ -1,4 +1,4 @@
package com.derkauzigekoala.hwhub package com.dltw.derkauzigekoala.hwhub
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

BIN
flutter_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View file

@ -372,7 +372,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -388,7 +388,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -405,7 +405,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -420,7 +420,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -551,7 +551,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -573,7 +573,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View file

@ -33,7 +33,6 @@ class _AboutScreenState extends State<AboutScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final version = _packageInfo != null final version = _packageInfo != null
? 'v${_packageInfo!.version} (${_packageInfo!.buildNumber})' ? 'v${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: ''; : '';

View file

@ -0,0 +1,422 @@
import 'package:flutter/material.dart';
import '../main.dart';
import '../services/collection_service.dart';
import '../theme/app_colors.dart';
import 'garage_screen.dart';
import 'manage_collection_screen.dart';
/// Lists all collections the current user is a member of.
class CollectionsScreen extends StatefulWidget {
const CollectionsScreen({super.key});
@override
State<CollectionsScreen> createState() => CollectionsScreenState();
}
class CollectionsScreenState extends State<CollectionsScreen> {
List<Collection> _collections = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
/// Public so other tabs can trigger a refresh.
void refresh() => _load();
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await CollectionService.getMyCollections();
if (!mounted) return;
setState(() {
_collections = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _createCollection() async {
final formKey = GlobalKey<FormState>();
final nameCtrl = TextEditingController();
final descCtrl = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
icon: Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
gradient: AppColors.brandGradient,
shape: BoxShape.circle,
),
child: const Icon(Icons.add, color: Colors.white, size: 28),
),
title: const Text('New Collection'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameCtrl,
autofocus: true,
maxLength: 50,
decoration: const InputDecoration(
labelText: 'Name',
hintText: 'e.g. Hot Wheels, Matchbox…',
),
validator: (value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) return 'Name is required';
if (trimmed.length < 2) return 'Name must be at least 2 characters';
if (trimmed.length > 50) return 'Name must be 50 characters or fewer';
return null;
},
),
const SizedBox(height: 12),
TextField(
controller: descCtrl,
decoration: const InputDecoration(
labelText: 'Description (optional)',
hintText: 'What is this collection for?',
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
Navigator.pop(context, true);
}
},
child: const Text('Create'),
),
],
),
);
if (result != true) return;
try {
await CollectionService.create(
name: nameCtrl.text.trim(),
description: descCtrl.text.trim(),
);
showGlobalSnackBar('Collection created!');
_load();
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
}
}
void _openCollection(Collection c) {
navigatorKey.currentState!.push(
MaterialPageRoute(
builder: (_) => GarageScreen(
collectionId: c.id,
collectionName: c.name,
isOwner: c.isOwner,
),
),
).then((_) => _load()); // refresh counts when coming back
}
void _manageCollection(Collection c) {
navigatorKey.currentState!.push(
MaterialPageRoute(
builder: (_) => ManageCollectionScreen(collection: c),
),
).then((_) => _load());
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// Header
SliverAppBar(
expandedHeight: 140,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.only(left: 20, bottom: 16),
title: Text(
'Collections',
style: TextStyle(
fontFamily: 'Poppins',
fontWeight: FontWeight.w700,
fontSize: 22,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
),
],
),
),
background: Container(
decoration: const BoxDecoration(
gradient: AppColors.brandGradient,
),
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 24),
child: Icon(
Icons.collections_bookmark,
size: 72,
color: Colors.white.withValues(alpha: 0.15),
),
),
),
),
),
),
// Content
if (_isLoading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_error != null)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline,
size: 56, color: AppColors.error),
const SizedBox(height: 16),
Text(_error!,
textAlign: TextAlign.center,
style:
const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
)
else if (_collections.isEmpty)
SliverFillRemaining(
child: Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.collections_bookmark_outlined,
size: 64, color: AppColors.textHint),
const SizedBox(height: 16),
const Text(
'No collections yet',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
const Text(
'Create your first collection to start tracking!',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textHint),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _createCollection,
icon: const Icon(Icons.add),
label: const Text('Create Collection'),
),
],
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final c = _collections[index];
return _CollectionCard(
collection: c,
onTap: () => _openCollection(c),
onManage: () => _manageCollection(c),
);
},
childCount: _collections.length,
),
),
),
],
),
floatingActionButton: _collections.isNotEmpty
? FloatingActionButton(
onPressed: _createCollection,
child: const Icon(Icons.add),
)
: null,
);
}
}
// Collection Card
class _CollectionCard extends StatelessWidget {
final Collection collection;
final VoidCallback onTap;
final VoidCallback onManage;
const _CollectionCard({
required this.collection,
required this.onTap,
required this.onManage,
});
@override
Widget build(BuildContext context) {
final c = collection;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Icon
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: c.isOwner
? AppColors.brandGradient
: const LinearGradient(
colors: [AppColors.navy, AppColors.navyLight],
),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
c.isOwner
? Icons.collections_bookmark
: Icons.group,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.name,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Row(
children: [
_MiniStat(
icon: Icons.directions_car,
value: '${c.itemCount}'),
const SizedBox(width: 16),
_MiniStat(
icon: Icons.people,
value: '${c.memberCount}'),
const SizedBox(width: 16),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: c.isOwner
? AppColors.orange.withValues(alpha: 0.15)
: AppColors.navy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
c.isOwner ? 'Owner' : 'Member',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: c.isOwner
? AppColors.orange
: AppColors.navy,
),
),
),
],
),
],
),
),
// Manage button
IconButton(
icon: const Icon(Icons.settings_outlined,
color: AppColors.textHint),
onPressed: onManage,
),
],
),
),
),
);
}
}
class _MiniStat extends StatelessWidget {
final IconData icon;
final String value;
const _MiniStat({required this.icon, required this.value});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppColors.textHint),
const SizedBox(width: 4),
Text(
value,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
);
}
}

View file

@ -7,9 +7,18 @@ import '../services/storage_service.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../widgets/car_card.dart'; import '../widgets/car_card.dart';
/// The "My Garage" screen shows the user's collected cars in a grid. /// The "My Garage" screen shows a collection's cars in a grid.
class GarageScreen extends StatefulWidget { class GarageScreen extends StatefulWidget {
const GarageScreen({super.key}); final String collectionId;
final String collectionName;
final bool isOwner;
const GarageScreen({
super.key,
required this.collectionId,
required this.collectionName,
this.isOwner = true,
});
@override @override
State<GarageScreen> createState() => GarageScreenState(); State<GarageScreen> createState() => GarageScreenState();
@ -44,13 +53,10 @@ class GarageScreenState extends State<GarageScreen> {
}); });
try { try {
final userId = supabase.auth.currentUser?.id;
if (userId == null) return;
final data = await supabase final data = await supabase
.from('hotwheels') .from('hotwheels')
.select() .select()
.eq('user_id', userId) .eq('collection_id', widget.collectionId)
.order('created_at', ascending: false); .order('created_at', ascending: false);
if (!mounted) return; if (!mounted) return;
@ -91,7 +97,7 @@ class GarageScreenState extends State<GarageScreen> {
titlePadding: titlePadding:
const EdgeInsets.only(left: 20, bottom: 16), const EdgeInsets.only(left: 20, bottom: 16),
title: Text( title: Text(
'My Garage', widget.collectionName,
style: TextStyle( style: TextStyle(
fontFamily: 'Poppins', fontFamily: 'Poppins',
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,

View file

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'garage_screen.dart'; import 'collections_screen.dart';
import 'scan_tab.dart'; import 'scan_tab.dart';
import 'profile_screen.dart'; import 'profile_screen.dart';
@ -13,19 +13,27 @@ class HomeShell extends StatefulWidget {
class _HomeShellState extends State<HomeShell> { class _HomeShellState extends State<HomeShell> {
int _currentIndex = 0; int _currentIndex = 0;
final _garageKey = GlobalKey<GarageScreenState>(); final _collectionsKey = GlobalKey<CollectionsScreenState>();
final _scanKey = GlobalKey<ScanTabState>();
final Map<int, DateTime> _lastRefreshed = {};
static const _refreshDebounce = Duration(seconds: 30);
late final List<Widget> _pages = <Widget>[ late final List<Widget> _pages = <Widget>[
GarageScreen(key: _garageKey), CollectionsScreen(key: _collectionsKey),
const ScanTab(), ScanTab(key: _scanKey),
const ProfileScreen(), const ProfileScreen(),
]; ];
void _onTabSelected(int i) { void _onTabSelected(int i) {
setState(() => _currentIndex = i); setState(() => _currentIndex = i);
// Refresh garage whenever user switches to it. final now = DateTime.now();
final last = _lastRefreshed[i];
if (last != null && now.difference(last) < _refreshDebounce) return;
_lastRefreshed[i] = now;
if (i == 0) { if (i == 0) {
_garageKey.currentState?.refresh(); _collectionsKey.currentState?.refresh();
} else if (i == 1) {
_scanKey.currentState?.refresh();
} }
} }
@ -41,9 +49,9 @@ class _HomeShellState extends State<HomeShell> {
onDestinationSelected: _onTabSelected, onDestinationSelected: _onTabSelected,
destinations: const [ destinations: const [
NavigationDestination( NavigationDestination(
icon: Icon(Icons.garage_outlined), icon: Icon(Icons.collections_bookmark_outlined),
selectedIcon: Icon(Icons.garage), selectedIcon: Icon(Icons.collections_bookmark),
label: 'Garage', label: 'Collections',
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.qr_code_scanner_outlined), icon: Icon(Icons.qr_code_scanner_outlined),

View file

@ -0,0 +1,449 @@
import 'package:flutter/material.dart';
import '../main.dart';
import '../services/collection_service.dart';
import '../theme/app_colors.dart';
/// Screen to manage a collection: rename, invite/remove members, delete.
class ManageCollectionScreen extends StatefulWidget {
final Collection collection;
const ManageCollectionScreen({super.key, required this.collection});
@override
State<ManageCollectionScreen> createState() =>
_ManageCollectionScreenState();
}
class _ManageCollectionScreenState extends State<ManageCollectionScreen> {
late Collection _collection;
List<CollectionMember> _members = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_collection = widget.collection;
_loadMembers();
}
Future<void> _loadMembers() async {
setState(() => _isLoading = true);
try {
final members =
await CollectionService.getMembers(_collection.id);
if (!mounted) return;
setState(() {
_members = members;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
showGlobalSnackBar('Failed to load members: $e', isError: true);
}
}
Future<void> _rename() async {
final formKey = GlobalKey<FormState>();
final ctrl = TextEditingController(text: _collection.name);
final descCtrl =
TextEditingController(text: _collection.description ?? '');
final result = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Rename Collection'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: ctrl,
autofocus: true,
maxLength: 50,
decoration: const InputDecoration(labelText: 'Name'),
validator: (value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) return 'Name is required';
if (trimmed.length < 2) return 'Name must be at least 2 characters';
if (trimmed.length > 50) return 'Name must be 50 characters or fewer';
return null;
},
),
const SizedBox(height: 12),
TextField(
controller: descCtrl,
decoration:
const InputDecoration(labelText: 'Description (optional)'),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
Navigator.pop(context, true);
}
},
child: const Text('Save'),
),
],
),
);
if (result != true) return;
final trimmedDesc = descCtrl.text.trim();
final String? description =
trimmedDesc.isEmpty ? null : trimmedDesc;
try {
await CollectionService.update(
collectionId: _collection.id,
name: ctrl.text.trim(),
description: description,
);
setState(() {
_collection = Collection(
id: _collection.id,
name: ctrl.text.trim(),
description: description,
ownerId: _collection.ownerId,
createdAt: _collection.createdAt,
role: _collection.role,
itemCount: _collection.itemCount,
memberCount: _collection.memberCount,
);
});
showGlobalSnackBar('Collection renamed!');
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
}
}
Future<void> _inviteMember() async {
final emailCtrl = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
icon: Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
gradient: AppColors.brandGradient,
shape: BoxShape.circle,
),
child: const Icon(Icons.person_add, color: Colors.white, size: 28),
),
title: const Text('Invite Member'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Enter the email address of the person you want to invite. '
'They must already have an account.',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
TextField(
controller: emailCtrl,
autofocus: true,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email address',
hintText: 'user@example.com',
prefixIcon: Icon(Icons.email_outlined),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (emailCtrl.text.trim().isEmpty) return;
Navigator.pop(context, true);
},
child: const Text('Invite'),
),
],
),
);
if (result != true) return;
final email = emailCtrl.text.trim();
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailRegex.hasMatch(email)) {
showGlobalSnackBar('Please enter a valid email address.', isError: true);
return;
}
try {
await CollectionService.inviteByEmail(
collectionId: _collection.id,
email: email,
);
showGlobalSnackBar('Member invited!');
_loadMembers();
} catch (e) {
showGlobalSnackBar('$e', isError: true);
}
}
Future<void> _removeMember(CollectionMember member) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Remove Member?'),
content: Text(
'Remove ${member.email} from this collection?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
child: const Text('Remove'),
),
],
),
);
if (confirmed != true) return;
try {
await CollectionService.removeMember(
collectionId: _collection.id,
membershipId: member.id,
);
showGlobalSnackBar('Member removed.');
_loadMembers();
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
}
}
Future<void> _leaveCollection() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Leave Collection?'),
content: Text(
'You will lose access to "${_collection.name}". '
'This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
child: const Text('Leave'),
),
],
),
);
if (confirmed != true) return;
try {
await CollectionService.leave(_collection.id);
showGlobalSnackBar('Left "${_collection.name}".');
if (mounted) Navigator.pop(context);
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
}
}
Future<void> _deleteCollection() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete Collection?'),
content: Text(
'Delete "${_collection.name}" and ALL its items? '
'This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: AppColors.error),
child: const Text('Delete Forever'),
),
],
),
);
if (confirmed != true) return;
try {
await CollectionService.delete(_collection.id);
showGlobalSnackBar('Collection deleted.');
if (mounted) Navigator.pop(context);
} catch (e) {
showGlobalSnackBar('Failed: $e', isError: true);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final currentUserId = supabase.auth.currentUser?.id;
return Scaffold(
appBar: AppBar(
title: Text(_collection.name),
actions: [
if (_collection.isOwner)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Rename',
onPressed: _rename,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Description
if (_collection.description != null &&
_collection.description!.isNotEmpty) ...[
Text(
_collection.description!,
style: const TextStyle(
fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 16),
],
// Members section
Row(
children: [
Text(
'Members',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (_collection.isOwner)
TextButton.icon(
onPressed: _inviteMember,
icon: const Icon(Icons.person_add, size: 18),
label: const Text('Invite'),
),
],
),
const SizedBox(height: 8),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
),
)
else
...List.generate(_members.length, (i) {
final m = _members[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: m.isOwner
? AppColors.orange
: AppColors.navy,
child: Icon(
m.isOwner ? Icons.star : Icons.person,
color: Colors.white,
size: 20,
),
),
title: Text(
m.email,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
m.isOwner ? 'Owner' : 'Member',
style: const TextStyle(fontSize: 12),
),
trailing: (!m.isOwner &&
_collection.isOwner &&
m.userId != currentUserId)
? IconButton(
icon: const Icon(Icons.remove_circle_outline,
color: AppColors.error),
onPressed: () => _removeMember(m),
)
: null,
),
);
}),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 16),
// Danger zone
Text(
'Danger Zone',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
const SizedBox(height: 12),
if (!_collection.isOwner)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _leaveCollection,
icon: const Icon(Icons.exit_to_app, color: AppColors.error),
label: const Text('Leave Collection',
style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
if (_collection.isOwner)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _deleteCollection,
icon: const Icon(Icons.delete_forever, color: AppColors.error),
label: const Text('Delete Collection',
style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
);
}
}

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../main.dart'; import '../main.dart';
import '../scanner_screen.dart'; import '../scanner_screen.dart';
import '../services/collection_service.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
@ -12,11 +13,43 @@ class ScanTab extends StatefulWidget {
const ScanTab({super.key}); const ScanTab({super.key});
@override @override
State<ScanTab> createState() => _ScanTabState(); State<ScanTab> createState() => ScanTabState();
} }
class _ScanTabState extends State<ScanTab> { class ScanTabState extends State<ScanTab> {
bool _isBusy = false; bool _isBusy = false;
List<Collection> _collections = [];
Collection? _selectedCollection;
bool _loadingCollections = true;
@override
void initState() {
super.initState();
_loadCollections();
}
/// Public so HomeShell can trigger a refresh when switching to this tab.
void refresh() => _loadCollections();
Future<void> _loadCollections() async {
try {
final list = await CollectionService.getMyCollections();
if (!mounted) return;
setState(() {
_collections = list;
_selectedCollection = list.isNotEmpty ? list.first : null;
_loadingCollections = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _loadingCollections = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to load collections: $e'),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -51,7 +84,7 @@ class _ScanTabState extends State<ScanTab> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
const Text( const Text(
'Point your camera at the model ID on the\npackaging to instantly add it to your garage.', 'Point your camera at the model ID on the\npackaging to instantly add it to your collection.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
@ -59,7 +92,74 @@ class _ScanTabState extends State<ScanTab> {
height: 1.5, height: 1.5,
), ),
), ),
const SizedBox(height: 36), const SizedBox(height: 24),
// Collection picker
if (_loadingCollections)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (_collections.isEmpty)
const Text(
'Create a collection first!',
style: TextStyle(color: AppColors.error),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: AppColors.orange.withValues(alpha: 0.4),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: _selectedCollection?.id,
icon: const Icon(Icons.arrow_drop_down,
color: AppColors.orange),
items: _collections
.map((c) => DropdownMenuItem(
value: c.id,
child: Row(
children: [
Icon(
c.isOwner
? Icons.collections_bookmark
: Icons.group,
size: 18,
color: AppColors.orange,
),
const SizedBox(width: 8),
Expanded(
child: Text(
c.name,
overflow: TextOverflow.ellipsis,
),
),
],
),
))
.toList(),
onChanged: (id) {
setState(() {
final matching =
_collections.where((c) => c.id == id);
_selectedCollection =
matching.isNotEmpty ? matching.first : null;
});
},
),
),
),
const SizedBox(height: 24),
// Scan button (gradient) // Scan button (gradient)
SizedBox( SizedBox(
@ -78,7 +178,9 @@ class _ScanTabState extends State<ScanTab> {
], ],
), ),
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _isBusy ? null : _openScanner, onPressed: _isBusy || _selectedCollection == null
? null
: _openScanner,
icon: _isBusy icon: _isBusy
? const SizedBox( ? const SizedBox(
width: 22, width: 22,
@ -113,7 +215,9 @@ class _ScanTabState extends State<ScanTab> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _isBusy ? null : _manualEntry, onPressed: _isBusy || _selectedCollection == null
? null
: _manualEntry,
icon: const Icon(Icons.keyboard), icon: const Icon(Icons.keyboard),
label: const Text('Enter ID Manually'), label: const Text('Enter ID Manually'),
), ),
@ -171,13 +275,18 @@ class _ScanTabState extends State<ScanTab> {
} }
Future<void> _processHwId(String hwId) async { Future<void> _processHwId(String hwId) async {
final collection = _selectedCollection;
if (collection == null) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
// Check if this hw_id already exists in the selected collection.
final data = await supabase final data = await supabase
.from('hotwheels') .from('hotwheels')
.select() .select()
.eq('hw_id', hwId) .eq('hw_id', hwId)
.eq('collection_id', collection.id)
.maybeSingle(); .maybeSingle();
if (!mounted) return; if (!mounted) return;
@ -190,9 +299,9 @@ class _ScanTabState extends State<ScanTab> {
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
icon: const Icon(Icons.check_circle, icon: const Icon(Icons.check_circle,
color: AppColors.success, size: 48), color: AppColors.success, size: 48),
title: const Text('Already in Garage!'), title: const Text('Already in Collection!'),
content: content:
Text('$hwId is already in your collection.'), Text('$hwId is already in "${collection.name}".'),
actions: [ actions: [
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -205,10 +314,14 @@ class _ScanTabState extends State<ScanTab> {
// New offer to add // New offer to add
final added = await showDialog<bool>( final added = await showDialog<bool>(
context: context, context: context,
builder: (_) => _AddCarDialog(hwId: hwId), builder: (_) => _AddCarDialog(
hwId: hwId,
collectionId: collection.id,
collectionName: collection.name,
),
); );
if (added == true) { if (added == true) {
showGlobalSnackBar('$hwId added to your garage! 🎉'); showGlobalSnackBar('$hwId added to "${collection.name}"! 🎉');
} }
} }
} catch (e) { } catch (e) {
@ -221,7 +334,13 @@ class _ScanTabState extends State<ScanTab> {
// Add Car Dialog (inline, styled) // Add Car Dialog (inline, styled)
class _AddCarDialog extends StatefulWidget { class _AddCarDialog extends StatefulWidget {
final String hwId; final String hwId;
const _AddCarDialog({required this.hwId}); final String collectionId;
final String collectionName;
const _AddCarDialog({
required this.hwId,
required this.collectionId,
required this.collectionName,
});
@override @override
State<_AddCarDialog> createState() => _AddCarDialogState(); State<_AddCarDialog> createState() => _AddCarDialogState();
@ -263,6 +382,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
await supabase.from('hotwheels').insert({ await supabase.from('hotwheels').insert({
'hw_id': widget.hwId, 'hw_id': widget.hwId,
'user_id': supabase.auth.currentUser!.id, 'user_id': supabase.auth.currentUser!.id,
'collection_id': widget.collectionId,
}); });
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, true); Navigator.pop(context, true);
@ -280,6 +400,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
final row = <String, dynamic>{ final row = <String, dynamic>{
'hw_id': widget.hwId, 'hw_id': widget.hwId,
'user_id': supabase.auth.currentUser!.id, 'user_id': supabase.auth.currentUser!.id,
'collection_id': widget.collectionId,
}; };
// Optional fields only include if filled in. // Optional fields only include if filled in.
@ -441,7 +562,7 @@ class _AddCarDialogState extends State<_AddCarDialog> {
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Text('Add to Garage'), : const Text('Add'),
), ),
], ],
); );

View file

@ -0,0 +1,293 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import '../main.dart';
/// Data model for a collection.
class Collection {
final String id;
final String name;
final String? description;
final String ownerId;
final DateTime createdAt;
final String role; // 'owner' or 'member'
final int itemCount;
final int memberCount;
Collection({
required this.id,
required this.name,
this.description,
required this.ownerId,
required this.createdAt,
required this.role,
this.itemCount = 0,
this.memberCount = 1,
});
bool get isOwner => role == 'owner';
}
/// Member of a collection.
class CollectionMember {
final String id;
final String userId;
final String email;
final String role;
final DateTime joinedAt;
CollectionMember({
required this.id,
required this.userId,
required this.email,
required this.role,
required this.joinedAt,
});
bool get isOwner => role == 'owner';
}
/// Service for managing collections and membership.
class CollectionService {
CollectionService._();
/// Fetch all collections the current user is a member of,
/// including item count and member count.
static Future<List<Collection>> getMyCollections() async {
final userId = supabase.auth.currentUser!.id;
// Get memberships with collection data.
final memberships = await supabase
.from('collection_members')
.select('role, collections(id, name, description, owner_id, created_at)')
.eq('user_id', userId);
// If the user has no memberships, return early.
if (memberships.isEmpty) {
return <Collection>[];
}
// Collect all distinct collection IDs from memberships.
final collectionIds = <String>{};
for (final m in memberships) {
final c = m['collections'] as Map<String, dynamic>;
final collectionId = c['id'] as String;
collectionIds.add(collectionId);
}
final collectionIdList = collectionIds.toList();
// Fetch all items for these collections in a single query and count them in memory.
final items = await supabase
.from('hotwheels')
.select('id, collection_id')
.in_('collection_id', collectionIdList);
final itemCounts = <String, int>{};
for (final item in items) {
final collectionId = item['collection_id'] as String;
itemCounts[collectionId] = (itemCounts[collectionId] ?? 0) + 1;
}
// Fetch all members for these collections in a single query and count them in memory.
final members = await supabase
.from('collection_members')
.select('id, collection_id')
.in_('collection_id', collectionIdList);
final memberCounts = <String, int>{};
for (final member in members) {
final collectionId = member['collection_id'] as String;
memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1;
}
final collections = <Collection>[];
for (final m in memberships) {
final c = m['collections'] as Map<String, dynamic>;
final collectionId = c['id'] as String;
collections.add(Collection(
id: collectionId,
name: c['name'] as String,
description: c['description'] as String?,
ownerId: c['owner_id'] as String,
createdAt: DateTime.parse(c['created_at'] as String),
role: m['role'] as String,
itemCount: itemCounts[collectionId] ?? 0,
memberCount: memberCounts[collectionId] ?? 1,
));
}
// Sort: owned first, then by name.
collections.sort((a, b) {
if (a.isOwner && !b.isOwner) return -1;
if (!a.isOwner && b.isOwner) return 1;
return a.name.compareTo(b.name);
});
return collections;
}
/// Create a new collection. The caller is automatically added as owner.
static Future<Collection> create({
required String name,
String? description,
}) async {
final userId = supabase.auth.currentUser!.id;
final row = await supabase
.from('collections')
.insert({
'name': name,
'owner_id': userId,
if (description != null && description.isNotEmpty)
'description': description,
})
.select()
.single();
// Add owner as a member.
await supabase.from('collection_members').insert({
'collection_id': row['id'],
'user_id': userId,
'role': 'owner',
});
return Collection(
id: row['id'] as String,
name: row['name'] as String,
description: row['description'] as String?,
ownerId: userId,
createdAt: DateTime.parse(row['created_at'] as String),
role: 'owner',
itemCount: 0,
memberCount: 1,
);
}
/// Update a collection's name/description. Owner only.
static Future<void> update({
required String collectionId,
required String name,
String? description,
}) async {
final userId = supabase.auth.currentUser!.id;
final collection = await supabase
.from('collections')
.select('owner_id')
.eq('id', collectionId)
.maybeSingle();
if (collection == null) {
throw Exception('Collection not found.');
}
if (collection['owner_id'] != userId) {
throw Exception('Only the collection owner can perform this action.');
}
await supabase.from('collections').update({
'name': name,
if (description != null && description.isNotEmpty)
'description': description,
}).eq('id', collectionId);
}
/// Delete a collection. Owner only. Cascade deletes members & items.
static Future<void> delete(String collectionId) async {
final userId = supabase.auth.currentUser!.id;
final collection = await supabase
.from('collections')
.select('owner_id')
.eq('id', collectionId)
.maybeSingle();
if (collection == null) {
throw Exception('Collection not found.');
}
if (collection['owner_id'] != userId) {
throw Exception('Only the collection owner can perform this action.');
}
await supabase.from('collections').delete().eq('id', collectionId);
}
/// Get members of a collection (with emails via RPC).
static Future<List<CollectionMember>> getMembers(
String collectionId) async {
final rows = await supabase.rpc('get_collection_members', params: {
'p_collection_id': collectionId,
});
final members = <CollectionMember>[];
for (final r in rows) {
members.add(CollectionMember(
id: r['id'] as String,
userId: r['user_id'] as String,
email: r['email'] as String? ?? 'unknown',
role: r['role'] as String,
joinedAt: DateTime.parse(r['created_at'] as String),
));
}
return members;
}
/// Invite a user by email. Looks up auth.users via an RPC function,
/// then inserts a collection_members row.
static Future<void> inviteByEmail({
required String collectionId,
required String email,
}) async {
// Call an RPC to look up the user ID by email.
final result = await supabase.rpc('get_user_id_by_email', params: {
'lookup_email': email.trim().toLowerCase(),
});
if (result == null || (result is List && result.isEmpty)) {
throw Exception(
'No user found with that email. They must create an account first.');
}
final userId = result is List ? result.first['id'] as String : result as String;
// Check if already a member.
final existing = await supabase
.from('collection_members')
.select('id')
.eq('collection_id', collectionId)
.eq('user_id', userId)
.maybeSingle();
if (existing != null) {
throw Exception('This user is already a member of this collection.');
}
await supabase.from('collection_members').insert({
'collection_id': collectionId,
'user_id': userId,
'role': 'member',
});
}
/// Remove a member from a collection.
static Future<void> removeMember({
required String collectionId,
required String membershipId,
}) async {
await supabase
.from('collection_members')
.delete()
.eq('id', membershipId);
}
/// Leave a collection (for non-owners).
static Future<void> leave(String collectionId) async {
final userId = supabase.auth.currentUser!.id;
await supabase
.from('collection_members')
.delete()
.eq('collection_id', collectionId)
.eq('user_id', userId);
}
}

View file

@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "hwhub") set(BINARY_NAME "hwhub")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.derkauzigekoala.hwhub") set(APPLICATION_ID "com.dltw.derkauzigekoala.hwhub")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View file

@ -385,7 +385,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub";
@ -399,7 +399,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub";
@ -413,7 +413,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/hwhub.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/hwhub";

View file

@ -8,7 +8,7 @@
PRODUCT_NAME = hwhub PRODUCT_NAME = hwhub
// The application's bundle identifier // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.derkauzigekoala.hwhub PRODUCT_BUNDLE_IDENTIFIER = com.dltw.derkauzigekoala.hwhub
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.