glanzlicht / MeinWeimar_APP

Official MeinWeimar App

Verified secure showcase
204 Files 2 MB Public size 14 Redacted Dart · PHP · XML · GITIGNORE · YAML · PLIST

Project overview

MeinWeimar_APP is a verified code showcase with 204 visible project entries across 2 top-level modules. The primary detected language is Dart; protected files remain visible in the structure while their contents stay private.

README

MeinWeimar — Flutter App & WordPress Plugin

> Working in Progress · Private portfolio repository · Active development

MeinWeimar is a location-based local platform for residents, students and visitors. This repository intentionally contains only two product parts:

  1. the Flutter consumer app;
  2. the meinweimar-core WordPress plugin that provides the editorial/admin
  3. layer and the REST contract consumed by the app.

The complete public website, WordPress core, databases, private infrastructure and unrelated platform modules are not part of this repository.

MeinWeimar consumer app preview

Why this project is interesting

MeinWeimar combines a mobile/web Flutter client with WordPress used as a structured content and administration backend. The goal is a maintainable product boundary: content, branding, navigation, categories, media and configuration are managed in WordPress and delivered through a versioned REST contract instead of being hardcoded in the Flutter UI.

The project demonstrates work across:

  • Flutter/Dart application architecture for iOS, Android and Web;
  • WordPress plugin architecture with custom post types, taxonomies, admin
  • settings and REST endpoints;

  • localized German/English UI and backend-controlled labels;
  • discovery/list/detail flows for places, services, events, classifieds, jobs
  • and real estate;

  • map, GPS, geocoding, marker and navigation contracts;
  • caching, ETag/config-version handling and offline-safe fallbacks;
  • role-aware account, claim, favorites, moderation and messaging boundaries;
  • automated Flutter tests, PHP/REST contract checks and release builds.

Repository structure

flutter-app/       Flutter consumer application
wordpress-plugin/  meinweimar-core WordPress plugin
screenshots/       Curated portfolio preview only

Current status

This is an active Working in Progress project. The core architecture and many end-to-end flows are implemented and continuously tested. Native device polish, production content, final legal/privacy review and further product modules are still being developed. The repository does not claim to be a finished production release.

Local development

Flutter app

cd flutter-app
flutter pub get
flutter analyze
flutter test
flutter run --dart-define=MW_API_BASE_URL=https://example.test/wp-json/meinweimar/v1

The API base URL and other environment-specific values are supplied at build or run time. No credentials are stored in the repository.

WordPress plugin

Copy wordpress-plugin into a WordPress installation's wp-content/plugins/meinweimar-core directory and activate it in the admin area. The plugin owns the MeinWeimar content types, editorial settings, permissions, REST responses, media/configuration contracts and frontend integration points.

Privacy and repository boundary

This repository is private and is prepared for controlled technical review. Secrets, production database exports, user data, WordPress uploads, private keys and infrastructure configuration must remain outside version control. The public-facing portfolio page uses a separate, redacted presentation layer and does not expose the private repository directly.

For technical reviewers

The most relevant entry points are:

  • flutter-app/lib/core — shared configuration, networking, localization,
  • theme and location contracts;

  • flutter-app/lib/features — consumer-facing product surfaces;
  • flutter-app/lib/repositories — API boundary and data access;
  • wordpress-plugin/includes — plugin services, REST, admin and contracts;
  • wordpress-plugin/assets — admin/frontend integration assets.

MeinWeimar is deliberately presented as an evolving product rather than a finished demo: the repository shows the architectural decisions, the working implementation and the remaining engineering work transparently.

