Skip to content

Instantly share code, notes, and snippets.

@p32929
Created December 21, 2025 09:07
Show Gist options
  • Select an option

  • Save p32929/d8215c814a19bf84e7dd063527a898b4 to your computer and use it in GitHub Desktop.

Select an option

Save p32929/d8215c814a19bf84e7dd063527a898b4 to your computer and use it in GitHub Desktop.
Just a simple page to show some dynamic infos in flutter
// =============================================================================
// STANDALONE WIDGET - Can be shared via GitHub Gist
// =============================================================================
// This file has NO dependencies on other project files.
// Only uses Flutter SDK + common 3rd party packages.
//
// Required packages (add to pubspec.yaml):
// - http: ^1.2.2
// - url_launcher: ^6.3.1
// - package_info_plus: ^8.1.2
// - google_fonts: ^6.2.1 (optional, for custom fonts)
// =============================================================================
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../services/json_cache_service.dart';
// Design colors
const _darkBg = Color(0xFF121212);
const _cardBg = Color(0xFF1E1E1E);
const _accentMint = Color(0xFF98D8C8);
/// Dynamic Content Page
///
/// Fetches JSON from a URL and renders it as a beautiful UI.
/// Perfect for About pages, Terms of Service, Privacy Policy, etc.
///
/// JSON Format:
/// ```json
/// {
/// "basic": [
/// {
/// "title": "Name",
/// "desc": "John Doe",
/// "logo": "https://..."
/// }
/// ],
/// "contact": [
/// {
/// "title": "Email",
/// "link": "mailto:email@example.com",
/// "logo": "https://...",
/// "desc": "email@example.com"
/// }
/// ],
/// "social": [
/// {
/// "title": "GitHub",
/// "link": "https://github.com/username",
/// "logo": "https://..."
/// }
/// ]
/// }
/// ```
///
/// **Structure**:
/// - Top-level keys can be ANY name (e.g., "basic", "contact", "skills")
/// - Each key contains an array of items
/// - Section titles auto-generated from keys (e.g., "contact" → "Contact")
class DynamicContentPage extends StatefulWidget {
/// URL to fetch JSON data from (e.g., GitHub Gist raw URL)
final String jsonUrl;
/// Page title shown in AppBar
final String pageTitle;
/// Show app name at the top
final bool showAppName;
/// Show app version at the bottom
final bool showAppVersion;
/// Optional custom colors (falls back to defaults)
final Color? backgroundColor;
final Color? cardBackgroundColor;
final Color? accentColor;
const DynamicContentPage({
super.key,
required this.jsonUrl,
this.pageTitle = 'About',
this.showAppName = true,
this.showAppVersion = true,
this.backgroundColor,
this.cardBackgroundColor,
this.accentColor,
});
@override
State<DynamicContentPage> createState() => _DynamicContentPageState();
}
class _DynamicContentPageState extends State<DynamicContentPage> {
Map<String, dynamic>? _data;
bool _isLoading = true;
String? _errorMessage;
PackageInfo? _packageInfo;
// Color getters with fallbacks
Color get _bg => widget.backgroundColor ?? _darkBg;
Color get _card => widget.cardBackgroundColor ?? _cardBg;
Color get _accent => widget.accentColor ?? _accentMint;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
try {
// Load package info if needed
if (widget.showAppName || widget.showAppVersion) {
_packageInfo = await PackageInfo.fromPlatform();
}
// Fetch JSON from URL with caching
// Use URL as part of cache key to support multiple different URLs
final cacheKey = 'dynamic_content_${widget.jsonUrl.hashCode}';
final data = await JsonCacheService.fetchWithCache(
url: widget.jsonUrl,
cacheKey: cacheKey,
);
setState(() {
_data = data;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _bg,
appBar: AppBar(
backgroundColor: _bg,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: Text(
widget.pageTitle,
style: GoogleFonts.rajdhani(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
centerTitle: false,
),
body: _isLoading
? _buildLoadingState()
: _errorMessage != null
? _buildErrorState()
: _buildContent(),
);
}
Widget _buildLoadingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: _accent),
const SizedBox(height: 16),
Text(
'Loading...',
style: GoogleFonts.rajdhani(
fontSize: 16,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildErrorState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.white.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'Failed to load content',
style: GoogleFonts.rajdhani(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.7),
),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Unknown error',
style: GoogleFonts.rajdhani(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.5),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadData();
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: ElevatedButton.styleFrom(
backgroundColor: _accent,
foregroundColor: _bg,
),
),
],
),
),
);
}
Widget _buildContent() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App name at top
if (widget.showAppName && _packageInfo != null)
_buildAppHeader(),
const SizedBox(height: 16),
// Dynamic sections - iterate through all keys in JSON
...(_data?.entries ?? []).map((entry) {
final sectionKey = entry.key;
final sectionData = entry.value;
// Skip if not a list
if (sectionData is! List) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSection(sectionKey, sectionData),
const SizedBox(height: 24),
],
);
}),
],
),
);
}
Widget _buildAppHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App name card
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _card,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'App Name',
style: GoogleFonts.rajdhani(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.6),
),
),
const SizedBox(height: 4),
Text(
_packageInfo!.appName,
style: GoogleFonts.rajdhani(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
],
),
),
// App version card (if enabled)
if (widget.showAppVersion)
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _card,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Version',
style: GoogleFonts.rajdhani(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.6),
),
),
const SizedBox(height: 4),
Text(
'${_packageInfo!.version} (${_packageInfo!.buildNumber})',
style: GoogleFonts.rajdhani(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
],
),
),
],
);
}
Widget _buildSection(String sectionKey, List sectionData) {
// Convert key to title case (e.g., "contact" -> "Contact")
final sectionTitle = sectionKey[0].toUpperCase() + sectionKey.substring(1);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sectionTitle,
style: GoogleFonts.rajdhani(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 12),
...sectionData.map((item) => _buildItem(item as Map<String, dynamic>)),
],
);
}
Widget _buildItem(Map<String, dynamic> item) {
final hasLink = item['link'] != null;
final hasDesc = item['desc'] != null;
// Determine what to show below title:
// 1. If description exists, show description (even if link also exists)
// 2. Otherwise, if link exists, show link
// 3. Otherwise, show nothing below title
final String? secondaryText = hasDesc
? item['desc'] as String
: (hasLink ? item['link'] as String : null);
return GestureDetector(
onTap: hasLink ? () => _launchUrl(item['link'] as String) : null,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _card,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Logo
if (item['logo'] != null)
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
item['logo'] as String,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.image_not_supported,
color: Colors.white.withValues(alpha: 0.3),
size: 24,
),
),
),
),
if (item['logo'] != null) const SizedBox(width: 16),
// Title and description/link
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Always show title
Text(
item['title'] as String,
style: GoogleFonts.rajdhani(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.6),
),
),
// Show description or link (preference to description)
if (secondaryText != null) ...[
const SizedBox(height: 4),
Text(
secondaryText,
style: GoogleFonts.rajdhani(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
],
),
),
// Link arrow
if (hasLink)
Icon(
Icons.arrow_forward_ios,
color: _accent,
size: 16,
),
],
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment