diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..5f7f0169 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "FSharp.suggestGitignore": false, + "cSpell.words": [ + "gruene" + ], + "files.eol": "\n" +} \ No newline at end of file diff --git a/assets/icons/refresh.svg b/assets/icons/refresh.svg new file mode 100644 index 00000000..5ac45b29 --- /dev/null +++ b/assets/icons/refresh.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/app/theme/theme.dart b/lib/app/theme/theme.dart index 6d3f46aa..46f32a6a 100644 --- a/lib/app/theme/theme.dart +++ b/lib/app/theme/theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:google_fonts/google_fonts.dart'; class ThemeColors { @@ -31,7 +32,7 @@ class ThemeColors { } class _ThemeTextStyles { - // TOOD use GrueneType font + // TODO use GrueneType font static TextStyle displayMedium = GoogleFonts.ptSans( textStyle: TextStyle( fontSize: 18, @@ -78,12 +79,12 @@ final ThemeData appTheme = ThemeData.light().copyWith( primaryColor: ThemeColors.primary, disabledColor: ThemeColors.textDisabled, colorScheme: ThemeData.light().colorScheme.copyWith( - primary: ThemeColors.primary, - secondary: ThemeColors.secondary, - tertiary: ThemeColors.tertiary, - surface: ThemeColors.background, - surfaceDim: ThemeColors.backgroundSecondary, - ), + primary: ThemeColors.primary, + secondary: ThemeColors.secondary, + tertiary: ThemeColors.tertiary, + surface: ThemeColors.background, + surfaceDim: ThemeColors.backgroundSecondary, + ), textTheme: TextTheme( displayLarge: _ThemeTextStyles.displayLarge, displayMedium: _ThemeTextStyles.displayMedium, @@ -101,4 +102,14 @@ final ThemeData appTheme = ThemeData.light().copyWith( unselectedLabelStyle: _ThemeTextStyles.labelSmall, ), scaffoldBackgroundColor: ThemeColors.backgroundSecondary, + actionIconTheme: ActionIconThemeData( + backButtonIconBuilder: (BuildContext context) => SvgPicture.asset('assets/icons/back.svg'), + ), + tabBarTheme: TabBarTheme( + indicatorColor: ThemeColors.primary, + indicatorSize: TabBarIndicatorSize.tab, + labelStyle: _ThemeTextStyles.titleMedium, + unselectedLabelStyle: _ThemeTextStyles.titleMedium, + labelColor: ThemeColors.primary, + ), ); diff --git a/lib/app/widgets/app_bar.dart b/lib/app/widgets/app_bar.dart index 6f29dfee..45345de2 100644 --- a/lib/app/widgets/app_bar.dart +++ b/lib/app/widgets/app_bar.dart @@ -12,14 +12,28 @@ class MainAppBar extends StatelessWidget implements PreferredSizeWidget { final currentRoute = GoRouterState.of(context); final theme = Theme.of(context); return AppBar( - title: Text(currentRoute.name ?? '', style: theme.textTheme.displayMedium?.apply(color: theme.colorScheme.surface)), + title: Text( + currentRoute.name ?? '', + style: theme.textTheme.displayMedium?.apply(color: theme.colorScheme.surface), + ), foregroundColor: theme.colorScheme.surface, backgroundColor: theme.primaryColor, centerTitle: true, actions: [ + if (currentRoute.path == Routes.campaigns) + IconButton( + icon: CustomIcon( + path: 'assets/icons/refresh.svg', + color: ThemeColors.background, + ), + onPressed: null, + ), if (currentRoute.path != Routes.settings) IconButton( - icon: CustomIcon(path: 'assets/icons/settings.svg', color: ThemeColors.background), + icon: CustomIcon( + path: 'assets/icons/settings.svg', + color: ThemeColors.background, + ), onPressed: () => context.push(Routes.settings), ), ], diff --git a/lib/features/campaigns/screens/campaigns_screen.dart b/lib/features/campaigns/screens/campaigns_screen.dart index ad866d22..f5379917 100644 --- a/lib/features/campaigns/screens/campaigns_screen.dart +++ b/lib/features/campaigns/screens/campaigns_screen.dart @@ -1,12 +1,93 @@ import 'package:flutter/material.dart'; +import 'package:gruene_app/app/theme/theme.dart'; +import 'package:gruene_app/features/campaigns/screens/doors_screen.dart'; +import 'package:gruene_app/features/campaigns/screens/flyer_screen.dart'; +import 'package:gruene_app/features/campaigns/screens/posters_screen.dart'; +import 'package:gruene_app/features/campaigns/screens/statistics_screen.dart'; +import 'package:gruene_app/features/campaigns/screens/teams_screen.dart'; +import 'package:gruene_app/i18n/translations.g.dart'; -class CampaignsScreen extends StatelessWidget { - const CampaignsScreen({super.key}); +class CampaignsScreen extends StatefulWidget { + CampaignsScreen({super.key}); + @override + State createState() => _CampaignsScreen(); +} + +class _CampaignsScreen extends State with SingleTickerProviderStateMixin { + final List campaignTabs = [ + CampaignMenuModel(t.campaigns.door.label, true, DoorsScreen()), + CampaignMenuModel(t.campaigns.posters.label, true, PostersScreen()), + CampaignMenuModel(t.campaigns.flyer.label, true, FlyerScreen()), + CampaignMenuModel(t.campaigns.team.label, false, TeamsScreen()), + CampaignMenuModel(t.campaigns.statistic.label, false, StatisticsScreen()), + ]; + late TabController _tabController; + + @override + void initState() { + _tabController = TabController(length: campaignTabs.length, vsync: this); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + void onTap(int index) { + setState(() { + _tabController.index = !campaignTabs[index]._enabled ? _tabController.previousIndex : index; + }); + } @override Widget build(BuildContext context) { - return const Center( - child: Text('Campaigns Screen'), + return DefaultTabController( + initialIndex: 0, + length: campaignTabs.length, + child: Scaffold( + backgroundColor: ThemeColors.background, + appBar: AppBar( + shape: Border(bottom: BorderSide(color: ThemeColors.textLight, width: 2)), + toolbarHeight: 0, + backgroundColor: ThemeColors.backgroundSecondary, + bottom: TabBar( + controller: _tabController, + tabs: campaignTabs + .map( + (CampaignMenuModel item) => Tab( + child: Semantics( + child: Text( + item._tabTitle, + style: item._enabled ? null : TextStyle(color: ThemeColors.textDisabled), + ), + ), + ), + ) + .toList(), + isScrollable: true, + tabAlignment: TabAlignment.start, + indicatorWeight: 4, + onTap: (int index) => onTap(index), + ), + ), + body: TabBarView( + controller: _tabController, + physics: NeverScrollableScrollPhysics(), + children: campaignTabs.map((CampaignMenuModel tab) { + return tab._view; + }).toList(), + ), + ), ); } } + +class CampaignMenuModel { + final String _tabTitle; + final bool _enabled; + final Widget _view; + + const CampaignMenuModel(this._tabTitle, this._enabled, this._view); +} diff --git a/lib/features/campaigns/screens/doors_screen.dart b/lib/features/campaigns/screens/doors_screen.dart new file mode 100644 index 00000000..e6715a56 --- /dev/null +++ b/lib/features/campaigns/screens/doors_screen.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:gruene_app/features/campaigns/widgets/filter_chip_widget.dart'; +import 'package:gruene_app/i18n/translations.g.dart'; + +class DoorsScreen extends StatelessWidget { + DoorsScreen({super.key}); + + final List doorsFilter = [ + FilterChipModel(t.campaigns.filters.visited_areas, false), + FilterChipModel(t.campaigns.filters.routes, false), + FilterChipModel(t.campaigns.filters.focusAreas, true), + FilterChipModel(t.campaigns.filters.experience_areas, false), + ]; + final Map> doorsExclusions = >{ + t.campaigns.filters.focusAreas: [t.campaigns.filters.visited_areas], + t.campaigns.filters.visited_areas: [t.campaigns.filters.focusAreas], + }; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FilterChipCampaign(doorsFilter, doorsExclusions), + Center(child: Text(t.campaigns.door.label)), + ], + ); + } +} diff --git a/lib/features/campaigns/screens/flyer_screen.dart b/lib/features/campaigns/screens/flyer_screen.dart new file mode 100644 index 00000000..1fd6569c --- /dev/null +++ b/lib/features/campaigns/screens/flyer_screen.dart @@ -0,0 +1,23 @@ +import 'package:flutter/widgets.dart'; +import 'package:gruene_app/features/campaigns/widgets/filter_chip_widget.dart'; +import 'package:gruene_app/i18n/translations.g.dart'; + +class FlyerScreen extends StatelessWidget { + FlyerScreen({super.key}); + + final List flyerFilter = [ + FilterChipModel(t.campaigns.filters.visited_areas, false), + FilterChipModel(t.campaigns.filters.routes, false), + FilterChipModel(t.campaigns.filters.experience_areas, false), + ]; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FilterChipCampaign(flyerFilter, >{}), + Center(child: Text(t.campaigns.flyer.label)), + ], + ); + } +} diff --git a/lib/features/campaigns/screens/posters_screen.dart b/lib/features/campaigns/screens/posters_screen.dart new file mode 100644 index 00000000..48fe1458 --- /dev/null +++ b/lib/features/campaigns/screens/posters_screen.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; +import 'package:gruene_app/features/campaigns/widgets/filter_chip_widget.dart'; +import 'package:gruene_app/i18n/translations.g.dart'; + +class PostersScreen extends StatelessWidget { + PostersScreen({super.key}); + + final List postersFilter = [ + FilterChipModel(t.campaigns.filters.routes, false), + FilterChipModel(t.campaigns.filters.polling_stations, false), + FilterChipModel(t.campaigns.filters.experience_areas, false), + ]; + @override + Widget build(BuildContext context) { + return Column( + children: [ + FilterChipCampaign(postersFilter, >{}), + Center(child: Text(t.campaigns.posters.label)), + ], + ); + } +} diff --git a/lib/features/campaigns/screens/statistics_screen.dart b/lib/features/campaigns/screens/statistics_screen.dart new file mode 100644 index 00000000..83ecc3cb --- /dev/null +++ b/lib/features/campaigns/screens/statistics_screen.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:gruene_app/app/theme/theme.dart'; +import 'package:gruene_app/i18n/translations.g.dart'; + +class StatisticsScreen extends StatelessWidget { + const StatisticsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Placeholder( + color: Colors.red, + child: Center( + child: Text( + t.campaigns.statistic.label, + style: TextStyle(fontSize: 20, color: ThemeColors.primary), + ), + ), + ); + } +} diff --git a/lib/features/campaigns/screens/teams_screen.dart b/lib/features/campaigns/screens/teams_screen.dart new file mode 100644 index 00000000..470c5a62 --- /dev/null +++ b/lib/features/campaigns/screens/teams_screen.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:gruene_app/app/theme/theme.dart'; +import 'package:gruene_app/i18n/translations.g.dart'; + +class TeamsScreen extends StatelessWidget { + const TeamsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Placeholder( + color: Colors.red, + child: Center( + child: Text( + t.campaigns.team.label, + style: TextStyle(fontSize: 20, color: ThemeColors.primary), + ), + ), + ); + } +} diff --git a/lib/features/campaigns/widgets/filter_chip_widget.dart b/lib/features/campaigns/widgets/filter_chip_widget.dart new file mode 100644 index 00000000..612585e4 --- /dev/null +++ b/lib/features/campaigns/widgets/filter_chip_widget.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:gruene_app/app/theme/theme.dart'; + +class FilterChipModel { + final String _text; + final bool _isEnabled; + + const FilterChipModel(this._text, this._isEnabled); +} + +class FilterChipCampaign extends StatefulWidget { + final List filterOptions; + final Map> filterExclusions; + + const FilterChipCampaign( + this.filterOptions, + this.filterExclusions, { + super.key, + }); + + @override + State createState() => _FilterChipCampaignState(); +} + +class _FilterChipCampaignState extends State { + Set currentFilters = {}; + + void unselect(String itemLabel) { + currentFilters.removeWhere((z) => z._text == itemLabel); + } + + void unselectExclusions(FilterChipModel item) { + var filterExclusions = widget.filterExclusions; + filterExclusions.entries.where((x) => x.key == item._text).map((x) => x.value).forEach((x) => x.forEach(unselect)); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: EdgeInsets.only(left: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Wrap( + spacing: 15.0, + children: widget.filterOptions.map((filterItem) { + return FilterChip( + label: Text(filterItem._text), + backgroundColor: ThemeColors.background, + selectedColor: ThemeColors.primary, + padding: EdgeInsets.zero, + side: filterItem._isEnabled + ? BorderSide(color: ThemeColors.primary, width: 2) + : BorderSide(color: ThemeColors.textDisabled, width: 1), + shape: StadiumBorder(), + selected: currentFilters.contains(filterItem), + showCheckmark: false, + labelStyle: TextStyle( + color: filterItem._isEnabled ? ChipLabelColor() : ThemeColors.textDisabled, + ), + onSelected: (bool selected) { + setState(() { + if (!filterItem._isEnabled) return; + if (selected) { + unselectExclusions(filterItem); + currentFilters.add(filterItem); + } else { + currentFilters.remove(filterItem); + } + }); + }, + ); + }).toList(), + ), + ], + ), + ), + ); + } +} + +class ChipLabelColor extends Color implements WidgetStateColor { + const ChipLabelColor() : super(_default); + + static const int _default = 0xFF000000; + + @override + Color resolve(Set states) { + if (states.contains(WidgetState.selected)) { + return Colors.white; // Selected text color + } + return Colors.black; // normal text color + } +} diff --git a/lib/i18n/app_de.json b/lib/i18n/app_de.json index dd606ce6..2cc6304f 100644 --- a/lib/i18n/app_de.json +++ b/lib/i18n/app_de.json @@ -8,7 +8,29 @@ }, "campaigns": { "campaigns": "Wahlkampf", - "label": "Wahlkampf" + "label": "Wahlkampf", + "filters": { + "focusAreas": "Fokusgebiete", + "routes": "Routen", + "visited_areas": "Besuchte Gebiete", + "polling_stations": "Wahllokale", + "experience_areas": "Erfahrungsgebiete" + }, + "posters": { + "label": "Plakate" + }, + "door": { + "label": "Haustür" + }, + "flyer": { + "label": "Flyer" + }, + "team": { + "label": "Team" + }, + "statistic": { + "label": "Statistik" + } }, "profiles": { "profiles": "Mitglieder",