mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
Porting the palette was not enough: the Warm Cinema charter only reached the widgets that read from AppColors. Nineteen screen and widget files still painted Apple TV values inline, so the app kept showing blue — most visibly on the selected navigation pill. Swept out: - 0xFF0A84FF (Apple blue) on the selected nav pill, category card icons and the "add playlist" button - 0xFF4FC3F7 and 0xFF1565C0 in the player overlays - Colors.blueAccent / Colors.blue in dialogs - Apple greys 1C1C1E / 2C2C2E / 3A3A3C / 8E8E93 -> warm charcoals - 128 Colors.white* -> the onSurfaceNN cream ladder (a pure white on a warm ground reads blue, which is the charter's whole point) - Colors.red / green / amber / yellow / grey* -> error, success, ratingGold, outlineVariant - pure black ink on light fills -> onPrimaryFixed, a warm near-black - the white focus glow -> an ember glow Deliberately left true black: the video surface backgrounds and the scrims over posters and video, where a warm tint would clash with the picture. Adds the onPrimaryFixed / primaryFixed / onSurface70 tokens the sweep needed. Verified: no Material or Apple colour literal remains outside lib/core/theme; flutter analyze reports no errors; 19/19 tests pass; release APK builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
264 lines
8.7 KiB
Dart
264 lines
8.7 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../core/theme/app_decorations.dart';
|
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
|
|
|
class MobileScaffold extends ConsumerStatefulWidget {
|
|
final Widget child;
|
|
final int currentIndex;
|
|
final Function(int) onIndexChanged;
|
|
|
|
const MobileScaffold({
|
|
super.key,
|
|
required this.child,
|
|
required this.currentIndex,
|
|
required this.onIndexChanged,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<MobileScaffold> createState() => _MobileScaffoldState();
|
|
}
|
|
|
|
class _MobileScaffoldState extends ConsumerState<MobileScaffold> {
|
|
late Stream<DateTime> _clockStream;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_clockStream =
|
|
Stream.periodic(const Duration(seconds: 1), (_) => DateTime.now());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
|
body: Column(
|
|
children: [
|
|
// Top Navigation Bar (Redesigned)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 24,
|
|
horizontal: 24,
|
|
), // Increased vertical padding for floating effect
|
|
decoration: const BoxDecoration(
|
|
color: Colors.transparent, // Transparent background
|
|
),
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: Container(
|
|
height: 64, // Taller pill to accommodate larger logo
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: AppDecorations.navPill(context),
|
|
child: Row(
|
|
children: [
|
|
// --- LEFT: LOGO & BRAND ---
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 8,
|
|
right: 32,
|
|
), // More spacing after logo
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 52, // Larger Logo
|
|
height: 52,
|
|
// No decoration for free-floating logo
|
|
child: Image.asset(
|
|
'assets/images/logo_xtremflow.png',
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (_, __, ___) => const Icon(
|
|
Icons.play_circle_filled,
|
|
color: AppColors.onSurface,
|
|
size: 40,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
'XtremFlow',
|
|
style: TextStyle(
|
|
color: AppDecorations.textPrimary(context),
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: 'Inter',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// --- CENTER: NAVIGATION ---
|
|
Expanded(
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildNavItem(0, 'Live TV', Icons.tv),
|
|
const SizedBox(width: 32), // Increased spacing
|
|
_buildNavItem(1, 'Movies', Icons.movie),
|
|
const SizedBox(width: 32),
|
|
_buildNavItem(2, 'Series', Icons.video_library),
|
|
const SizedBox(width: 32),
|
|
_buildNavItem(3, 'Settings', Icons.settings),
|
|
],
|
|
),
|
|
),
|
|
|
|
// --- RIGHT: CLOCK ONLY ---
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 32, right: 16),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppDecorations.divider(context),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: StreamBuilder<DateTime>(
|
|
stream: _clockStream,
|
|
initialData: DateTime.now(),
|
|
builder: (context, snapshot) {
|
|
final time =
|
|
DateFormat('HH:mm').format(snapshot.data!);
|
|
return Text(
|
|
time,
|
|
style: TextStyle(
|
|
color: AppDecorations.textPrimary(context),
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Main Content
|
|
Expanded(child: widget.child),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNavItem(int index, String label, IconData icon) {
|
|
final isSelected = widget.currentIndex == index;
|
|
return _AppleTVNavItem(
|
|
isSelected: isSelected,
|
|
icon: icon,
|
|
label: label,
|
|
onPressed: () => widget.onIndexChanged(index),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Apple TV style navigation item - matches desktop style
|
|
class _AppleTVNavItem extends StatefulWidget {
|
|
final bool isSelected;
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
|
|
const _AppleTVNavItem({
|
|
required this.isSelected,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onPressed,
|
|
});
|
|
|
|
@override
|
|
State<_AppleTVNavItem> createState() => _AppleTVNavItemState();
|
|
}
|
|
|
|
class _AppleTVNavItemState extends State<_AppleTVNavItem> {
|
|
final FocusNode _focusNode = FocusNode();
|
|
bool _isFocused = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_focusNode.addListener(_onFocusChange);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_focusNode.removeListener(_onFocusChange);
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onFocusChange() {
|
|
setState(() => _isFocused = _focusNode.hasFocus);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const Color selectedColor = AppColors.primaryFill;
|
|
final Color unselectedColor = AppDecorations.textSecondary(context);
|
|
final Color textColor =
|
|
widget.isSelected ? AppColors.onSurface : unselectedColor;
|
|
final Color iconColor =
|
|
widget.isSelected ? AppColors.onSurface : unselectedColor;
|
|
final FontWeight fontWeight =
|
|
widget.isSelected ? FontWeight.w700 : FontWeight.w500;
|
|
|
|
return Focus(
|
|
focusNode: _focusNode,
|
|
onKeyEvent: (node, event) {
|
|
if (event is KeyDownEvent) {
|
|
final key = event.logicalKey;
|
|
if (key == LogicalKeyboardKey.select ||
|
|
key == LogicalKeyboardKey.enter ||
|
|
key == LogicalKeyboardKey.space) {
|
|
widget.onPressed();
|
|
return KeyEventResult.handled;
|
|
}
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: GestureDetector(
|
|
onTap: widget.onPressed,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: widget.isSelected
|
|
? selectedColor
|
|
: (_isFocused
|
|
? AppDecorations.divider(context).withValues(alpha: 0.2)
|
|
: Colors.transparent),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: _isFocused && !widget.isSelected
|
|
? Border.all(color: AppDecorations.divider(context))
|
|
: null,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(widget.icon, color: iconColor, size: 18),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
widget.label,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontWeight: fontWeight,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|