Created
December 21, 2025 09:07
-
-
Save p32929/d8215c814a19bf84e7dd063527a898b4 to your computer and use it in GitHub Desktop.
Just a simple page to show some dynamic infos in flutter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================================= | |
| // 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