flutter-app/lib/features/home/home_page.dartDart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/config/app_config.dart';
import '../../core/location/location_controller.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart';
import '../../models/content_models.dart';
import '../../repositories/discovery_repository.dart';
import '../../shared/discovery/service_cards.dart';
import '../../shared/media/public_media_image.dart';
import '../../shared/states/app_state_widgets.dart';
class HomePage extends StatefulWidget {
const HomePage({
super.key,
required this.repository,
required this.brand,
required this.hero,
required this.resources,
this.locationController,
this.homeSections = HomeSectionConfig.defaults,
required this.onOpenDiscovery,
required this.onOpenResource,
this.onOpenQuickItem,
required this.onOpenItem,
required this.onOpenCommunity,
});
final DiscoveryRepository repository;
final BrandConfig brand;
final HomeHeroConfig hero;
final List<ResourceDefinition> resources;
final LocationController? locationController;
final List<HomeSectionConfig> homeSections;
final VoidCallback onOpenDiscovery;
final ValueChanged<String> onOpenResource;
final ValueChanged<HomeQuickAccessItem>? onOpenQuickItem;
final ValueChanged<DiscoveryItem> onOpenItem;
final VoidCallback onOpenCommunity;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
late Future<_HomeData> _future;
double? _lastLoadedLatitude;
double? _lastLoadedLongitude;
@override
void initState() {
super.initState();
widget.locationController?.addListener(_handleLocationChanged);
_future = _load();
}
@override
void dispose() {
widget.locationController?.removeListener(_handleLocationChanged);
super.dispose();
}
void _handleLocationChanged() {
if (!mounted) return;
final coordinate = widget.locationController?.coordinate;
if (_lastLoadedLatitude == coordinate?.latitude &&
_lastLoadedLongitude == coordinate?.longitude) {
return;
}
unawaited(_refresh());
}
Future<void> _refresh() async {
final future = _load();
setState(() => _future = future);
await future;
}
Future<_HomeData> _load() async {
final enabled = widget.homeSections
.where((section) => section.enabled)
.map((section) => section.id)
.toSet();
final sections = await Future.wait([
enabled.contains('events')
? _discover(const ['events'], sort: 'auto', perPage: 5)
: Future.value(const _HomeSection(items: [])),
enabled.contains('news')
? _discover(
const ['news'],
sort: 'newest',
perPage: 5,
// News are editorial content. They must never inherit the
// discovery shell's location/radius query, even on Home.
radiusKm: null,
)
: Future.value(const _HomeSection(items: [])),
(enabled.contains('nearby') || enabled.contains('places'))
? _discover(
const ['places'],
sort: 'auto',
perPage: 6,
useLocation: true,
)
: Future.value(const _HomeSection(items: [])),
enabled.contains('listings')
? _discover(
const ['classifieds', 'jobs', 'real-estate'],
sort: 'newest',
perPage: 6,
)
: Future.value(const _HomeSection(items: [])),
]);
return _HomeData(
events: sections[0],
news: sections[1],
places: sections[2],
listings: sections[3],
);
}
Future<_HomeSection> _discover(
List<String> types, {
required String sort,
required int perPage,
double? radiusKm = 10,
bool useLocation = false,
}) async {
try {
final coordinate = useLocation
? widget.locationController?.coordinate
: null;
_lastLoadedLatitude = coordinate?.latitude;
_lastLoadedLongitude = coordinate?.longitude;
final response = await widget.repository.discover(
types: types,
perPage: perPage,
sort: sort,
radiusKm: radiusKm,
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
);
return _HomeSection(
items: response.items,
categoryNames: {
for (final option in response.meta.categories)
option.slug: option.name,
},
);
} catch (_) {
return const _HomeSection(items: [], failed: true);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<_HomeData>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting &&
!snapshot.hasData) {
return _scrollShell(
SliverToBoxAdapter(
child: Column(
children: [
const LinearProgressIndicator(minHeight: 2),
..._content(context, const _HomeData.empty()),
],
),
),
);
}
if (snapshot.hasError || !snapshot.hasData) {
return _scrollShell(
SliverFillRemaining(
hasScrollBody: false,
child: ErrorState(onRetry: _refresh),
),
);
}
final data = snapshot.data!;
if (data.allFailed) {
return _scrollShell(
SliverFillRemaining(
hasScrollBody: false,
child: ErrorState(onRetry: _refresh),
),
);
}
return _scrollShell(
SliverToBoxAdapter(child: Column(children: _content(context, data))),
);
},
);
}
Widget _scrollShell(Widget sliver) {
return ColoredBox(
color: AppTheme.palette(context).surface,
child: RefreshIndicator(
onRefresh: _refresh,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 28),
sliver: sliver,
),
],
),
),
);
}
List<Widget> _content(BuildContext context, _HomeData data) {
final l10n = AppLocalizations.of(context);
final content = <Widget>[];
for (final section in widget.homeSections) {
if (!section.enabled) continue;
final block = _sectionBlock(context, section, data, l10n);
if (block == null) continue;
if (content.isNotEmpty) {
content.add(const SizedBox(height: 26));
}
content.add(block);
}
return content;
}
Widget? _sectionBlock(
BuildContext context,
HomeSectionConfig section,
_HomeData data,
AppLocalizations l10n,
) {
final locale = Localizations.localeOf(context).toLanguageTag();
final title = section.text(locale, _sectionFallbackTitle(section.id, l10n));
final sectionTitle = _SectionTitle(
title: title,
icon: _sectionIcon(section.icon),
iconUrl: section.iconUrl,
);
switch (section.id) {
case 'hero':
return _HeroCard(
brand: widget.brand,
hero: widget.hero,
onOpenDiscovery: widget.onOpenDiscovery,
);
case 'quick_access':
final quickItems = _quickItems(section, l10n);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle,
const SizedBox(height: 10),
_QuickAccessSlider(
items: quickItems,
labelBuilder: (item) =>
item.text(locale, _resourceLabel(l10n, item.route)),
onOpen: (item) {
final callback = widget.onOpenQuickItem;
if (callback != null) {
callback(item);
} else {
widget.onOpenResource(item.route);
}
},
),
],
);
case 'events':
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle,
const SizedBox(height: 10),
_HorizontalItems(
items: data.events.items,
categoryNames: data.events.categoryNames,
resourceLabelBuilder: (item) =>
_resourceLabel(l10n, item.resource),
emptyLabel: l10n.homeEmptyBody,
onOpenItem: widget.onOpenItem,
),
],
);
case 'news':
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle,
const SizedBox(height: 10),
_HorizontalItems(
items: data.news.items,
categoryNames: data.news.categoryNames,
resourceLabelBuilder: (item) =>
_resourceLabel(l10n, item.resource),
emptyLabel: l10n.homeEmptyBody,
onOpenItem: widget.onOpenItem,
),
],
);
case 'nearby':
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
title: title,
icon: _sectionIcon(section.icon),
iconUrl: section.iconUrl,
trailing: l10n.homeNearbyAction,
),
const SizedBox(height: 10),
_HorizontalItems(
items: data.places.items,
categoryNames: data.places.categoryNames,
resourceLabelBuilder: (item) =>
_resourceLabel(l10n, item.resource),
emptyLabel: l10n.homeNearbyBody,
onOpenItem: widget.onOpenItem,
),
],
);
case 'listings':
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle,
const SizedBox(height: 10),
_ListingGrid(
items: data.listings.items,
resourceLabelBuilder: (item) =>
_resourceLabel(l10n, item.resource),
emptyLabel: l10n.homeEmptyBody,
onOpenItem: widget.onOpenItem,
),
],
);
case 'community':
return _CommunityCard(
title: title,
icon: _sectionIcon(section.icon),
iconUrl: section.iconUrl,
onOpen: widget.onOpenCommunity,
);
default:
return null;
}
}
String _sectionFallbackTitle(String id, AppLocalizations l10n) {
return switch (id) {
'quick_access' => l10n.homeQuickAccessTitle,
'events' => l10n.homeEventsTitle,
'news' => l10n.homeNewsTitle,
'nearby' => l10n.homeNearbyTitle,
'listings' => l10n.homeListingsTitle,
'community' => l10n.homeCommunityTitle,
_ => '',
};
}
IconData _sectionIcon(String key) {
return switch (key) {
'bolt' => Icons.bolt_outlined,
'event' => Icons.event_outlined,
'auto_stories' => Icons.auto_stories_outlined,
'near_me' => Icons.near_me_outlined,
'new_releases' => Icons.new_releases_outlined,
'people' => Icons.people_alt_outlined,
'place' => Icons.place_outlined,
'map' => Icons.map_outlined,
'sell' => Icons.sell_outlined,
'work' => Icons.work_outline,
'home_work' => Icons.home_work_outlined,
'article' => Icons.article_outlined,
'menu_book' => Icons.menu_book_outlined,
_ => Icons.grid_view_outlined,
};
}
List<ResourceDefinition> _quickResources() {
final byId = <String, ResourceDefinition>{
for (final resource in widget.resources) resource.id: resource,
};
const ids = ['places', 'events', 'classifieds', 'jobs', 'real-estate'];
final resources = <ResourceDefinition>[];
for (final id in ids) {
final resource = byId[id];
if (resource != null) resources.add(resource);
}
if (resources.isNotEmpty) return resources;
return const [
ResourceDefinition(id: 'places', label: '', endpoint: '', geo: true),
ResourceDefinition(id: 'events', label: '', endpoint: '', geo: true),
ResourceDefinition(id: 'classifieds', label: '', endpoint: '', geo: true),
ResourceDefinition(id: 'jobs', label: '', endpoint: '', geo: true),
ResourceDefinition(id: 'real-estate', label: '', endpoint: '', geo: true),
];
}
List<HomeQuickAccessItem> _quickItems(
HomeSectionConfig section,
AppLocalizations l10n,
) {
if (section.quickItems.isNotEmpty) return section.quickItems;
// Compatibility for an older backend response. These are resource
// definitions, not invented category terms; visible category choices
// still come exclusively from WordPress.
return _quickResources()
.map(
(resource) => HomeQuickAccessItem(
route: resource.id,
category: '',
label: {'*': _resourceLabel(l10n, resource.id)},
imageUrl: resource.iconUrl,
imageAlt: resource.iconAlt,
),
)
.toList(growable: false);
}
String _resourceLabel(AppLocalizations l10n, String id) {
return switch (id) {
'places' => l10n.navPlaces,
'events' => l10n.navEvents,
'classifieds' => l10n.moduleClassifieds,
'jobs' => l10n.moduleJobs,
'real-estate' => l10n.moduleRealEstate,
_ => l10n.contentTypeOther,
};
}
}
class _HomeData {
const _HomeData.empty()
: events = const _HomeSection(items: []),
news = const _HomeSection(items: []),
places = const _HomeSection(items: []),
listings = const _HomeSection(items: []);
const _HomeData({
required this.events,
required this.news,
required this.places,
required this.listings,
});
final _HomeSection events;
final _HomeSection news;
final _HomeSection places;
final _HomeSection listings;
bool get allFailed =>
events.failed && news.failed && places.failed && listings.failed;
}
class _HomeSection {
const _HomeSection({
required this.items,
this.categoryNames = const {},
this.failed = false,
});
final List<DiscoveryItem> items;
final Map<String, String> categoryNames;
final bool failed;
}
class _HeroCard extends StatelessWidget {
const _HeroCard({
required this.brand,
required this.hero,
required this.onOpenDiscovery,
});
final BrandConfig brand;
final HomeHeroConfig hero;
final VoidCallback onOpenDiscovery;
@override
Widget build(BuildContext context) {
final palette = AppTheme.palette(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final title = hero.text(hero.title, locale, '');
final body = hero.text(hero.body, locale, '');
final primaryLabel = hero.text(hero.primaryLabel, locale, '');
final secondaryLabel = hero.text(hero.secondaryLabel, locale, '');
final imageUrl = hero.imageUrl.isNotEmpty
? hero.imageUrl
: brand.heroImageUrl;
final imageAlt = hero.imageAlt.isNotEmpty
? hero.imageAlt
: brand.heroImageAlt;
final logoUrl = hero.logoUrl.isNotEmpty ? hero.logoUrl : brand.logoLightUrl;
final logoAlt = hero.logoAlt.isNotEmpty ? hero.logoAlt : brand.logoLightAlt;
final eyebrow = hero.text(hero.eyebrow, locale, '');
final hasImage = imageUrl.trim().isNotEmpty;
final imageFit = hero.imageFit == 'contain' ? BoxFit.contain : BoxFit.cover;
final imageAspectRatio = hero.imageWidth > 0 && hero.imageHeight > 0
? hero.imageWidth / hero.imageHeight
: null;
final imageHeightMode =
hero.heightMode == 'image' && imageAspectRatio != null;
final content = _heroContent(
context,
title: title,
body: body,
eyebrow: eyebrow,
primaryLabel: primaryLabel,
secondaryLabel: secondaryLabel,
primaryUrl: hero.text(hero.primaryUrl, locale, ''),
secondaryUrl: hero.text(hero.secondaryUrl, locale, ''),
logoUrl: logoUrl,
logoAlt: logoAlt,
palette: palette,
);
final stack = _heroStack(
context,
content: content,
hasImage: hasImage,
imageUrl: imageUrl,
imageAlt: imageAlt,
imageFit: imageFit,
imageHeightMode: imageHeightMode,
);
return ClipRRect(
borderRadius: BorderRadius.circular(26),
child: imageHeightMode
? AspectRatio(aspectRatio: imageAspectRatio, child: stack)
: IntrinsicHeight(child: stack),
);
}
Widget _heroStack(
BuildContext context, {
required Widget content,
required bool hasImage,
required String imageUrl,
required String imageAlt,
required BoxFit imageFit,
required bool imageHeightMode,
}) {
final palette = AppTheme.palette(context);
final background = Positioned.fill(
child: hasImage
? ColoredBox(
color: palette.secondary,
child: PublicMediaImage(
url: imageUrl,
alt: imageAlt,
fit: imageFit,
),
)
: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
palette.secondary,
palette.brand.withValues(alpha: .86),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
);
final overlay = Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
palette.ink.withValues(alpha: .88),
palette.ink.withValues(alpha: .20),
palette.ink.withValues(alpha: .72),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
);
final contentLayer = imageHeightMode
? SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: content,
)
: Padding(padding: const EdgeInsets.all(24), child: content);
return Stack(children: [background, overlay, contentLayer]);
}
Widget _heroContent(
BuildContext context, {
required String title,
required String body,
required String eyebrow,
required String primaryLabel,
required String secondaryLabel,
required String primaryUrl,
required String secondaryUrl,
required String logoUrl,
required String logoAlt,
required MeinWeimarPalette palette,
}) {
final titleColor = _parseHeroColor(hero.colors.title, Colors.white);
final bodyColor = _parseHeroColor(
hero.colors.body,
Colors.white.withValues(alpha: .78),
);
final eyebrowColor = _parseHeroColor(hero.colors.eyebrow, palette.accent);
final buttonColor = _parseHeroColor(hero.colors.button, palette.brand);
final buttonTextColor = _parseHeroColor(
hero.colors.buttonText,
Colors.white,
);
final secondaryButtonColor = _parseHeroColor(
hero.colors.secondaryButton,
palette.secondary,
);
final secondaryButtonTextColor = _parseHeroColor(
hero.colors.secondaryButtonText,
Colors.white,
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (logoUrl.isNotEmpty)
SizedBox(
width: 132,
height: 42,
child: Image.network(
logoUrl,
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
semanticLabel: logoAlt,
),
),
if (eyebrow.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
eyebrow,
style: TextStyle(
color: eyebrowColor,
fontWeight: FontWeight.w800,
letterSpacing: 1.1,
),
),
],
if (title.isNotEmpty || body.isNotEmpty || primaryLabel.isNotEmpty)
const SizedBox(height: 16),
if (title.isNotEmpty)
Text(
title,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: titleColor,
fontWeight: FontWeight.w800,
),
),
if (title.isNotEmpty && body.isNotEmpty) const SizedBox(height: 6),
if (body.isNotEmpty)
Text(body, style: TextStyle(color: bodyColor, height: 1.35)),
if (primaryLabel.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _openLink(context, primaryUrl),
style: FilledButton.styleFrom(
backgroundColor: buttonColor,
foregroundColor: buttonTextColor,
),
icon: const Icon(Icons.search_outlined),
label: Text(primaryLabel),
),
],
if (secondaryLabel.isNotEmpty) ...[
const SizedBox(height: 8),
TextButton(
onPressed: () => _openLink(context, secondaryUrl),
style: TextButton.styleFrom(
backgroundColor: secondaryButtonColor,
foregroundColor: secondaryButtonTextColor,
),
child: Text(secondaryLabel),
),
],
],
);
}
Color _parseHeroColor(String value, Color fallback) {
final normalized = value.trim().replaceFirst('#', '');
if (normalized.length != 6 && normalized.length != 8) return fallback;
final parsed = int.tryParse(normalized, radix: 16);
if (parsed == null) return fallback;
return normalized.length == 8 ? Color(parsed) : Color(0xFF000000 | parsed);
}
Future<void> _openLink(BuildContext context, String url) async {
final normalized = url.trim();
if (normalized.isEmpty || normalized.startsWith('#')) {
onOpenDiscovery();
return;
}
final uri = Uri.tryParse(normalized);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.platformDefault);
return;
}
onOpenDiscovery();
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({
required this.title,
required this.icon,
this.iconUrl = '',
this.trailing,
});
final String title;
final IconData icon;
final String iconUrl;
final String? trailing;
@override
Widget build(BuildContext context) {
final titleText = Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
);
final titleRow = Row(
children: [
iconUrl.isEmpty
? Icon(icon, color: AppTheme.palette(context).brand)
: PublicMediaImage(
url: iconUrl,
width: 24,
height: 24,
fit: BoxFit.contain,
alt: '',
emptyIcon: icon,
errorIcon: icon,
iconSize: 22,
iconColor: AppTheme.palette(context).brand,
backgroundColor: Colors.transparent,
borderColor: Colors.transparent,
),
const SizedBox(width: 8),
Expanded(child: titleText),
],
);
if (trailing == null) return titleRow;
final trailingText = Text(
trailing!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontWeight: FontWeight.w700,
),
);
if (MediaQuery.sizeOf(context).width < 420) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
titleRow,
Padding(
padding: const EdgeInsets.only(left: 32, top: 3),
child: trailingText,
),
],
);
}
return Row(
children: [
Expanded(child: titleRow),
const SizedBox(width: 8),
Flexible(child: trailingText),
],
);
}
}
class _QuickAccessSlider extends StatelessWidget {
const _QuickAccessSlider({
required this.items,
required this.labelBuilder,
required this.onOpen,
});
final List<HomeQuickAccessItem> items;
final String Function(HomeQuickAccessItem item) labelBuilder;
final ValueChanged<HomeQuickAccessItem> onOpen;
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final itemWidth = width >= 900 ? 120.0 : 104.0;
return SizedBox(
height: 145,
child: ListView.separated(
scrollDirection: Axis.horizontal,
dragStartBehavior: DragStartBehavior.down,
padding: const EdgeInsets.only(right: 4),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final item = items[index];
return SizedBox(
width: itemWidth,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onOpen(item),
child: Column(
children: [
SizedBox(
width: 88,
height: 88,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppTheme.palette(
context,
).muted.withValues(alpha: .24),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(1),
child: ClipOval(
child: PublicMediaImage(
url: item.imageUrl,
alt: item.imageAlt,
width: 86,
height: 86,
fit: BoxFit.cover,
backgroundColor: AppTheme.palette(
context,
).brandSurface,
borderColor: Colors.transparent,
iconColor: AppTheme.palette(context).brand,
emptyIcon: _resourceIcon(item.route),
errorIcon: _resourceIcon(item.route),
fallbackBuilder: (_) => ColoredBox(
color: AppTheme.palette(context).brandSurface,
child: Center(
child: Icon(
_resourceIcon(item.route),
size: 34,
color: AppTheme.palette(context).brand,
),
),
),
),
),
),
),
),
const SizedBox(height: 7),
Text(
labelBuilder(item),
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
height: 1.1,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
},
),
);
}
IconData _resourceIcon(String id) {
return switch (id) {
'places' => Icons.place_outlined,
'events' => Icons.event_outlined,
'services' => Icons.account_balance_outlined,
'classifieds' => Icons.sell_outlined,
'jobs' => Icons.work_outline,
'real-estate' => Icons.home_work_outlined,
_ => Icons.grid_view_outlined,
};
}
}
class _HorizontalItems extends StatelessWidget {
const _HorizontalItems({
required this.items,
required this.categoryNames,
required this.resourceLabelBuilder,
required this.emptyLabel,
required this.onOpenItem,
});
final List<DiscoveryItem> items;
final Map<String, String> categoryNames;
final String Function(DiscoveryItem item) resourceLabelBuilder;
final String emptyLabel;
final ValueChanged<DiscoveryItem> onOpenItem;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Text(
emptyLabel,
style: TextStyle(color: AppTheme.palette(context).muted),
),
),
);
}
// Let the tallest card determine the viewport height. A fixed height
// clips localized two-line titles, addresses or the shared card shadow.
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SingleChildScrollView(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
dragStartBehavior: DragStartBehavior.down,
padding: const EdgeInsets.only(right: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < items.length; index++)
Padding(
padding: EdgeInsets.only(
right: index == items.length - 1 ? 0 : 12,
),
child: HomeDiscoveryCard(
item: items[index],
resourceLabel: resourceLabelBuilder(items[index]),
categoryLabel: _categoryLabel(items[index]),
onTap: () => onOpenItem(items[index]),
),
),
],
),
),
);
}
String _categoryLabel(DiscoveryItem item) {
for (final slugs in item.terms.values) {
for (final slug in slugs) {
final label = categoryNames[slug];
if (label != null && label.trim().isNotEmpty) return label;
}
}
return '';
}
}
class _ListingGrid extends StatelessWidget {
const _ListingGrid({
required this.items,
required this.resourceLabelBuilder,
required this.emptyLabel,
required this.onOpenItem,
});
final List<DiscoveryItem> items;
final String Function(DiscoveryItem item) resourceLabelBuilder;
final String emptyLabel;
final ValueChanged<DiscoveryItem> onOpenItem;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Text(
emptyLabel,
style: TextStyle(color: AppTheme.palette(context).muted),
),
),
);
}
final columns = MediaQuery.sizeOf(context).width >= 900 ? 3 : 1;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: columns == 1 ? 3.4 : 1.45,
),
itemBuilder: (context, index) {
final item = items[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => onOpenItem(item),
child: Row(
children: [
SizedBox(
width: columns == 1 ? 118 : 100,
height: double.infinity,
child: PublicMediaImage(
url: item.imageUrl,
alt: item.title,
fit: BoxFit.cover,
backgroundColor: AppTheme.palette(context).brandSurface,
iconColor: AppTheme.palette(context).brand,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
resourceLabelBuilder(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).brand,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: .35,
),
),
const SizedBox(height: 4),
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
if (item.address.formatted.isNotEmpty) ...[
const SizedBox(height: 5),
Text(
item.address.formatted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppTheme.palette(context).muted,
fontSize: 12,
),
),
],
],
),
),
),
],
),
),
);
},
);
}
}
class _CommunityCard extends StatelessWidget {
const _CommunityCard({
required this.title,
required this.icon,
required this.iconUrl,
required this.onOpen,
});
final String title;
final IconData icon;
final String iconUrl;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Card(
color: AppTheme.palette(context).ink,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
iconUrl.isEmpty
? Icon(icon, color: AppTheme.palette(context).brand, size: 34)
: PublicMediaImage(
url: iconUrl,
width: 34,
height: 34,
fit: BoxFit.contain,
alt: '',
emptyIcon: icon,
errorIcon: icon,
iconSize: 30,
iconColor: AppTheme.palette(context).brand,
backgroundColor: Colors.transparent,
borderColor: Colors.transparent,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 19,
),
),
const SizedBox(height: 6),
Text(
l10n.homeCommunityBody,
style: const TextStyle(color: Colors.white70, height: 1.35),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: onOpen,
child: Text(l10n.homeCommunityAction),
),
],
),
),
],
),
),
);
}
}

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

flutter-appmodule
wordpress-pluginmodule

Technology stack

DartPHPJavaScriptARBLOCKPBXPROJFlutter / Dart
Dart56.1%
PHP29.4%
JavaScript4.0%
ARB2.9%
LOCK1.7%
PBXPROJ1.7%
CSS1.5%
Markdown0.4%