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.

wordpress-plugin/includes/class-notifications.phpPHP
<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Private in-app notifications. External delivery channels are intentionally
* not activated here; consent, quiet hours and provider policy remain open.
*/
final class Notifications
{
private const SCHEMA_VERSION = '0.2.0';
private const MAX_PAYLOAD_BYTES = 4000;
public static function hooks(): void
{
add_action('rest_api_init', [self::class, 'register_routes']);
// Schema upgrades are write operations. Never run them as a side
// effect of a public page or REST request.
add_action('admin_init', [self::class, 'maybe_upgrade'], 1);
}
public static function install(): void
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->get_charset_collate();
$schema_before = self::schema_incomplete();
if (null === $schema_before || !self::prepare_dedupe_key_migration()) {
// A failed schema read or legacy-row normalization is not proof
// that the database is safe to change. Do not continue into
// dbDelta() or update the schema version option.
return;
}
dbDelta("CREATE TABLE " . self::table() . " (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
recipient_user_id bigint(20) unsigned NOT NULL,
type varchar(80) NOT NULL,
target_type varchar(40) NOT NULL DEFAULT '',
target_id bigint(20) unsigned NULL DEFAULT NULL,
payload_json text NOT NULL,
status varchar(20) NOT NULL DEFAULT 'unread',
locale varchar(20) NOT NULL DEFAULT '',
dedupe_key varchar(160) NOT NULL DEFAULT '',
created_at datetime NOT NULL,
read_at datetime NULL DEFAULT NULL,
expires_at datetime NULL DEFAULT NULL,
PRIMARY KEY (id),
KEY recipient_status (recipient_user_id,status,created_at),
KEY recipient_created (recipient_user_id,created_at),
KEY dedupe_key (dedupe_key),
UNIQUE KEY recipient_dedupe (recipient_user_id,dedupe_key)
) {$charset_collate};");
if (false !== self::schema_incomplete()) {
// dbDelta() may have failed or left an unreadable/incomplete
// schema. Never mark the upgrade as complete in that state.
return;
}
update_option('meinweimar_notifications_schema_version', self::SCHEMA_VERSION, false);
}
public static function maybe_upgrade(): void
{
if (!current_user_can('manage_options')) {
return;
}
$schema_incomplete = self::schema_incomplete();
if ($schema_incomplete === null) {
// A failed read is not evidence of a missing schema. Do not let
// an unhealthy database trigger dbDelta() or update_option().
return;
}
global $wpdb;
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$schema_version = get_option('meinweimar_notifications_schema_version', '');
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '') {
return;
}
if (self::SCHEMA_VERSION !== (string) $schema_version || $schema_incomplete) {
self::install();
}
}
private static function schema_incomplete(): ?bool
{
global $wpdb;
$table = self::table();
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '') {
return null;
}
if ($exists !== $table) {
return true;
}
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$columns = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($columns)) {
return null;
}
return [] !== array_diff([
'payload_json',
'dedupe_key',
'read_at',
'expires_at',
], $columns);
}
public static function register_routes(): void
{
register_rest_route('meinweimar/v1', '/notifications', [
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'list'],
'permission_callback' => [AccountApi::class, 'require_login'],
]);
register_rest_route('meinweimar/v1', '/notifications/(?P<id>\d+)/read', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'mark_read'],
'permission_callback' => [AccountApi::class, 'require_login'],
]);
}
public static function create(array $notification): int|WP_Error
{
global $wpdb;
$recipient = absint($notification['recipient_user_id'] ?? 0);
$type = sanitize_key((string) ($notification['type'] ?? ''));
if (!$recipient || !$type) {
return new WP_Error('meinweimar_notification_invalid', __('Die Benachrichtigung ist ungültig.', 'meinweimar-core'), ['status' => 400]);
}
$payload = is_array($notification['payload'] ?? null) ? $notification['payload'] : [];
$payload = self::safe_payload($payload);
$payload_json = wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload_json) || strlen($payload_json) > self::MAX_PAYLOAD_BYTES) {
return new WP_Error('meinweimar_notification_payload_invalid', __('Die Benachrichtigungsdaten sind ungültig.', 'meinweimar-core'), ['status' => 400]);
}
$dedupe_key = self::text($notification['dedupe_key'] ?? '', 160);
if (!$dedupe_key) {
// Empty keys must remain distinct once recipient-scoped dedupe is
// enforced. Callers that need idempotency provide their own key.
$dedupe_key = 'notification-' . wp_generate_uuid4();
}
if ($dedupe_key) {
$wpdb->last_error = '';
$existing = $wpdb->get_var($wpdb->prepare('SELECT id FROM ' . self::table() . ' WHERE recipient_user_id = %d AND dedupe_key = %s LIMIT 1', $recipient, $dedupe_key));
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht geprüft werden.', 'meinweimar-core'), ['status' => 503]);
}
if ($existing) {
return (int) $existing;
}
}
$wpdb->last_error = '';
$inserted = $wpdb->insert(self::table(), [
'recipient_user_id' => $recipient,
'type' => $type,
'target_type' => sanitize_key((string) ($notification['target_type'] ?? '')),
'target_id' => absint($notification['target_id'] ?? 0) ?: null,
'payload_json' => $payload_json,
'status' => 'unread',
'locale' => Settings::sanitize_locale($notification['locale'] ?? ''),
'dedupe_key' => $dedupe_key,
'created_at' => current_time('mysql', true),
'read_at' => null,
'expires_at' => null,
], ['%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
if ($inserted) {
return (int) $wpdb->insert_id;
}
$insert_error = (string) $wpdb->last_error;
if ($dedupe_key && $insert_error !== '') {
// A concurrent request may have won the recipient-scoped unique
// key between the preflight SELECT and this INSERT. Re-read the
// committed row so notification delivery remains idempotent.
$wpdb->last_error = '';
$existing = $wpdb->get_var($wpdb->prepare('SELECT id FROM ' . self::table() . ' WHERE recipient_user_id = %d AND dedupe_key = %s LIMIT 1', $recipient, $dedupe_key));
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht geprüft werden.', 'meinweimar-core'), ['status' => 503]);
}
if ($existing) {
return (int) $existing;
}
}
if ($insert_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht gespeichert werden.', 'meinweimar-core'), ['status' => 503]);
}
return new WP_Error('meinweimar_notification_store_failed', __('Die Benachrichtigung konnte nicht gespeichert werden.', 'meinweimar-core'), ['status' => 500]);
}
public static function list(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$status = sanitize_key((string) $request->get_param('status'));
$limit = max(1, min(100, absint($request->get_param('per_page') ?: 50)));
$where = 'recipient_user_id = %d';
$params = [get_current_user_id()];
if (in_array($status, ['unread', 'read'], true)) {
$where .= ' AND status = %s';
$params[] = $status;
}
$params[] = $limit;
$wpdb->last_error = '';
$rows = $wpdb->get_results($wpdb->prepare('SELECT id, type, target_type, target_id, payload_json, status, locale, created_at, read_at FROM ' . self::table() . ' WHERE ' . $where . ' ORDER BY created_at DESC LIMIT %d', ...$params), ARRAY_A);
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigungen sind derzeit nicht verfügbar.', 'meinweimar-core'), ['status' => 503]);
}
return new WP_REST_Response(['notifications' => array_map([self::class, 'normalize'], is_array($rows) ? $rows : [])]);
}
public static function mark_read(WP_REST_Request $request): WP_REST_Response|WP_Error
{
global $wpdb;
$id = absint($request['id']);
$wpdb->last_error = '';
$updated = $wpdb->query($wpdb->prepare('UPDATE ' . self::table() . ' SET status = %s, read_at = %s WHERE id = %d AND recipient_user_id = %d', 'read', current_time('mysql', true), $id, get_current_user_id()));
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht aktualisiert werden.', 'meinweimar-core'), ['status' => 503]);
}
if (!$updated) {
$wpdb->last_error = '';
$exists = $wpdb->get_var($wpdb->prepare('SELECT id FROM ' . self::table() . ' WHERE id = %d AND recipient_user_id = %d', $id, get_current_user_id()));
if ((string) $wpdb->last_error !== '') {
return new WP_Error('meinweimar_notification_storage_unavailable', __('Die Benachrichtigung konnte derzeit nicht geprüft werden.', 'meinweimar-core'), ['status' => 503]);
}
if (!$exists) {
return new WP_Error('meinweimar_notification_not_found', __('Benachrichtigung nicht gefunden.', 'meinweimar-core'), ['status' => 404]);
}
}
return new WP_REST_Response(['id' => $id, 'status' => 'read']);
}
public static function notify_image_suggestion_status(array $suggestion, string $status, string $reason_code = ''): int|WP_Error
{
$recipient = absint($suggestion['submitter_user_id'] ?? 0);
$id = absint($suggestion['id'] ?? 0);
if (!$recipient || !$id) {
return 0;
}
return self::create([
'recipient_user_id' => $recipient,
'type' => 'image_suggestion_status',
'target_type' => (string) ($suggestion['target_type'] ?? ''),
'target_id' => absint($suggestion['target_id'] ?? 0),
'payload' => [
'suggestion_id' => $id,
'status' => sanitize_key($status),
'reason_code' => sanitize_key($reason_code),
],
'dedupe_key' => 'image-suggestion:' . $id . ':' . sanitize_key($status),
]);
}
private static function normalize(array $row): array
{
$payload = json_decode((string) ($row['payload_json'] ?? ''), true);
return [
'id' => absint($row['id'] ?? 0),
'type' => (string) ($row['type'] ?? ''),
'target_type' => (string) ($row['target_type'] ?? ''),
'target_id' => absint($row['target_id'] ?? 0),
'payload' => is_array($payload) ? $payload : [],
'status' => (string) ($row['status'] ?? 'unread'),
'locale' => (string) ($row['locale'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
'read_at' => (string) ($row['read_at'] ?? ''),
];
}
private static function safe_payload(array $payload): array
{
$safe = [];
foreach ($payload as $key => $value) {
if (!is_scalar($value) || !preg_match('/^[a-z0-9_:-]{1,60}$/i', (string) $key)) {
continue;
}
$safe[sanitize_key((string) $key)] = self::text($value, 300);
}
return $safe;
}
private static function prepare_dedupe_key_migration(): bool
{
global $wpdb;
$table = self::table();
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '') {
return false;
}
if ($exists !== $table) {
// A fresh install has nothing to normalize; dbDelta() will
// create the table below.
return true;
}
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$columns = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($columns)) {
return false;
}
if (!in_array('dedupe_key', $columns, true)) {
// Older tables can be safely extended by dbDelta() first; there
// are no dedupe values to normalize yet.
return true;
}
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$duplicates = $wpdb->get_results("SELECT recipient_user_id, dedupe_key FROM {$table} WHERE dedupe_key <> '' GROUP BY recipient_user_id, dedupe_key HAVING COUNT(*) > 1", ARRAY_A);
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($duplicates)) {
return false;
}
foreach (is_array($duplicates) ? $duplicates : [] as $duplicate) {
$previous_error = (string) $wpdb->last_error;
$wpdb->last_error = '';
$rows = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM {$table} WHERE recipient_user_id = %d AND dedupe_key = %s ORDER BY id ASC",
(int) $duplicate['recipient_user_id'],
(string) $duplicate['dedupe_key']
));
$query_error = (string) $wpdb->last_error;
$wpdb->last_error = $previous_error;
if ($query_error !== '' || !is_array($rows)) {
return false;
}
array_shift($rows);
foreach ($rows as $id) {
$wpdb->last_error = '';
$updated = $wpdb->update($table, ['dedupe_key' => 'legacy-' . absint($id)], ['id' => absint($id)], ['%s'], ['%d']);
if (false === $updated || (string) $wpdb->last_error !== '') {
return false;
}
}
}
$wpdb->last_error = '';
$updated = $wpdb->query("UPDATE {$table} SET dedupe_key = CONCAT('legacy-', id) WHERE dedupe_key = ''");
return false !== $updated && (string) $wpdb->last_error === '';
}
private static function text($value, int $max): string
{
if (!is_scalar($value)) {
return '';
}
$value = sanitize_text_field((string) $value);
return function_exists('mb_substr') ? mb_substr($value, 0, $max) : substr($value, 0, $max);
}
private static function table(): string
{
global $wpdb;
return $wpdb->prefix . 'mw_notifications';
}
}

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%